diff --git a/.env.testing b/.env.testing new file mode 100644 index 00000000000..315d23f1016 --- /dev/null +++ b/.env.testing @@ -0,0 +1 @@ +FOO_BAR_SECRET=foo_bar_secret diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000000..18b0b39652b --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Adding ruff UP rules +451981485d4c0fa72ebfca081342e16405909f7d diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..887a2c18f01 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# SCM syntax highlighting & preventing 3-way merges +pixi.lock merge=binary linguist-language=YAML linguist-generated=true diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 8dc6d359b6e..9f6eb26c08d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -18,17 +18,42 @@ body: placeholder: What happened? validations: required: true + - type: checkboxes + id: will-submit-pr + attributes: + label: Will you submit a PR? + description: | + If you plan to submit a PR for this issue, please let us know. We'd love to review a PR! + options: + - label: 'Yes' - type: textarea id: env attributes: label: Environment - description: Please paste the output of `marimo env` - placeholder: marimo env + description: | + In marimo, open **Send feedback → Report an issue** and click **Open GitHub issue** to prefill this field, or copy the environment details with the copy icon and paste below. Otherwise, paste the output of `marimo env`. + placeholder: | + Paste the copied environment details here; they render as a collapsible section. If you ran `marimo env` instead, paste its output. validations: required: true - type: textarea id: reproduction-code attributes: label: Code to reproduce - description: If possible, paste a minimal reproducible example or link to a repository that reproduces the issue. - placeholder: import marimo + description: | + Help us help you! + If possible, paste a minimal reproducible example or link to a repository that reproduces the issue. + And use `marimo edit --sandbox` to include the package dependencies in the bug report. + placeholder: | + ```python + # /// script + # requires-python = ">=3.11" + # dependencies = [ + # "marimo", + # ] + # /// + + import marimo + ... + + ``` diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 96bafb2df92..d2d484f58dd 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: true contact_links: + - name: VS Code Extension Issues + url: https://github.com/marimo-team/marimo-lsp/issues/new/choose + about: For issues with the marimo VS Code extension, please open an issue in the marimo-lsp repository. - name: Discord Chat url: https://marimo.io/discord?ref=issues about: Ask questions and discuss with other marimo users. diff --git a/.github/ISSUE_TEMPLATE/documentation.yaml b/.github/ISSUE_TEMPLATE/documentation.yaml index 3ec2e4b3af1..410aa9d30e0 100644 --- a/.github/ISSUE_TEMPLATE/documentation.yaml +++ b/.github/ISSUE_TEMPLATE/documentation.yaml @@ -29,3 +29,11 @@ body: label: Your Suggestion for Changes validations: required: true + - type: checkboxes + id: will-submit-pr + attributes: + label: Will you submit a PR? + description: | + If you plan to submit a PR for this issue, please let us know. We'd love to review a PR! + options: + - label: 'Yes' diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 46525b4fcb4..159db7b980b 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -1,7 +1,7 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/SchemaStore/schemastore/refs/heads/master/src/schemas/json/github-issue-forms.json name: '🚀 New feature proposal' description: Propose a new feature to be added to marimo -labels: [enhancement] +labels: [enhancement, needs discussion] type: Feature body: - type: markdown @@ -12,21 +12,25 @@ body: id: feature-description attributes: label: Description - description: 'Clear and concise description of the problem. Please make the reason and usecases as detailed as possible. If you intend to submit a PR for this issue, tell us in the description. Thanks!' - placeholder: I am hoping to be able to... + description: 'A clear and concise description of the problem you are facing. Please describe your use case in detail. Thanks!' + placeholder: I am hoping to be able to ... validations: required: true - - type: textarea - id: suggested-solution + - type: checkboxes + id: will-submit-pr attributes: - label: Suggested solution - validations: - required: true + label: Are you willing to submit a PR? (You must receive approval from the team before submitting a PR.) + description: | + If you are interested in submitting a PR, please check this box. + However, please confirm with us on whether your proposed feature + fits with our roadmap before submitting a PR. + options: + - label: 'Yes' - type: textarea id: alternative attributes: - label: Alternative - description: "Clear and concise description of any alternative solutions or features you've considered." + label: Alternatives + description: "Alternatives you've considered." - type: textarea id: additional-context attributes: diff --git a/.github/ISSUE_TEMPLATE/molab.yaml b/.github/ISSUE_TEMPLATE/molab.yaml new file mode 100644 index 00000000000..df8f30d06f7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/molab.yaml @@ -0,0 +1,79 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/SchemaStore/schemastore/refs/heads/master/src/schemas/json/github-issue-forms.json +name: '☁️ molab issue' +description: Report an issue with molab (cloud-hosted marimo notebooks) +labels: [molab] +body: + - type: markdown + attributes: + value: | + Thank you for reporting this molab issue! This template is for issues specific to [molab](https://molab.marimo.io), our cloud-hosted notebook environment. For issues with the open-source marimo library itself, please use the [bug report](https://github.com/marimo-team/marimo/issues/new?template=bug_report.yaml) template instead. + - type: textarea + id: description + attributes: + label: Describe the issue + description: | + A clear and concise description of the issue you're experiencing. + You may include screenshots or screen recordings here. + placeholder: What happened? + validations: + required: true + - type: input + id: notebook-url + attributes: + label: Notebook URL (optional) + description: | + If you're comfortable sharing it publicly, link to the molab notebook where you encountered the issue. This helps us reproduce and debug faster. + placeholder: https://molab.marimo.io/notebooks/nb_... + validations: + required: false + - type: input + id: marimo-version + attributes: + label: marimo version (optional) + description: | + If possible, run `import marimo; marimo.__version__` in a cell and paste the result here. + placeholder: e.g. 0.20.0 + validations: + required: false + - type: dropdown + id: category + attributes: + label: Issue category + description: What area of molab does this relate to? + options: + - Notebook editor + - Package management + - Storage / file uploads + - Sharing / embedding + - GitHub previews + - Performance + - Authentication / account + - Other + validations: + required: true + - type: textarea + id: reproduction-steps + attributes: + label: Steps to reproduce + description: Walk us through how to reproduce the issue. + placeholder: | + 1. Open a notebook + 2. ... + 3. See error + validations: + required: false + - type: textarea + id: expected-behavior + attributes: + label: Expected behavior + description: What did you expect to happen? + validations: + required: false + - type: input + id: browser + attributes: + label: Browser (optional) + description: Which browser are you using? + placeholder: e.g. Chrome 130, Firefox 131, Safari 18 + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c69b49640d8..a3ace1b66b3 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,29 +1,21 @@ ## 📝 Summary - -## 🔍 Description of Changes - - +Closes # -## 📋 Checklist +## 📋 Pre-Review Checklist + -- [ ] I have read the [contributor guidelines](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md). - [ ] For large changes, or changes that affect the public API: this change was discussed or approved through an issue, on [Discord](https://marimo.io/discord?ref=pr), or the community [discussions](https://github.com/marimo-team/marimo/discussions) (Please provide a link if applicable). -- [ ] I have added tests for the changes made. -- [ ] I have run the code and verified that it works as expected. +- [ ] Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it. +- [ ] Video or media evidence is provided for any visual changes (optional). -## 📜 Reviewers +## ✅ Merge Checklist - - -@akshayka OR @mscolnick +- [ ] I have read the [contributor guidelines](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md). +- [ ] Documentation has been updated where applicable, including docstrings for API changes. +- [ ] Tests have been added for the changes made. diff --git a/.github/actions/build-frontend/action.yaml b/.github/actions/build-frontend/action.yaml new file mode 100644 index 00000000000..4517b04c79a --- /dev/null +++ b/.github/actions/build-frontend/action.yaml @@ -0,0 +1,36 @@ +name: Build Frontend +description: Setup pnpm, Node.js, and build the frontend + +inputs: + node-version: + description: 'Node.js version to use' + required: false + default: '24' + turbo-token: + description: 'Turbo token for caching' + required: false + codecov-token: + description: 'Codecov token' + required: false + +runs: + using: 'composite' + + steps: + - name: ⎔ Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + + - name: ⎔ Setup Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: ${{ inputs.node-version }} + cache: pnpm + cache-dependency-path: '**/pnpm-lock.yaml' + + - name: 📦 Build frontend + shell: bash + run: make fe + env: + TURBO_TOKEN: ${{ inputs.turbo-token }} + TURBO_TEAM: marimo + CODECOV_TOKEN: ${{ inputs.codecov-token }} diff --git a/.github/actions/install/action.yaml b/.github/actions/install/action.yaml new file mode 100644 index 00000000000..41b3c7bf148 --- /dev/null +++ b/.github/actions/install/action.yaml @@ -0,0 +1,20 @@ +name: Install Repo +description: Checkout repo and install the ts toolchain and dependencies + +runs: + using: 'composite' + + steps: + - name: ⎔ Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + + - name: ⎔ Setup Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: '**/pnpm-lock.yaml' + + - name: 📥 Install dependencies + shell: bash + run: pnpm install --ignore-scripts --frozen-lockfile diff --git a/.github/actions/pr-comment-on-failure/action.yaml b/.github/actions/pr-comment-on-failure/action.yaml new file mode 100644 index 00000000000..d66ffde5ace --- /dev/null +++ b/.github/actions/pr-comment-on-failure/action.yaml @@ -0,0 +1,20 @@ +name: "Job Summary on Failure" +description: "Adds a job summary when checks fail, helping contributors fix issues" +inputs: + check-name: + description: "The name of the check that failed" + required: true + command: + description: "The command that contributors should run" + required: true + +runs: + using: "composite" + steps: + - name: Add failure summary + shell: bash + env: + CHECK_NAME: ${{ inputs.check-name }} + COMMAND: ${{ inputs.command }} + run: | + printf '### 👋 The **%s** check failed\n\nTo fix this locally, please run:\n\n```bash\n%s\n```\n' "$CHECK_NAME" "$COMMAND" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/actions/test-python/action.yaml b/.github/actions/test-python/action.yaml new file mode 100644 index 00000000000..e083c229802 --- /dev/null +++ b/.github/actions/test-python/action.yaml @@ -0,0 +1,111 @@ +name: Test Python +description: Run the marimo Python test suite for a given Python version and dependency set. Assumes the repo has already been checked out. + +inputs: + python-version: + description: Python version to test against. + required: true + dependencies: + description: Dependency group — "core", "core,optional", or "minimal". + required: true + +runs: + using: composite + steps: + # Use uv's managed Python rather than the hostedtoolcache build from + # actions/setup-python: the hostedtoolcache interpreter has ABI quirks + # that caused numpy/matplotlib lazy submodule imports to fail on CI. + - name: 🐍 Setup uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + python-version: ${{ inputs.python-version }} + enable-cache: true + + # This step is needed since some of our tests rely on the index.html file + - name: Create assets directory, copy over index.html + shell: bash + run: | + mkdir -p marimo/_static/assets + cp frontend/index.html marimo/_static/index.html + cp frontend/public/favicon.ico marimo/_static/favicon.ico + + # Setup test flags based on conditions + - name: Setup test flags + id: setup-flags + shell: bash + run: | + # Set CHANGED_FROM: On PRs, compare against base branch; on main, compare against HEAD~1 + CHANGED_FROM="${{ github.base_ref && format('origin/{0}', github.base_ref) || 'HEAD~1' }}" + echo "changed_from=$CHANGED_FROM" + echo "changed_from=$CHANGED_FROM" >> $GITHUB_OUTPUT + + # Set INCLUDE_UNCHANGED: true if test-all label, python 3.13 + optional deps, or main branch + if [[ "${{ contains(github.event.pull_request.labels.*.name, 'test-all') }}" == "true" ]] || \ + [[ "${{ inputs.python-version }}" == "3.13" && "${{ inputs.dependencies }}" == "core,optional" ]] || \ + [[ "${{ github.ref }}" == "refs/heads/main" ]]; then + echo "include_unchanged=true" + echo "include_unchanged=true" >> $GITHUB_OUTPUT + else + echo "include_unchanged=false" + echo "include_unchanged=false" >> $GITHUB_OUTPUT + fi + + # Test with base dependencies + # Exit code 5 = no tests collected (e.g. only CLI test files changed); + # treat that as success since CLI tests run in a separate workflow. + # + # xdist is disabled when the `test-serial` label is applied to a PR, + # as an escape hatch when parallelism causes flakes that block a merge. + - name: Test changed with base dependencies + if: ${{ inputs.dependencies == 'core' }} + shell: bash + run: | + uv run --python ${{ inputs.python-version }} --group test pytest tests/ \ + -v \ + ${{ !contains(github.event.pull_request.labels.*.name, 'test-serial') && '-n auto' || '-p no:xdist' }} \ + -k "not test_cli" \ + --durations=10 \ + -p packages.pytest_changed \ + --changed-from=${{ steps.setup-flags.outputs.changed_from }} \ + --include-unchanged=${{ steps.setup-flags.outputs.include_unchanged }} \ + --picked=first \ + --inline-snapshot=disable \ + || { ec=$?; [ $ec -eq 5 ] && exit 0 || exit $ec; } + + # Test with optional dependencies + - name: Test changed with optional dependencies + if: ${{ inputs.dependencies == 'core,optional' }} + shell: bash + run: | + uv run --python ${{ inputs.python-version }} --group test-optional pytest tests/ \ + -v \ + ${{ !contains(github.event.pull_request.labels.*.name, 'test-serial') && '-n auto' || '-p no:xdist' }} \ + -k "not test_cli" \ + --durations=10 \ + -p packages.pytest_changed \ + --changed-from=${{ steps.setup-flags.outputs.changed_from }} \ + --include-unchanged=${{ steps.setup-flags.outputs.include_unchanged }} \ + --picked=first \ + --inline-snapshot=disable \ + || { ec=$?; [ $ec -eq 5 ] && exit 0 || exit $ec; } + + # Test with minimal dependencies using lowest resolution + # https://docs.astral.sh/uv/concepts/resolution/#lowest-resolution + # https://docs.astral.sh/uv/reference/environment/#uv_resolution + - name: Test with minimal dependencies (lowest resolution) + if: ${{ inputs.dependencies == 'minimal' }} + shell: bash + env: + UV_RESOLUTION: lowest-direct + run: | + uv run --python ${{ inputs.python-version }} --group test pytest tests/ \ + -v \ + ${{ !contains(github.event.pull_request.labels.*.name, 'test-serial') && '-n auto' || '-p no:xdist' }} \ + -k "not test_cli" \ + --durations=10 \ + -p packages.pytest_changed \ + --changed-from=${{ steps.setup-flags.outputs.changed_from }} \ + --include-unchanged=${{ steps.setup-flags.outputs.include_unchanged }} \ + --picked=first \ + --inline-snapshot=disable \ + || { ec=$?; [ $ec -eq 5 ] && exit 0 || exit $ec; } diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000000..9cc6d45ba49 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,8 @@ +# Code Review Process + +1. If the PR seems AI-generated, you should request the contributor to: + - Provide screenshots of the fixed output (if possible) + - Check if it satisfies edge cases instead of just the direct problem it addresses. + +For example, if there is a frontend change to make a border thicker, how does this look in run mode, app-view and edit-view? +For a backend change, has the user tested different scenarios? diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000000..e459edb69ea --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,16 @@ +documentation: + - changed-files: + - any-glob-to-any-file: ['docs/**'] +bash-focus: + - changed-files: + - any-glob-to-any-file: ['packages/openapi/api.yaml', 'frontend/index.html'] +dependencies: + - changed-files: + - any-glob-to-any-file: ['.pre-commit-config.yaml', 'pnpm-lock.yaml'] +# Only valid once this is merged: https://github.com/actions/labeler/pull/866 +# enhancement: +# - title: ['^feat:', '^enhancement:'] +# internal: +# - title: ['^internal:', '^ci:'] +# preview: +# - title: ['^preview:', '^experimental:'] diff --git a/.github/problemMatchers/mypy.json b/.github/problemMatchers/mypy.json new file mode 100644 index 00000000000..a17bfa1b5ec --- /dev/null +++ b/.github/problemMatchers/mypy.json @@ -0,0 +1,17 @@ +{ + "problemMatcher": [ + { + "owner": "mypy", + "severity": "error", + "pattern": [ + { + "regexp": "^([^:]+):(\\d+): (error|warning): (.+)$", + "file": 1, + "line": 2, + "severity": 3, + "message": 4 + } + ] + } + ] +} diff --git a/.github/problemMatchers/tsc.json b/.github/problemMatchers/tsc.json new file mode 100644 index 00000000000..ec32b558cdc --- /dev/null +++ b/.github/problemMatchers/tsc.json @@ -0,0 +1,19 @@ +{ + "problemMatcher": [ + { + "owner": "tsc", + "severity": "error", + "pattern": [ + { + "regexp": "^.*?([^\\s(]+)\\((\\d+),(\\d+)\\): (error|warning) (TS\\d+): (.+)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "code": 5, + "message": 6 + } + ] + } + ] +} diff --git a/.github/renovate.json b/.github/renovate.json deleted file mode 100644 index b57d8c0e4b2..00000000000 --- a/.github/renovate.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["config:base", "schedule:monthly"], - "labels": ["dependencies"], - "pin": false, - "rangeStrategy": "bump", - "postUpdateOptions": ["pnpmDedupe"], - "python": { - "enabled": true, - "packagePatterns": [".*sphinx.*", ".*ruff.*"] - }, - "pre-commit": { - "enabled": true - }, - "pep621": { - "enabled": true - }, - "packageRules": [ - { - "depTypeList": ["peerDependencies", "project.dependencies"], - "enabled": false - }, - { - "groupName": "pre-commit dependencies", - "matchManagers": ["pre-commit"], - "description": "Weekly update of pre-commit dependencies" - }, - { - "packageNames": ["node"], - "enabled": false - }, - { - "matchUpdateTypes": ["minor", "patch"], - "groupName": "all non-major dependencies", - "groupSlug": "all-minor-patch", - "excludePackagePatterns": [".*eslint.*", ".*radix.*", ".*storybook.*"], - "excludePackageNames": [ - "geopandas", - "typescript", - "react-resizable-panels", - "openapi-fetch" - ] - }, - { - "packagePatterns": [".*radix.*"], - "groupName": "all radix dependencies", - "groupSlug": "all-radix", - "matchUpdateTypes": ["patch"] - }, - { - "packagePatterns": [".*storybook.*"], - "groupName": "all storybook dependencies", - "groupSlug": "all-storybook", - "matchUpdateTypes": ["minor", "patch", "major"] - }, - { - "packagePatterns": [".*eslint.*"], - "groupName": "all eslint dependencies", - "groupSlug": "all-eslint", - "matchUpdateTypes": ["minor", "patch", "major"] - } - ] -} diff --git a/.github/renovate.json5 b/.github/renovate.json5 new file mode 100644 index 00000000000..94d1119f352 --- /dev/null +++ b/.github/renovate.json5 @@ -0,0 +1,131 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended", "schedule:monthly"], + "minimumReleaseAge": "7 days", + "labels": ["dependencies"], + "pin": { + "enabled": false + }, + "rangeStrategy": "bump", + "postUpdateOptions": ["pnpmDedupe"], + // Requires opt-in + "pre-commit": { + "enabled": true + }, + "packageRules": [ + // Don't upgrade npm peerDependencies or python project dependencies + { + "matchDepTypes": ["peerDependencies", "project.dependencies"], + "enabled": false + }, + // Don't upgrade node + { + "matchPackageNames": ["node"], + "enabled": false + }, + // Only upgrade ruff, mkdocs, pytest + { + "groupName": "ruff, mkdocs, pytest", + "matchManagers": ["pep621"], + "matchPackageNames": ["ruff**", "mkdocs**", "pytest**"] + }, + // Upgrade pre-commit dependencies weekly + { + "groupName": "pre-commit dependencies", + "matchManagers": ["pre-commit"], + "matchPackagePatterns": ["*"], + "description": "Weekly update of pre-commit dependencies" + }, + // Group all GitHub Actions dependencies + { + "groupName": "GitHub Actions", + "matchManagers": ["github-actions"], + "matchPackagePatterns": ["*"], + "matchUpdateTypes": ["minor", "patch", "major"] + }, + // Group all Docker dependencies + { + "groupName": "Docker updates", + "matchDatasources": ["docker"], + "matchPackagePatterns": ["*"], + "matchUpdateTypes": ["minor", "patch", "major"] + }, + // Upgrade all npm non-major dependencies + { + "groupName": "all npm non-major dependencies", + "matchManagers": ["npm"], + "matchUpdateTypes": ["minor", "patch"], + "matchPackageNames": [ + "!/.*eslint.*/", + "!/.*radix.*/", + "!/.*storybook.*/", + "!/.*typescript.*/", + "!/.*openapi-fetch.*/", + "!/.*react-aria.*/", + "!/.*@oxc.*/", + // react-hook-form is pinned to 7.54.2 + "!/.*react-hook-form.*/", + // pyodide needs to be manually updated + "!/.*pyodide.*/" + ], + "schedule": ["on the 1st day of the month"] + }, + // Group all CodeMirror dependencies + { + "groupName": "codemirror dependencies", + "matchPackageNames": ["@codemirror/**", "@lezer/**", "@uiw/codemirror-**", "codemirror-**"], + "matchUpdateTypes": ["minor", "patch", "major"], + "schedule": ["on the 7th day of the month"] + }, + // Group React Aria dependencies + { + "groupName": "react-aria dependencies", + "matchPackageNames": ["@react-aria/**", "react-aria**"], + "matchUpdateTypes": ["minor", "patch", "major"], + "schedule": ["on the 14th day of the month"] + }, + // Group testing dependencies + { + "groupName": "testing dependencies", + "matchPackageNames": ["@testing-library/**", "@playwright/**", "vitest**"], + "matchDepTypes": ["devDependencies"], + "schedule": ["on the 21st day of the month"] + }, + // Group build tools + { + "groupName": "build tools", + "matchPackageNames": ["/.*vite.*/", "typescript", "postcss", "/.*tailwind.*/", "autoprefixer"], + "matchDepTypes": ["devDependencies"], + "matchUpdateTypes": ["minor", "patch"], + "schedule": ["on the 23rd day of the month"] + }, + // Group all radix dependencies + { + "groupName": "all radix dependencies", + "matchUpdateTypes": ["patch", "minor", "major"], + "matchPackageNames": ["/.*radix.*/"], + "schedule": ["on the 25th day of the month"] + }, + // Group all storybook dependencies + { + "groupName": "all storybook dependencies", + "matchUpdateTypes": ["minor", "patch", "major"], + "matchPackageNames": ["/.*storybook.*/"], + "schedule": ["on the 27th day of the month"] + }, + // Group all eslint dependencies + { + "groupName": "all eslint dependencies", + "matchUpdateTypes": ["minor", "patch", "major"], + "matchPackageNames": ["/.*eslint.*/"], + "schedule": ["on the 28th day of the month"] + }, + // Group all oxc dependencies + { + "groupName": "all oxc dependencies", + "matchUpdateTypes": ["minor", "patch", "major"], + "matchPackageNames": ["/.*@oxc.*/", "oxlint", "oxfmt"], + "schedule": ["on the 29th day of the month"] + } + ] +} diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 59f5f5eb841..87b907af48a 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -3,38 +3,59 @@ on: issue_comment: types: [created] pull_request_target: - types: [opened, closed, synchronize] + types: [opened, closed, reopened, synchronize] -# explicitly configure permissions, in case your GITHUB_TOKEN workflow permissions are set to read-only in repository settings -permissions: - actions: write - contents: write - pull-requests: write - statuses: write +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: false # Don't cancel CLA checks + +permissions: {} jobs: CLAAssistant: runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: write + contents: read # signatures are written to marimo-team/cla-signers with the PAT + pull-requests: write + statuses: write + id-token: write # required for Doppler OIDC + # Write scopes on a pull_request_target job are safe only while nothing + # here checks out or executes PR-controlled content. Don't add a checkout + # or build step. steps: + - name: 🔐 Fetch Doppler secrets + if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' + uses: dopplerhq/secrets-fetch-action@cd2efbf9a404504316435873eff298b82f7e0562 # v1.3.0 + id: secrets + with: + auth-method: oidc + doppler-identity-id: ${{ vars.DOPPLER_IDENTITY_ID }} + doppler-project: marimo-oss + doppler-config: ci_cla + - name: "CLA Assistant" if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' - uses: contributor-assistant/github-action@v2.5.1 + # maintained fork of the archived contributor-assistant/github-action + uses: iainmcgin/cla-github-action@0d27e5a16278d4adb6b0c4b92f08ad27b0a21dc8 # v3.2.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # the below token should have repo scope and must be manually added by you in the repository's secret - # This token is required only if you have configured to store the signatures in a remote repository/organization - PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + PERSONAL_ACCESS_TOKEN: ${{ steps.secrets.outputs.PERSONAL_ACCESS_TOKEN }} with: path-to-signatures: "signatures/version1/cla.json" path-to-document: "https://marimo.io/cla" # e.g. a CLA or a DCO document # branch should not be protected branch: "main" - allowlist: user1,bot* + # AI coding agents add Co-authored-by trailers; they cannot sign a CLA. + allowlist: bot*,copilot,Copilot,Codex,Cursor,noreply@anthropic.com,cursoragent@cursor.com,codex@openai.com - # the followings are the optional inputs - If the optional inputs are not given, then default values will be taken + # the following are the optional inputs - If the optional inputs are not given, then default values will be taken remote-organization-name: marimo-team remote-repository-name: cla-signers lock-pullrequest-aftermerge: false #- if you don't want this bot to automatically lock the pull request after merging (default - true) + # fail the check if the PR opener authored none of the commits + require-opener-as-author: true #create-file-commit-message: 'For example: Creating file for storing CLA Signatures' #signed-commit-message: 'For example: $contributorName has signed the CLA in $owner/$repo#$pullRequestNo' #custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign' diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml deleted file mode 100644 index 0813defcfaf..00000000000 --- a/.github/workflows/dev-release.yml +++ /dev/null @@ -1,151 +0,0 @@ -name: Publish dev release of marimo-base - -# Publish development release to test pypi on pushes to main -on: - push: - branches: - - main - -env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: marimo - -jobs: - publish_dev_release: - name: 📤 Publish dev release - runs-on: ubuntu-latest - defaults: - run: - shell: bash - - steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 - - - name: ⬇️ Checkout repo - uses: actions/checkout@v4 - with: - # get tag history for version number - fetch-depth: 0 - - - uses: pnpm/action-setup@v2 - with: - version: 9 - - - name: ⎔ Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: 'pnpm' - cache-dependency-path: '**/pnpm-lock.yaml' - registry-url: 'https://registry.npmjs.org' - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: 📦 Build frontend - run: make fe - - - name: 🥚 Install Hatch - uses: pypa/hatch@install - - - name: Adapt pyproject.toml to build marimo-base - run: ./scripts/modify_pyproject_for_marimo_base.sh - - # patch __init__.py version to be of the form - # X.Y.-dev{n-commits-since-last-tag} - - name: 🔨 Patch version number - run: | - # Get the version number and increment patch - # - assumes version is on a line of the form __version__ == "x.y.z" - incremented_version=`grep '__version__' marimo/__init__.py | awk '{print $3}' | awk -F. '{printf "%d.%d.%d", $1, $2, $3+1}'` - # Get the number of commits since last tag - n_commits=`git rev-list $(git describe --tags --abbrev=0)..HEAD --count` - # Form the new version, which is one patch ahead of the last version - # so installing from Test PyPI does the right thing - MARIMO_VERSION="${incremented_version}-dev${n_commits}" - # Set the version in the environment for later steps - echo "MARIMO_VERSION=$MARIMO_VERSION" >> $GITHUB_ENV - sed -i "s/__version__ = \".*\"/__version__ = \"$MARIMO_VERSION\"/" marimo/__init__.py - - - name: 📦 Build marimo - run: hatch build --clean - - - name: 📦 Validate wheel under 2mb - run: ./scripts/validate_base_wheel_size.sh - - - name: 📤 Upload to TestPyPI - env: - HATCH_INDEX_USER: ${{ secrets.TEST_PYPI_USER }} - HATCH_INDEX_AUTH: ${{ secrets.TEST_PYPI_MARIMO_BASE_PASSWORD }} - run: hatch publish --repo test - - - name: 📦 Update package.json version from CLI - working-directory: frontend - run: | - echo "Updating package.json version to ${{ env.MARIMO_VERSION }}" - npm version ${{ env.MARIMO_VERSION }} --no-git-tag-version - - - name: 📤 Upload wasm to npm - working-directory: frontend - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - for i in {1..3}; do - npm publish --access public && break || { - echo "Publish attempt $i failed, retrying..." - sleep 10 - } - done - - - name: 📦 Update package.json name to @marimo-team/islands - working-directory: frontend - run: | - sed -i 's/"name": "@marimo-team\/frontend"/"name": "@marimo-team\/islands"/' package.json - - - name: 📦 Rebuild frontend - working-directory: frontend - env: - NODE_ENV: production - VITE_MARIMO_ISLANDS: 'true' - VITE_MARIMO_VERSION: ${{ env.MARIMO_VERSION }} - run: | - pnpm turbo build:islands - ./islands/validate.sh - - - name: 📤 Upload islands to npm - working-directory: frontend - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - for i in {1..3}; do - npm publish --access public && break || { - echo "Publish attempt $i failed, retrying..." - sleep 10 - } - done - - - name: 📝 Comment PR - uses: actions/github-script@v7 - continue-on-error: true - with: - script: | - try { - const pullRequest = await github.rest.search.issuesAndPullRequests({ - q: `sha:${context.sha} is:pr is:merged` - }); - - if (pullRequest.data.items.length > 0) { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.data.items[0].number, - body: `🚀 Development release published. You may be able to view the changes at https://marimo.app?v=${process.env.MARIMO_VERSION}` - }); - } else { - console.log("No merged PR found for this SHA."); - } - } catch (err) { - console.error(err); - } diff --git a/.github/workflows/dev_build.yaml b/.github/workflows/dev_build.yaml new file mode 100644 index 00000000000..095169cfe57 --- /dev/null +++ b/.github/workflows/dev_build.yaml @@ -0,0 +1,29 @@ +name: Dev Build + +on: + pull_request: + paths: + - 'Makefile' + - 'pyproject.toml' + +permissions: + contents: read + +jobs: + build: + timeout-minutes: 20 + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: prefix-dev/setup-pixi@82d477f15f3a381dbcc8adc1206ce643fe110fb7 # v0.9.3 + with: + pixi-version: v0.62.2 + locked: false + frozen: false + cache: false + - run: pixi run make check-prereqs + - run: pixi run make fe + - run: pixi run make py diff --git a/.github/workflows/discord-release.yml b/.github/workflows/discord-release.yml index 349a3c36c32..f1964646922 100644 --- a/.github/workflows/discord-release.yml +++ b/.github/workflows/discord-release.yml @@ -4,15 +4,22 @@ on: release: types: [published] +concurrency: + group: ${{ github.workflow }}-${{ github.event.release.id }} + cancel-in-progress: false # Don't cancel release notifications + +permissions: + contents: read + jobs: github-releases-to-discord: runs-on: ubuntu-latest steps: - name: ⬇️ Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: 📧 Post to Discord - uses: SethCohen/github-releases-to-discord@v1.15.0 + uses: SethCohen/github-releases-to-discord@6ac5abea42b8cbac14316970819a8a535aab08ea # v1.16.2 with: webhook_url: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} color: "2105893" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000000..ad3dd12a35a --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,110 @@ +name: Build and Deploy Docs + +on: + # Deploy on push to main + push: + branches: [main] + # Deploy on workflow dispatch + workflow_dispatch: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.run_id }} + cancel-in-progress: false + +permissions: + contents: read + id-token: write # required for Doppler OIDC + +env: + UV_EXCLUDE_NEWER: "7 days" + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 12 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: 🔐 Fetch Doppler secrets + uses: dopplerhq/secrets-fetch-action@cd2efbf9a404504316435873eff298b82f7e0562 # v1.3.0 + id: secrets + with: + auth-method: oidc + doppler-identity-id: ${{ vars.DOPPLER_IDENTITY_ID }} + doppler-project: marimo-oss + doppler-config: ci_docs + + - name: 🐍 Setup uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + enable-cache: true + python-version: 3.12 + + - name: ⚡ Install Vercel CLI + run: npm install --ignore-scripts --global vercel@50.44.0 + + - name: ⬇️ Pull Vercel Environment Information + env: + VERCEL_ORG_ID: ${{ steps.secrets.outputs.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ steps.secrets.outputs.VERCEL_PROJECT_ID }} + VERCEL_TOKEN: ${{ steps.secrets.outputs.VERCEL_TOKEN }} + run: vercel pull --yes --environment=production + + - name: 📚 Build docs + env: + VERCEL_ORG_ID: ${{ steps.secrets.outputs.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ steps.secrets.outputs.VERCEL_PROJECT_ID }} + VERCEL_TOKEN: ${{ steps.secrets.outputs.VERCEL_TOKEN }} + run: vercel build --prod + + - name: 📝 Generate markdown versions of docs + run: | + uv run --group docs python docs/scripts/html_to_markdown.py \ + --input-dir .vercel/output/static \ + --base-url https://docs.marimo.io + + - name: 🔀 Add edge middleware for markdown content negotiation + run: | + mkdir -p .vercel/output/functions/_middleware.func + cp docs/scripts/middleware.js .vercel/output/functions/_middleware.func/index.js + echo '{"runtime":"edge","entrypoint":"index.js"}' > .vercel/output/functions/_middleware.func/.vc-config.json + uv run --group docs python docs/scripts/patch_vercel_config.py .vercel/output/config.json + + - name: 📚 llms.txt + # Generated locally from the build artifact (the .md files produced by + # the step above) and the mkdocs nav — no live-site crawl, so it's + # always in sync with this commit. It's a compact index of links to the + # per-page .md files, not a full-content dump. + run: | + uv run --group docs python docs/scripts/generate_llms_txt.py \ + --input-dir .vercel/output/static \ + --base-url https://docs.marimo.io \ + --output-index .vercel/output/static/llms.txt + + - name: 📚 CLAUDE.md + run: cp docs/_static/CLAUDE.md .vercel/output/static/CLAUDE.md + + # Skip deploy if a newer commit has landed on main while this run was building, + # so an older run can't overwrite a newer deploy. The run still completes green. + - name: 🔍 Check if still tip of main + id: latest + if: github.ref == 'refs/heads/main' + env: + GH_SHA: ${{ github.sha }} + GH_REPO: ${{ github.repository }} + run: | + LATEST_SHA=$(git ls-remote "https://github.com/${GH_REPO}.git" refs/heads/main | awk '{print $1}') + if [ "$LATEST_SHA" = "$GH_SHA" ]; then + echo "is_latest=true" >> "$GITHUB_OUTPUT" + else + echo "is_latest=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping deploy — superseded by a newer commit on main ($LATEST_SHA)." + fi + + - name: 🚀 Deploy to Vercel + if: github.ref == 'refs/heads/main' && steps.latest.outputs.is_latest == 'true' + env: + VERCEL_ORG_ID: ${{ steps.secrets.outputs.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ steps.secrets.outputs.VERCEL_PROJECT_ID }} + VERCEL_TOKEN: ${{ steps.secrets.outputs.VERCEL_TOKEN }} + run: vercel deploy --prebuilt --prod diff --git a/.github/workflows/enforce-label.yml b/.github/workflows/enforce-label.yml new file mode 100644 index 00000000000..ce28d6724a8 --- /dev/null +++ b/.github/workflows/enforce-label.yml @@ -0,0 +1,19 @@ +name: 🏷️ Enforce Pull Request Label + +# Enforces that at least one label is selected on pull requests + +on: + pull_request: + types: [labeled, unlabeled, opened, reopened, edited, synchronize] + +permissions: + contents: read + +jobs: + enforce-label: + runs-on: ubuntu-latest + steps: + - uses: yogevbd/enforce-label-action@a3c219da6b8fa73f6ba62b68ff09c469b3a1c024 # 2.2.2 + with: + REQUIRED_LABELS_ANY: "enhancement,bug,documentation,internal,preview,dependencies,other" + REQUIRED_LABELS_ANY_DESCRIPTION: "🏷️ Select at least one label: ['enhancement','bug','documentation','internal','preview','dependencies','other']" diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 00000000000..def2491356c --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,19 @@ +name: 🏷️ Pull Request Labeler + +# Auto-label pull requests based on config in .github/labeler.yml + +on: + pull_request_target: + types: [labeled, unlabeled, opened, reopened, edited, synchronize] + +permissions: + contents: read + pull-requests: write + +jobs: + auto-label: + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml new file mode 100644 index 00000000000..1a5bd8123bb --- /dev/null +++ b/.github/workflows/link-check.yml @@ -0,0 +1,40 @@ +name: 🔗 Link check + +# Check for broken links in README files. +# Runs weekly on Mondays at 8:00 UTC and on every push to main. + +on: + schedule: + - cron: "0 8 * * 1" + push: + paths: + - "README.md" + - "README_Chinese.md" + - "README_Japanese.md" + - "README_Spanish.md" + - "README_Traditional_Chinese.md" + workflow_dispatch: + +permissions: + contents: read + +jobs: + link-check: + name: Check links in README files + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Check links in README files + uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 # v2 + with: + args: >- + --verbose + --no-progress + --accept 200,403,408 + README.md + README_Chinese.md + README_Japanese.md + README_Spanish.md + README_Traditional_Chinese.md + fail: true diff --git a/.github/workflows/marimo-bot.yml b/.github/workflows/marimo-bot.yml index 90576d15cae..90cdd8b3ecf 100644 --- a/.github/workflows/marimo-bot.yml +++ b/.github/workflows/marimo-bot.yml @@ -4,33 +4,47 @@ on: issue_comment: types: [created] +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + env: TURBO_TEAM: marimo + UV_EXCLUDE_NEWER: "7 days" jobs: # Various jobs that can be triggered by comments create-test-release: + # NB: only the commenter's association is checked. The PR author's + # association says nothing about who is invoking this command. if: > ( - github.event.issue.author_association == 'OWNER' || - github.event.issue.author_association == 'COLLABORATOR' || - github.event.issue.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'MEMBER' ) && github.event.issue.pull_request && contains(github.event.comment.body, '/marimo create-test-release') - name: 📤 Publish test release + name: 📦 Build test release runs-on: ubuntu-latest + # NB: no `environment:` or secrets/OIDC — this job runs PR-controlled + # build hooks (frontend build, pyproject patch, `uv build`). + permissions: + contents: read + pull-requests: write defaults: run: shell: bash - steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 + outputs: + marimo_version: ${{ steps.get_version.outputs.marimo_version }} + comment_id: ${{ steps.comment.outputs.result }} + steps: - name: 📝 Get PR Info id: pr env: @@ -41,8 +55,9 @@ jobs: run: | pr="$(gh api /repos/${GH_REPO}/pulls/${PR_NUMBER})" head_sha="$(echo "$pr" | jq -r .head.sha)" - pushed_at="$(echo "$pr" | jq -r .pushed_at)" + pushed_at="$(echo "$pr" | jq -r .head.repo.pushed_at)" + # NB mitigate potential race condition. if [[ $(date -d "$pushed_at" +%s) -gt $(date -d "$COMMENT_AT" +%s) ]]; then echo "Updating is not allowed because the PR was pushed to (at $pushed_at) after the triggering comment was issued (at $COMMENT_AT)" exit 1 @@ -51,13 +66,13 @@ jobs: echo "head_sha=$head_sha" >> $GITHUB_OUTPUT - name: ⬇️ Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 ref: ${{ steps.pr.outputs.head_sha }} - name: 📝 Initial Comment on PR - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 id: comment with: script: | @@ -70,59 +85,48 @@ jobs: console.log(`Comment created with ID: ${comment.data.id}`); return comment.data.id; - - uses: pnpm/action-setup@v2 - with: - version: 9 - - - name: ⎔ Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: 'pnpm' - cache-dependency-path: '**/pnpm-lock.yaml' - registry-url: 'https://registry.npmjs.org' - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: 📦 Build frontend - run: make fe - env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + # NB: no turbo-token — this step runs PR-controlled build hooks. + uses: ./.github/actions/build-frontend - - name: 🥚 Install Hatch - uses: pypa/hatch@install + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 - name: Adapt pyproject.toml to build marimo-base - run: ./scripts/modify_pyproject_for_marimo_base.sh + run: uv run ./scripts/modify_pyproject_for_marimo_base.py - # patch __init__.py version to be of the form + # patch pyproject.toml version to be of the form # X.Y.Z-dev9{4random digits} # This must be a valid semver version from # https://packaging.python.org/en/latest/discussions/versioning/ - name: 🔨 Patch version number + id: get_version run: | # Get the version number - # - assumes version is on a line of the form __version__ == "x.y.z" - current_version=`grep '__version__' marimo/__init__.py | awk '{print $3}' | tr -d '"'` + current_version=`uv version --short` # Generate a random 4-digit number random_digits=`shuf -i 1000-9999 -n 1` # Form the new version with the random digits MARIMO_VERSION="${current_version}-dev9${random_digits}" # Set the version in the environment for later steps echo "MARIMO_VERSION=$MARIMO_VERSION" >> $GITHUB_ENV - sed -i "s/__version__ = \".*\"/__version__ = \"$MARIMO_VERSION\"/" marimo/__init__.py + echo "marimo_version=$MARIMO_VERSION" >> $GITHUB_OUTPUT + uv version "$MARIMO_VERSION" + env: + NO_COLOR: 1 - name: 📦 Build marimo - run: hatch build --clean + run: uv build - name: 📦 Validate wheel under 2mb run: ./scripts/validate_base_wheel_size.sh - - name: 📤 Upload to TestPyPI - env: - HATCH_INDEX_USER: ${{ secrets.TEST_PYPI_USER }} - HATCH_INDEX_AUTH: ${{ secrets.TEST_PYPI_MARIMO_BASE_PASSWORD }} - run: hatch publish --repo test + - name: 📦 Upload wheel artifact for TestPyPI publish + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: wheel-test-release + path: dist/ + retention-days: 1 - name: 📦 Update package.json version from CLI working-directory: frontend @@ -130,44 +134,89 @@ jobs: echo "Updating package.json version to ${{ env.MARIMO_VERSION }}" npm version ${{ env.MARIMO_VERSION }} --no-git-tag-version - - name: 📤 Upload wasm to npm - working-directory: frontend - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - for i in {1..3}; do - npm publish --access public && break || { - echo "Publish attempt $i failed, retrying..." - sleep 10 - } - done + - name: 📦 Upload frontend artifact for npm publish + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: frontend-test-branch + path: frontend/ + retention-days: 1 + + publish-test-release: + name: 📤 Publish dist to TestPyPI + needs: create-test-release + runs-on: ubuntu-latest + environment: testpypi + # NB: no repo checkout — only the pre-built artifact is published. + permissions: + contents: read + id-token: write + steps: + - name: ⬇️ Download dist artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: wheel-test-release + path: dist/ + + - name: 📤 Upload to TestPyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1 + with: + verbose: true + repository-url: https://test.pypi.org/legacy/ + + publish_wasm_test: + name: 📤 Publish @marimo-team/frontend to npm + needs: create-test-release + uses: ./.github/workflows/publish-npm.yml + with: + package-artifact-name: frontend-test-branch + working-directory: '.' + npm-tag: 'dev' + permissions: + id-token: write # Required for OIDC + contents: read + + update_pr_comment: + name: 📝 Update PR Comment + needs: [create-test-release, publish-test-release, publish_wasm_test] + runs-on: ubuntu-latest + if: always() + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - name: 📝 Update PR Comment - uses: actions/github-script@v7 + - name: 📝 Update PR Comment on Success + if: needs.publish-test-release.result == 'success' && needs.publish_wasm_test.result == 'success' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 continue-on-error: true + env: + MARIMO_VERSION: ${{ needs.create-test-release.outputs.marimo_version }} + COMMENT_ID: ${{ needs.create-test-release.outputs.comment_id }} with: script: | try { - const commentId = ${{steps.comment.outputs.result}} + const commentId = parseInt(process.env.COMMENT_ID, 10); + const marimoVersion = process.env.MARIMO_VERSION || 'unknown'; console.log(`Updating comment with ID: ${commentId}`); await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: commentId, - body: `🚀 Test release published. You may be able to view the changes at https://marimo.app?v=${process.env.MARIMO_VERSION}` + body: `🚀 Test release published. You may be able to view the changes at https://marimo.app?v=${encodeURIComponent(marimoVersion)}` }); } catch (err) { console.error(err); } - name: 📝 Update PR Comment on Failure - if: failure() - uses: actions/github-script@v7 + if: needs.create-test-release.result == 'failure' || needs.publish-test-release.result == 'failure' || needs.publish_wasm_test.result == 'failure' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + continue-on-error: true + env: + COMMENT_ID: ${{ needs.create-test-release.outputs.comment_id }} with: script: | try { - const commentId = ${{steps.comment.outputs.result}} + const commentId = parseInt(process.env.COMMENT_ID, 10); console.log(`Updating comment with ID: ${commentId}`); await github.rest.issues.updateComment({ owner: context.repo.owner, diff --git a/.github/workflows/notify-readme-owners.yml b/.github/workflows/notify-readme-owners.yml new file mode 100644 index 00000000000..7bdcacddc99 --- /dev/null +++ b/.github/workflows/notify-readme-owners.yml @@ -0,0 +1,59 @@ +name: README Translation Notification +on: + pull_request: + types: [closed] + paths: + - 'README.md' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + notify-translators: + # Only run this job when the PR is merged, not when it's closed without merging + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Comment on PR with translation notification + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + // Define translation maintainers + const translations = [ + { file: 'README_Traditional_Chinese.md', language: 'Traditional_Chinese', maintainers: ['@thliang01'] }, + { file: 'README_Chinese.md', language: 'Chinese', maintainers: ['@metaboulie'] }, + { file: 'README_Japanese.md', language: 'Japanese', maintainers: ['@kiwamizamurai'] }, + { file: 'README_Spanish.md', language: 'Spanish', maintainers: ['@Francisco-G-P'] } + ]; + + // Create a notification comment tagging all translation maintainers + const maintainersList = translations.map(t => + `- ${t.language} (${t.file}): ${t.maintainers.join(', ')}` + ).join('\n'); + + const comment = `## README Translation Update Needed + + This PR includes changes to the main README.md. The following translation files may need to be updated: + + ${maintainersList} + + Translation maintainers, please review the changes in this PR and update your respective README translations accordingly. + + CC: ${translations.flatMap(t => t.maintainers).join(' ')}`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment + }); diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 9d2359ebbca..1f1480d4f90 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -3,42 +3,40 @@ name: GitHub Pages on: # Weekly schedule: - - cron: '0 0 * * 0' # Run at midnight UTC on Sunday + - cron: "0 0 * * 0" # Run at midnight UTC on Sunday # Allow manual trigger workflow_dispatch: {} +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: UV_SYSTEM_PYTHON: 1 + UV_EXCLUDE_NEWER: "7 days" jobs: export-notebooks: runs-on: ubuntu-latest permissions: - pull-requests: write - contents: write + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 - - name: 🚀 Install uv - uses: astral-sh/setup-uv@v4 - - - name: 🐍 Set up Python - uses: actions/setup-python@v5 + - name: 🐍 Setup uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 with: + enable-cache: true python-version: 3.12 - - name: 📦 Install marimo - run: | - uv pip install marimo - - name: 🛠️ Run export run: | - marimo export html-wasm frontend/public/files/wasm-intro.py -o out --mode run + uvx marimo export html-wasm frontend/public/export_demos/wasm-intro.py -o out --mode run sed -i -e "s/after_delay/off/g" out/index.html - name: 📄 List changed files @@ -48,7 +46,7 @@ jobs: git diff --name-only HEAD^ HEAD - name: 📦 Upload Pages Artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: path: out @@ -66,6 +64,6 @@ jobs: steps: - name: 🌐 Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 with: artifact_name: github-pages diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index feabb8142bd..57f19baada0 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -5,10 +5,16 @@ on: branches: [main] pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: marimo MARIMO_SKIP_UPDATE_CHECK: 1 + UV_EXCLUDE_NEWER: "7 days" jobs: changes: @@ -16,73 +22,72 @@ jobs: outputs: playwright: ${{ steps.filter.outputs.playwright }} steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 id: filter with: filters: | playwright: - - '**' - - '!docs/**' - - '!examples/**' - - '!README.md' - + - 'frontend/**' + - 'marimo/**' + - '.github/workflows/playwright.yml' test: needs: changes if: ${{ needs.changes.outputs.playwright == 'true' }} - timeout-minutes: 16 + timeout-minutes: 18 # 2024-01-18 avg: 5.0m max: 7.0m runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # required for Doppler OIDC defaults: run: shell: bash env: NODE_ENV: test - DEBUG: 'pw:webserver' + DEBUG: "pw:webserver" steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 - - name: ⬇️ Checkout repo - uses: actions/checkout@v4 - + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Write TOML run: | - echo "[experimental]" > .marimo.toml + echo "[runtime]" > .marimo.toml + echo "auto_instantiate=true" >> .marimo.toml + echo "[experimental]" >> .marimo.toml echo "layouts=true" >> .marimo.toml - - uses: pnpm/action-setup@v2 - with: - version: 9 - - - name: ⎔ Setup Node.js - uses: actions/setup-node@v4 + # Turn on automatic instantiation for playwright tests + - name: Update pyproject.toml + run: | + sed -i 's/auto_instantiate = false/auto_instantiate = true/' pyproject.toml + + - name: 🔐 Fetch Doppler secrets + # Skip on PRs from forks: id-token write is downgraded to read, so OIDC fails. + # Downstream tokens (TURBO_TOKEN, CODECOV_TOKEN) are optional and tolerate empty. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: dopplerhq/secrets-fetch-action@cd2efbf9a404504316435873eff298b82f7e0562 # v1.3.0 + id: secrets with: - node-version: 20 - cache: 'pnpm' - cache-dependency-path: '**/pnpm-lock.yaml' + auth-method: oidc + doppler-identity-id: ${{ vars.DOPPLER_IDENTITY_ID }} + doppler-project: marimo-oss + doppler-config: ci_test - name: 📦 Build frontend - run: make fe - - - name: 🐍 Setup Python 3.12 - uses: actions/setup-python@v5 + uses: ./.github/actions/build-frontend with: - python-version: '3.12' - cache: 'pip' + turbo-token: ${{ steps.secrets.outputs.TURBO_TOKEN }} + codecov-token: ${{ steps.secrets.outputs.CODECOV_TOKEN }} - # We tried to add uv, but marimo is unable to - # start when installed. - # - name: 🐍 Setup uv - # uses: yezz123/setup-uv@v4 - # with: - # uv-venv: marimo-venv + - name: 🐍 Setup uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + enable-cache: true + python-version: 3.12 - name: 🐍 Install marimo run: | - python -m pip install --upgrade pip - pip install . - echo "MARIMO_VERSION=$(marimo --version)" >> $GITHUB_ENV + echo "MARIMO_VERSION=$(uv run marimo --version)" >> $GITHUB_ENV - name: 🎭 Get installed Playwright version id: playwright-version @@ -93,7 +98,7 @@ jobs: echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV - name: 📦 Cache playwright binaries - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 id: playwright-cache with: path: | @@ -112,7 +117,7 @@ jobs: VITE_MARIMO_VERSION: ${{ env.MARIMO_VERSION }} # - name: ☁️ Google Auth - # uses: google-github-actions/auth@v2 + # uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2 # # Skip on forks # if: github.event.pull_request.head.repo.organization == 'marimo-team' # with: @@ -120,10 +125,10 @@ jobs: # - name: 🌲 Get branch name # id: branch-name - # uses: tj-actions/branch-names@v7 + # uses: tj-actions/branch-names@6c999acf206f5561e19f46301bb310e9e70d8815 # v7 # - name: 📦 Upload to bucket (PR) - # uses: google-github-actions/upload-cloud-storage@v2 + # uses: google-github-actions/upload-cloud-storage@c0f6160ff80057923ff50e5e567695cea181ec23 # v2 # # Only on PRs and not forks # if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.organization == 'marimo-team' # with: @@ -131,7 +136,7 @@ jobs: # path: frontend/e2e-tests/screenshots # - name: 📦 Upload to bucket (main) - # uses: google-github-actions/upload-cloud-storage@v2 + # uses: google-github-actions/upload-cloud-storage@c0f6160ff80057923ff50e5e567695cea181ec23 # v2 # # Only on main branch and not forks # if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.event.pull_request.head.repo.organization == 'marimo-team' # with: @@ -139,7 +144,7 @@ jobs: # path: frontend/e2e-tests/screenshots - name: 📊 Upload report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: always() with: name: playwright-report diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml new file mode 100644 index 00000000000..174ce311fa3 --- /dev/null +++ b/.github/workflows/publish-docker.yml @@ -0,0 +1,201 @@ +name: Publish Docker images + +on: + workflow_call: + inputs: + marimo_version: + description: 'marimo version to publish' + required: true + type: string + workflow_dispatch: + inputs: + marimo_version: + description: 'marimo version to publish' + required: true + type: string + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: marimo-team/marimo + +jobs: + publish_docker: + name: 🐋 Publish Docker images + runs-on: ubuntu-latest + # Don't error while this is in BETA + continue-on-error: true + + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: ⏳ Wait for PyPI package availability + run: | + VERSION="${{ inputs.marimo_version }}" + echo "Waiting for marimo version $VERSION to be available on PyPI..." + + MAX_ATTEMPTS=30 + ATTEMPT=0 + SLEEP_TIME=10 + + while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do + ATTEMPT=$((ATTEMPT + 1)) + echo "Attempt $ATTEMPT/$MAX_ATTEMPTS: Checking PyPI for version $VERSION..." + + # Check if the version exists on PyPI + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/marimo/$VERSION/json") + + if [ "$HTTP_CODE" = "200" ]; then + echo "✅ Version $VERSION is available on PyPI!" + exit 0 + else + echo "Version $VERSION not yet available (HTTP $HTTP_CODE). Waiting ${SLEEP_TIME}s..." + sleep $SLEEP_TIME + fi + done + + echo "❌ Timed out waiting for version $VERSION on PyPI after $((MAX_ATTEMPTS * SLEEP_TIME)) seconds" + exit 1 + + - name: 🐋 Log in to the Container registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 🐋 Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: 🐋 Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: 🧪 Build docker image for testing + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: ./docker/Dockerfile + # Don't push test image + push: false + load: true + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test + target: slim + build-args: | + marimo_version=${{ inputs.marimo_version }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: 🧪 Test docker build + run: | + docker run -d -e PORT=9090 -p 9090:9090 --name test_container ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test + + # Wait for container to be ready with retries + echo "Waiting for marimo server to start..." + MAX_ATTEMPTS=30 + ATTEMPT=0 + SLEEP_TIME=2 + + while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do + ATTEMPT=$((ATTEMPT + 1)) + echo "Attempt $ATTEMPT/$MAX_ATTEMPTS: Checking health endpoint..." + + if curl -f http://localhost:9090/health 2>/dev/null; then + echo "✅ Health check passed!" + docker stop test_container + docker rm test_container + exit 0 + else + echo "Health check failed. Waiting ${SLEEP_TIME}s..." + sleep $SLEEP_TIME + fi + done + + echo "❌ Health check failed after $((MAX_ATTEMPTS * SLEEP_TIME)) seconds" + echo "Container logs:" + docker logs test_container + docker stop test_container + docker rm test_container + exit 1 + + - name: 📦 Build and push Docker images (slim) + id: build_slim + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: ./docker/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.marimo_version }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + target: slim + platforms: linux/amd64,linux/arm64 + build-args: | + marimo_version=${{ inputs.marimo_version }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: 📦 Build and push Docker images (data) + id: build_data + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: ./docker/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.marimo_version }}-data + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-data + target: data + platforms: linux/amd64,linux/arm64 + build-args: | + marimo_version=${{ inputs.marimo_version }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: 📦 Build and push Docker images (sql) + id: build_sql + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: ./docker/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.marimo_version }}-sql + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-sql + target: sql + platforms: linux/amd64,linux/arm64 + build-args: | + marimo_version=${{ inputs.marimo_version }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: 🔍 Determine Docker publish status + if: always() + id: docker_publish_status + run: | + status="success" + if [ "${{ steps.build_slim.outcome }}" = "failure" ] || \ + [ "${{ steps.build_data.outcome }}" = "failure" ] || \ + [ "${{ steps.build_sql.outcome }}" = "failure" ]; then + status="failure" + fi + echo "status=$status" >> "$GITHUB_OUTPUT" + + - name: 📢 Notify release result + if: always() + continue-on-error: true + uses: marimo-team/internal-gh-actions/release-notification@ba06d4db1f3c5c9b86983ce409e57196f8376777 # main + with: + status: ${{ steps.docker_publish_status.outputs.status }} + slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL_RELEASES }} + artifact-url: "https://ghcr.io/marimo-team/marimo" diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 00000000000..003b9cc3914 --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,55 @@ +name: Publish Package to npm + +on: + workflow_call: + inputs: + package-artifact-name: + required: true + type: string + description: 'Name of the artifact containing the built package' + working-directory: + required: false + type: string + default: '.' + description: 'Directory within the artifact to publish from' + npm-tag: + required: false + type: string + default: '' + description: 'npm tag to use when publishing (e.g., "dev", "next"). Leave empty for default (latest)' + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + id-token: write # Required for OIDC authentication and provenance + contents: read + steps: + - name: 📦 Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: 24 + registry-url: 'https://registry.npmjs.org' + + - name: 📥 Download built package artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ inputs.package-artifact-name }} + path: ${{ inputs.working-directory }} + + - name: 📤 Publish to npm + working-directory: ${{ inputs.working-directory }} + env: + NODE_AUTH_TOKEN: "" # Clear placeholder set by setup-node to enable OIDC + run: | + TAG_FLAG="" + if [ -n "${{ inputs.npm-tag }}" ]; then + TAG_FLAG="--tag ${{ inputs.npm-tag }}" + fi + + for i in {1..3}; do + npm publish --access public --provenance $TAG_FLAG && break || { + echo "Publish attempt $i failed, retrying..." + sleep 10 + } + done diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml new file mode 100644 index 00000000000..d3bda90a24a --- /dev/null +++ b/.github/workflows/release-dev.yml @@ -0,0 +1,193 @@ +name: Publish dev release + +on: + workflow_call: + outputs: + marimo_version: + description: "The dev version that was built" + value: ${{ jobs.publish_dev_release.outputs.marimo_version }} + +permissions: + contents: read + +env: + TURBO_TEAM: marimo + UV_EXCLUDE_NEWER: "7 days" + +jobs: + publish_dev_release: + name: 📤 Build dev release + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # required for Doppler OIDC + defaults: + run: + shell: bash + + outputs: + marimo_version: ${{ steps.get_version.outputs.marimo_version }} + + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + # get tag history for version number + fetch-depth: 0 + + - name: 🔐 Fetch Doppler secrets + uses: dopplerhq/secrets-fetch-action@cd2efbf9a404504316435873eff298b82f7e0562 # v1.3.0 + id: secrets + with: + auth-method: oidc + doppler-identity-id: ${{ vars.DOPPLER_IDENTITY_ID }} + doppler-project: marimo-oss + doppler-config: ci_deploy + + - name: 📦 Build frontend + uses: ./.github/actions/build-frontend + with: + turbo-token: ${{ steps.secrets.outputs.TURBO_TOKEN }} + codecov-token: ${{ steps.secrets.outputs.CODECOV_TOKEN }} + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + + # patch pyproject.toml version to be of the form + # X.Y.-dev{n-commits-since-last-tag} + - name: 🔨 Patch version number + id: get_version + run: | + # Get the version number and increment patch + # - assumes version is on a line of the form __version__ == "x.y.z" + incremented_version=`uv version --short | awk -F. '{printf "%d.%d.%d", $1, $2, $3+1}'` + # Get the number of commits since last tag + n_commits=`git rev-list $(git describe --tags --abbrev=0)..HEAD --count` + # Form the new version, which is one patch ahead of the last version + # so installing from Test PyPI does the right thing + MARIMO_VERSION="${incremented_version}-dev${n_commits}" + # Set the version in the environment for later steps + echo "MARIMO_VERSION=$MARIMO_VERSION" >> $GITHUB_ENV + echo "marimo_version=$MARIMO_VERSION" >> $GITHUB_OUTPUT + uv version "$MARIMO_VERSION" + env: + NO_COLOR: 1 + + - name: 📦 Build marimo + run: uv build + + - name: 📦 Upload marimo dist artifact + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: pypi-marimo-dev + path: dist/ + retention-days: 1 + + - name: Clean output dir + run: rm -rf dist + + - name: Adapt pyproject.toml to build marimo-base + run: uv run ./scripts/modify_pyproject_for_marimo_base.py + + - name: 📦 Build marimo-base + run: uv build + + - name: 📦 Validate wheel under 2mb + run: ./scripts/validate_base_wheel_size.sh + + - name: 📦 Upload marimo-base dist artifact + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: pypi-marimo-base-dev + path: dist/ + retention-days: 1 + + - name: 📦 Update package.json version from CLI + working-directory: frontend + run: | + echo "Updating package.json version to ${{ env.MARIMO_VERSION }}" + npm version ${{ env.MARIMO_VERSION }} --no-git-tag-version + + - name: 📦 Upload frontend artifact for npm publish + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: frontend-dev + path: frontend/ + retention-days: 1 + + - name: 📦 Update package.json name to @marimo-team/islands + working-directory: frontend + run: | + sed -i 's/"name": "@marimo-team\/frontend"/"name": "@marimo-team\/islands"/' package.json + + - name: 📦 Rebuild frontend for islands + working-directory: frontend + env: + NODE_ENV: production + VITE_MARIMO_ISLANDS: 'true' + VITE_MARIMO_VERSION: ${{ env.MARIMO_VERSION }} + run: | + pnpm turbo build:islands + ./islands/validate.sh + + - name: 📦 Upload islands artifact for npm publish + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: frontend-islands-dev + path: frontend/ + retention-days: 1 + + # Recheck after the build completes before publishing to npm. The `dev` dist-tag is + # mutable — if a stale older run publishes after a newer one, `@dev` gets pointed at + # older artifacts. Always runs; returns is_latest=true for non-main callers so this + # reusable workflow stays safe outside the push-to-main path. + recheck_tip_of_main: + name: 🔍 Still tip of main? + needs: publish_dev_release + runs-on: ubuntu-latest + outputs: + is_latest: ${{ steps.check.outputs.is_latest }} + steps: + - id: check + env: + GH_REF: ${{ github.ref }} + GH_SHA: ${{ github.sha }} + GH_REPO: ${{ github.repository }} + run: | + if [ "$GH_REF" != "refs/heads/main" ]; then + echo "is_latest=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + LATEST_SHA=$(git ls-remote "https://github.com/${GH_REPO}.git" refs/heads/main | awk '{print $1}') + if [ "$LATEST_SHA" = "$GH_SHA" ]; then + echo "is_latest=true" >> "$GITHUB_OUTPUT" + else + echo "is_latest=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping npm publish — superseded by a newer commit on main ($LATEST_SHA) during build." + fi + + publish_wasm_dev: + name: 📤 Publish @marimo-team/frontend to npm + needs: [publish_dev_release, recheck_tip_of_main] + if: needs.recheck_tip_of_main.outputs.is_latest == 'true' + uses: ./.github/workflows/publish-npm.yml + with: + package-artifact-name: frontend-dev + working-directory: '.' + npm-tag: 'dev' + permissions: + id-token: write # Required for OIDC + contents: read + + publish_islands_dev: + name: 📤 Publish @marimo-team/islands to npm + needs: [publish_dev_release, recheck_tip_of_main] + if: needs.recheck_tip_of_main.outputs.is_latest == 'true' + uses: ./.github/workflows/publish-npm.yml + with: + package-artifact-name: frontend-islands-dev + working-directory: '.' + npm-tag: 'dev' + permissions: + id-token: write # Required for OIDC + contents: read diff --git a/.github/workflows/release-marimo-base.yml b/.github/workflows/release-marimo-base.yml index edb30d7ec77..637dd26b4da 100644 --- a/.github/workflows/release-marimo-base.yml +++ b/.github/workflows/release-marimo-base.yml @@ -4,44 +4,82 @@ name: Publish marimo-base release on: push: tags: - - '[0-9]+.[0-9]+.[0-9]+' + - "[0-9]+.[0-9]+.[0-9]+" workflow_dispatch: {} +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: marimo - REGISTRY: ghcr.io - IMAGE_NAME: marimo-team/marimo + UV_EXCLUDE_NEWER: "7 days" jobs: publish_release: name: 📤 Publish release runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write defaults: run: shell: bash steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 - - name: ⬇️ Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: 🔐 Fetch Doppler secrets + uses: dopplerhq/secrets-fetch-action@cd2efbf9a404504316435873eff298b82f7e0562 # v1.3.0 + id: secrets + with: + auth-method: oidc + doppler-identity-id: ${{ vars.DOPPLER_IDENTITY_ID }} + doppler-project: marimo-oss + doppler-config: ci_deploy - - name: 🥚 Install Hatch - uses: pypa/hatch@install + - name: 📦 Build frontend + uses: ./.github/actions/build-frontend + with: + turbo-token: ${{ steps.secrets.outputs.TURBO_TOKEN }} + codecov-token: ${{ steps.secrets.outputs.CODECOV_TOKEN }} + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 - name: Adapt pyproject.toml to build marimo-base - run: ./scripts/modify_pyproject_for_marimo_base.sh + run: uv run ./scripts/modify_pyproject_for_marimo_base.py - name: 📦 Build marimo-base - run: hatch build --clean + run: uv build - name: 📦 Validate wheel under 2mb run: ./scripts/validate_base_wheel_size.sh - name: 📤 Upload to PyPI - env: - HATCH_INDEX_USER: ${{ secrets.PYPI_USER }} - HATCH_INDEX_AUTH: ${{ secrets.PYPI_MARIMO_BASE_PASSWORD }} - run: hatch publish + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1 + with: + skip-existing: true + verbose: true + attestations: false + + - name: 📤 Upload to TestPyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1 + with: + skip-existing: true + verbose: true + attestations: false + repository-url: https://test.pypi.org/legacy/ + + - name: 📢 Notify release result + if: always() + continue-on-error: true + uses: marimo-team/internal-gh-actions/release-notification@ba06d4db1f3c5c9b86983ce409e57196f8376777 # main + with: + status: ${{ job.status }} + slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL_RELEASES }} + artifact-url: "https://pypi.org/project/marimo-base/" diff --git a/.github/workflows/release-prod.yml b/.github/workflows/release-prod.yml new file mode 100644 index 00000000000..7b1389b3cd3 --- /dev/null +++ b/.github/workflows/release-prod.yml @@ -0,0 +1,123 @@ +name: Publish prod release + +on: + workflow_call: + outputs: + marimo_version: + description: "The release version that was built" + value: ${{ jobs.publish_release.outputs.marimo_version }} + +permissions: + contents: read + +env: + TURBO_TEAM: marimo + UV_EXCLUDE_NEWER: "7 days" + +jobs: + publish_release: + name: 📤 Build release + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # required for Doppler OIDC + defaults: + run: + shell: bash + + outputs: + marimo_version: ${{ steps.get_version.outputs.marimo_version }} + + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: 🔐 Fetch Doppler secrets + uses: dopplerhq/secrets-fetch-action@cd2efbf9a404504316435873eff298b82f7e0562 # v1.3.0 + id: secrets + with: + auth-method: oidc + doppler-identity-id: ${{ vars.DOPPLER_IDENTITY_ID }} + doppler-project: marimo-oss + doppler-config: ci_deploy + + - name: 📦 Build frontend + uses: ./.github/actions/build-frontend + with: + turbo-token: ${{ steps.secrets.outputs.TURBO_TOKEN }} + codecov-token: ${{ steps.secrets.outputs.CODECOV_TOKEN }} + + - name: 🐍 Setup uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + + - name: 📦 Build marimo + run: uv build + + - name: 📦 Upload marimo dist artifact + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: pypi-marimo + path: dist/ + retention-days: 1 + + - name: 🔨 Get version + id: get_version + run: | + echo "marimo_version=$(uv version --short)" >> $GITHUB_OUTPUT + + - name: 📦 Update package.json version from CLI + working-directory: frontend + run: | + echo "Updating package.json version to ${{ steps.get_version.outputs.marimo_version }}" + npm version ${{ steps.get_version.outputs.marimo_version }} --no-git-tag-version + + - name: 📦 Upload frontend artifact for npm publish + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: frontend + path: frontend/ + retention-days: 1 + + - name: 📦 Update package.json name to @marimo-team/islands + working-directory: frontend + run: | + sed -i 's/"name": "@marimo-team\/frontend"/"name": "@marimo-team\/islands"/' package.json + + - name: 📦 Rebuild frontend for islands + working-directory: frontend + env: + NODE_ENV: production + VITE_MARIMO_ISLANDS: 'true' + VITE_MARIMO_VERSION: ${{ steps.get_version.outputs.marimo_version }} + run: | + pnpm turbo build:islands + ./islands/validate.sh + + - name: 📦 Upload islands artifact for npm publish + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: frontend-islands + path: frontend/ + retention-days: 1 + + publish_wasm: + name: 📤 Publish @marimo-team/frontend to npm + needs: publish_release + uses: ./.github/workflows/publish-npm.yml + with: + package-artifact-name: frontend + working-directory: '.' + permissions: + id-token: write # Required for OIDC + contents: read + + publish_islands: + name: 📤 Publish @marimo-team/islands to npm + needs: publish_release + uses: ./.github/workflows/publish-npm.yml + with: + package-artifact-name: frontend-islands + working-directory: '.' + permissions: + id-token: write # Required for OIDC + contents: read diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 00000000000..77cd43353aa --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,70 @@ +name: Auto-tag Release + +# When a release PR is merged, create and push the version tag. +# The tag push then triggers the production release workflow. +on: + pull_request: + types: [closed] + +jobs: + create-tag: + if: >- + github.event.pull_request.merged == true && + github.event.pull_request.base.ref == 'main' && + startsWith(github.event.pull_request.title, 'release: ') + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # required for Doppler OIDC + steps: + - name: Extract version from PR title + id: version + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + VERSION="${PR_TITLE#release: }" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid version format: $VERSION" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: 🔐 Fetch Doppler secrets + uses: dopplerhq/secrets-fetch-action@cd2efbf9a404504316435873eff298b82f7e0562 # v1.3.0 + id: secrets + with: + auth-method: oidc + doppler-identity-id: ${{ vars.DOPPLER_IDENTITY_ID }} + doppler-project: marimo-oss + doppler-config: ci_deploy + + # Use a GitHub App token so the tag push triggers other workflows. + # Tags pushed with the default GITHUB_TOKEN do not trigger workflows. + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ steps.secrets.outputs.RELEASE_APP_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + token: ${{ steps.app-token.outputs.token }} + + - name: Verify version in pyproject.toml + run: | + TOML_VERSION=$(grep -m1 '^version' pyproject.toml | sed 's/.*"\(.*\)".*/\1/') + if [ "$TOML_VERSION" != "${{ steps.version.outputs.version }}" ]; then + echo "::error::Version mismatch: PR title says ${{ steps.version.outputs.version }}, pyproject.toml says $TOML_VERSION" + exit 1 + fi + + - name: Create and push tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + VERSION="${{ steps.version.outputs.version }}" + git tag -a "$VERSION" -m "release: $VERSION" + git push origin "$VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0adcd2691c7..66e2467d5e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,221 +1,210 @@ -name: Publish release +name: Release -# release a new version of marimo on tag push +# Orchestrates both production and development releases. +# PyPI publishing is done directly in this top-level workflow +# because PyPI Trusted Publishing does not support reusable workflows. +# See: https://docs.pypi.org/trusted-publishers/troubleshooting/#reusable-workflows-on-github on: push: + branches: + - main tags: - '[0-9]+.[0-9]+.[0-9]+' workflow_dispatch: {} -env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: marimo - REGISTRY: ghcr.io - IMAGE_NAME: marimo-team/marimo +concurrency: + # On main: unique group per run so nothing is cancelled (avoids a red X on the commit + # when a newer push supersedes this one). On tags: per-ref group with cancel-in-progress, + # preserving the original production-release behavior. + group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.run_id || github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: - publish_release: - name: 📤 Publish release - runs-on: ubuntu-latest - defaults: - run: - shell: bash + # ── Production Release (tag push) ────────────────────────────── - outputs: - marimo_version: ${{ steps.get_version.outputs.marimo_version }} + build_prod: + name: 🚀 Production Release + if: startsWith(github.ref, 'refs/tags/') + uses: ./.github/workflows/release-prod.yml + secrets: inherit + permissions: + contents: read # Required for checkout + id-token: write # Required for nodejs OIDC + publish_prod_pypi: + name: 📤 Publish to PyPI + if: startsWith(github.ref, 'refs/tags/') + needs: build_prod + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 - - - name: ⬇️ Checkout repo - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v2 + - name: 📥 Download marimo dist + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - version: 9 - - - name: ⎔ Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: 'pnpm' - cache-dependency-path: '**/pnpm-lock.yaml' - registry-url: 'https://registry.npmjs.org' - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: 📦 Build frontend - run: make fe - - - name: 🥚 Install Hatch - uses: pypa/hatch@install - - - name: 📦 Build marimo - run: hatch build - + name: pypi-marimo + path: dist/ - name: 📤 Upload to PyPI - env: - HATCH_INDEX_USER: ${{ secrets.PYPI_USER }} - HATCH_INDEX_AUTH: ${{ secrets.PYPI_PASSWORD }} - run: hatch publish - - - name: 🔨 Get version - id: get_version - run: | - echo "marimo_version=$(hatch version)" >> $GITHUB_OUTPUT + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1 + with: + skip-existing: true + verbose: true - - name: 📦 Update package.json version from CLI - working-directory: frontend - run: | - echo "Updating package.json version to ${{ steps.get_version.outputs.marimo_version }}" - npm version ${{ steps.get_version.outputs.marimo_version }} --no-git-tag-version + - name: 📢 Notify release result + if: always() + continue-on-error: true + uses: marimo-team/internal-gh-actions/release-notification@ba06d4db1f3c5c9b86983ce409e57196f8376777 # main + with: + status: ${{ job.status }} + slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL_RELEASES }} + artifact-url: "https://pypi.org/project/marimo/" - - name: 📤 Upload wasm to npm - working-directory: frontend - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - for i in {1..3}; do - npm publish --access public && break || { - echo "Publish attempt $i failed, retrying..." - sleep 10 - } - done + publish_docker: + name: 🐋 Publish Docker images + if: startsWith(github.ref, 'refs/tags/') + needs: [build_prod, publish_prod_pypi] + uses: ./.github/workflows/publish-docker.yml + with: + marimo_version: ${{ needs.build_prod.outputs.marimo_version }} + secrets: inherit + permissions: + contents: read # Required for checkout + packages: write # Required for Docker image publishing - - name: 📦 Update package.json name to @marimo-team/islands - working-directory: frontend - run: | - sed -i 's/"name": "@marimo-team\/frontend"/"name": "@marimo-team\/islands"/' package.json + # ── Development Release (push to main) ───────────────────────── - - name: 📦 Rebuild frontend - working-directory: frontend + # Skip the dev release chain if a newer commit has landed on main while this run was + # queued, so we don't burn a TestPyPI version on a superseded SHA. Downstream dev jobs + # (build_dev + publish_dev_*) skip automatically via their `needs:` chain. + check_dev_latest: + name: 🔍 Still tip of main? + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + outputs: + is_latest: ${{ steps.check.outputs.is_latest }} + steps: + - id: check env: - NODE_ENV: production - VITE_MARIMO_ISLANDS: 'true' - VITE_MARIMO_VERSION: ${{ steps.get_version.outputs.marimo_version }} + GH_SHA: ${{ github.sha }} + GH_REPO: ${{ github.repository }} run: | - pnpm turbo build:islands - ./islands/validate.sh - - - name: 📤 Upload islands to npm - working-directory: frontend + LATEST_SHA=$(git ls-remote "https://github.com/${GH_REPO}.git" refs/heads/main | awk '{print $1}') + if [ "$LATEST_SHA" = "$GH_SHA" ]; then + echo "is_latest=true" >> "$GITHUB_OUTPUT" + else + echo "is_latest=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping dev release — superseded by a newer commit on main ($LATEST_SHA)." + fi + + build_dev: + name: 🔧 Development Release + needs: check_dev_latest + if: github.ref == 'refs/heads/main' && needs.check_dev_latest.outputs.is_latest == 'true' + uses: ./.github/workflows/release-dev.yml + secrets: inherit + permissions: + contents: read # Required for checkout + id-token: write # Required for nodejs OIDC + + # Recheck after the build completes: the build takes several minutes, long enough that + # a newer commit may have landed during that window. Skipping here prevents a stale + # older run from publishing to TestPyPI after the newer run has already published. + recheck_dev_latest: + name: 🔍 Recheck still tip of main? + needs: build_dev + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + outputs: + is_latest: ${{ steps.check.outputs.is_latest }} + steps: + - id: check env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + GH_SHA: ${{ github.sha }} + GH_REPO: ${{ github.repository }} run: | - for i in {1..3}; do - npm publish --access public && break || { - echo "Publish attempt $i failed, retrying..." - sleep 10 - } - done - - publish_docker: - name: 🐋 Publish Docker images + LATEST_SHA=$(git ls-remote "https://github.com/${GH_REPO}.git" refs/heads/main | awk '{print $1}') + if [ "$LATEST_SHA" = "$GH_SHA" ]; then + echo "is_latest=true" >> "$GITHUB_OUTPUT" + else + echo "is_latest=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping TestPyPI publish — superseded by a newer commit on main ($LATEST_SHA) during build." + fi + + publish_dev_pypi_marimo: + name: 📤 Publish marimo to TestPyPI + if: github.ref == 'refs/heads/main' && needs.recheck_dev_latest.outputs.is_latest == 'true' + needs: [build_dev, recheck_dev_latest] runs-on: ubuntu-latest - needs: [publish_release] - # Don't error while this is in BETA - continue-on-error: true + environment: testpypi permissions: contents: read - packages: write - + id-token: write steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 - - - name: ⬇️ Checkout repo - uses: actions/checkout@v4 - - - name: 🐋 Log in to the Container registry - uses: docker/login-action@v3 + - name: 📥 Download marimo dist + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: 🐋 Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v5 + name: pypi-marimo-dev + path: dist/ + - name: 📤 Upload to TestPyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - - name: 🐋 Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + verbose: true + repository-url: https://test.pypi.org/legacy/ - - name: 🧪 Build docker image for testing - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/Dockerfile - # Don't push test image - push: false - load: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test - target: slim - build-args: | - marimo_version=${{ needs.publish_release.outputs.marimo_version }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: 🧪 Test docker build - run: | - docker run -d -e PORT=9090 -p 9090:9090 --name test_container ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test - sleep 2 - curl -f http://localhost:9090/health || (docker logs test_container && exit 1) - docker stop test_container - docker rm test_container - - - name: 📦 Build and push Docker images (slim) - uses: docker/build-push-action@v6 + publish_dev_pypi_marimo_base: + name: 📤 Publish marimo-base to TestPyPI + if: github.ref == 'refs/heads/main' && needs.recheck_dev_latest.outputs.is_latest == 'true' + needs: [build_dev, recheck_dev_latest] + runs-on: ubuntu-latest + environment: testpypi + permissions: + contents: read + id-token: write + steps: + - name: 📥 Download marimo-base dist + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - context: . - file: ./docker/Dockerfile - push: true - tags: | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.publish_release.outputs.marimo_version }} - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest - target: slim - platforms: linux/amd64,linux/arm64 - build-args: | - marimo_version=${{ needs.publish_release.outputs.marimo_version }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: 📦 Build and push Docker images (data) - uses: docker/build-push-action@v6 + name: pypi-marimo-base-dev + path: dist/ + - name: 📤 Upload to TestPyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1 with: - context: . - file: ./docker/Dockerfile - push: true - tags: | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.publish_release.outputs.marimo_version }}-data - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-data - target: data - platforms: linux/amd64,linux/arm64 - build-args: | - marimo_version=${{ needs.publish_release.outputs.marimo_version }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + verbose: true + repository-url: https://test.pypi.org/legacy/ - - name: 📦 Build and push Docker images (sql) - uses: docker/build-push-action@v6 + comment_pr: + name: 📝 Comment PR + if: github.ref == 'refs/heads/main' + needs: [build_dev, publish_dev_pypi_marimo, publish_dev_pypi_marimo_base] + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: 📝 Comment PR + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + continue-on-error: true + env: + MARIMO_VERSION: ${{ needs.build_dev.outputs.marimo_version }} with: - context: . - file: ./docker/Dockerfile - push: true - tags: | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.publish_release.outputs.marimo_version }}-sql - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-sql - target: sql - platforms: linux/amd64,linux/arm64 - build-args: | - marimo_version=${{ needs.publish_release.outputs.marimo_version }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + script: | + try { + const marimoVersion = process.env.MARIMO_VERSION || 'unknown'; + const pullRequest = await github.rest.search.issuesAndPullRequests({ + q: `sha:${context.sha} is:pr is:merged` + }); + + if (pullRequest.data.items.length > 0) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.data.items[0].number, + body: `🚀 Development release published. You may be able to view the changes at https://marimo.app?v=${encodeURIComponent(marimoVersion)}` + }); + } else { + console.log("No merged PR found for this SHA."); + } + } catch (err) { + console.error(err); + } diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000000..b6094106b6c --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,87 @@ +name: 🧹 Stale PRs + +# Close stale pull requests after a period of inactivity. +# Runs daily at 9:00 UTC. Can also be triggered manually. + +on: + schedule: + - cron: "0 9 * * *" + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + # actions/stale can't filter by author, so we translate "team member's + # draft" into a label that exempt-pr-labels understands. External drafts + # are left unlabeled and therefore processed like any other PR. + - name: Exempt team-member draft PRs + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + with: + script: | + const TEAM = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); + const LABEL = "team-draft"; + const prs = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: "open", + per_page: 100, + }); + for (const pr of prs) { + const shouldExempt = pr.draft && TEAM.has(pr.author_association); + const hasLabel = pr.labels.some((l) => l.name === LABEL); + if (shouldExempt && !hasLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: [LABEL], + }); + } else if (!shouldExempt && hasLabel) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + name: LABEL, + }); + } + } + + - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9 + with: + # --- General --- + repo-token: ${{ secrets.GITHUB_TOKEN }} + operations-per-run: 60 + + # --- Pull Requests --- + days-before-stale: 30 + days-before-close: 14 + stale-pr-label: "stale" + stale-pr-message: > + This pull request has been automatically marked as stale because it + has not had activity in 30 days. It will be closed in 14 days if no + further activity occurs. If this PR is still relevant, please leave + a comment or push new changes to keep it open. Thank you for your + contribution! + close-pr-message: > + This pull request has been automatically closed due to inactivity. + Feel free to reopen it if you'd like to continue working on it. + close-pr-label: "closed-stale" + # Process external drafts; team drafts are exempted via the + # "team-draft" label applied in the step above. + exempt-draft-pr: false + # Don't touch issues with this workflow + days-before-issue-stale: -1 + days-before-issue-close: -1 + # Exempt PRs with these labels + exempt-pr-labels: "do-not-close,work-in-progress,pinned,team-draft" + # Remove stale label when PR is updated + remove-stale-when-updated: true + + # --- Rate Limiting --- + # Ascending order processes oldest first + ascending: true diff --git a/.github/workflows/sync-llm-info.yml b/.github/workflows/sync-llm-info.yml new file mode 100644 index 00000000000..d7841e92549 --- /dev/null +++ b/.github/workflows/sync-llm-info.yml @@ -0,0 +1,123 @@ +name: Sync LLM models + +on: + # Weekly, Mondays at 08:30 UTC. + schedule: + - cron: "30 8 * * 1" + workflow_dispatch: {} + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: write + id-token: write + pull-requests: write + +jobs: + sync-models: + name: Sync models.dev catalog + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + BRANCH_NAME: automation/sync-llm-info-models + PR_TITLE: Update LLM model catalog + defaults: + run: + shell: bash + + steps: + - name: 🔐 Fetch Doppler secrets + uses: dopplerhq/secrets-fetch-action@cd2efbf9a404504316435873eff298b82f7e0562 # v1.3.0 + id: secrets + with: + auth-method: oidc + doppler-identity-id: ${{ vars.DOPPLER_IDENTITY_ID }} + doppler-project: marimo-oss + doppler-config: ci_deploy + + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ steps.secrets.outputs.RELEASE_APP_PRIVATE_KEY }} + + - name: ⬇️ Checkout repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: 📥 Install repo + uses: ./.github/actions/install + + - name: 🔄 Sync models (5 newest per provider) + run: pnpm --dir packages/llm-info sync-models -n 5 + + - name: 🛠️ Generate llm-info data + run: pnpm --dir packages/llm-info codegen + + - name: 🧪 Test llm-info + run: pnpm --dir packages/llm-info test + + - name: 🔎 Check for model catalog changes + id: changes + run: | + if git diff --quiet -- packages/llm-info/data/models.yml; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + echo "No model catalog changes." + else + echo "has_changes=true" >> "$GITHUB_OUTPUT" + git --no-pager diff --stat -- packages/llm-info/data/models.yml + fi + + - name: 🔎 Check for existing update PR + id: existing_pr + if: steps.changes.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + number="$(gh pr list --state open --head "$BRANCH_NAME" --json number --jq '.[0].number // ""')" + echo "number=$number" >> "$GITHUB_OUTPUT" + if [[ -n "$number" ]]; then + echo "An update PR already exists: #$number" + fi + + - name: ⬆️ Push update branch + if: steps.changes.outputs.has_changes == 'true' + env: + GIT_AUTHOR_NAME: github-actions[bot] + GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com + run: | + git fetch origin "$BRANCH_NAME":"refs/remotes/origin/$BRANCH_NAME" || true + git switch -c "$BRANCH_NAME" + git add packages/llm-info/data/models.yml + git commit -m "Update LLM model catalog" + git push --force-with-lease origin "$BRANCH_NAME" + + - name: 📝 Create update PR + if: steps.changes.outputs.has_changes == 'true' && steps.existing_pr.outputs.number == '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr create \ + --draft \ + --base main \ + --head "$BRANCH_NAME" \ + --label enhancement \ + --title "$PR_TITLE" \ + --body "$(cat <<'EOF' + This pull request was generated automatically by the weekly llm-info sync workflow. + + ## Summary + - Syncs `packages/llm-info/data/models.yml` from `models.dev`. + - Runs llm-info code generation and tests. + + ## Test plan + - `pnpm --dir packages/llm-info test` + EOF + )" diff --git a/.github/workflows/test_be.yaml b/.github/workflows/test_be.yaml index 6a3f2b6039c..3fbf03eb73c 100644 --- a/.github/workflows/test_be.yaml +++ b/.github/workflows/test_be.yaml @@ -5,17 +5,26 @@ on: branches: [main] pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + env: MARIMO_SKIP_UPDATE_CHECK: 1 + UV_EXCLUDE_NEWER: "7 days" jobs: changes: runs-on: ubuntu-latest outputs: backend: ${{ steps.filter.outputs.backend }} + docs: ${{ steps.filter.outputs.docs }} steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 id: filter with: filters: | @@ -23,52 +32,168 @@ jobs: - 'marimo/**' - 'tests/**' - 'pyproject.toml' + docs: + - 'docs/**' + + test_docs: + needs: changes + if: ${{ needs.changes.outputs.docs == 'true' }} + name: Test docs + runs-on: ubuntu-latest + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - test_python: + - name: 🐍 Setup uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + + - name: 📚 Build docs + id: docs + run: uv run --group docs mkdocs build --strict + + - name: Summarize docs failure + if: failure() && steps.docs.outcome == 'failure' + uses: ./.github/actions/pr-comment-on-failure + continue-on-error: true + with: + check-name: "docs build" + command: "uv run --group docs mkdocs build --strict" + + test_check: needs: changes if: ${{ needs.changes.outputs.backend == 'true' }} - name: ${{ matrix.os }} / Py ${{ matrix.python-version }} / ${{ matrix.dependencies }} deps - runs-on: ${{ matrix.os }} - timeout-minutes: 15 - defaults: - run: - shell: bash + name: Test lint and typecheck + runs-on: ubuntu-latest + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: 🐍 Setup uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + + - name: 🧹 Lint + id: lint + run: uv run ruff check --fix --output-format=github + + - name: 📋 Register mypy problem matcher + run: echo "::add-matcher::.github/problemMatchers/mypy.json" + + - name: 🔍 Typecheck + id: typecheck + run: uv run --only-group typecheck mypy marimo --exclude=marimo/_tutorials/ + + - name: Summarize lint failure + if: failure() && steps.lint.outcome == 'failure' + continue-on-error: true + uses: ./.github/actions/pr-comment-on-failure + with: + check-name: "lint" + command: "uv run ruff check --fix" + + - name: Summarize typecheck failure + if: failure() && steps.typecheck.outcome == 'failure' + continue-on-error: true + uses: ./.github/actions/pr-comment-on-failure + with: + check-name: "typecheck" + command: "uv run --only-group typecheck mypy marimo --exclude=marimo/_tutorials/" + + # For PRs, we only run the tests for the changed files. + # If there is a `test-all` label, we run the tests across unchanged files as well. + # + # Split by OS so that a failure on one runner does not cancel sibling jobs on + # other runners (each job has its own matrix and therefore its own fail-fast + # scope). Within a single OS, the default fail-fast behavior is preserved. + test_python_ubuntu: + needs: changes + if: ${{ needs.changes.outputs.backend == 'true' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'test-all')) }} + name: ubuntu-latest / Py ${{ matrix.python-version }} / ${{ matrix.dependencies }} deps + runs-on: ubuntu-latest + timeout-minutes: 25 strategy: matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - dependencies: ['core', 'core,optional'] - python-version: ['3.9'] + python-version: ["3.10"] + dependencies: ["core", "core,optional"] include: - - os: ubuntu-latest - python-version: '3.10' - dependencies: 'core' - - os: ubuntu-latest - python-version: '3.11' - dependencies: 'core' - - os: ubuntu-latest - python-version: '3.12' - dependencies: 'core' - - os: ubuntu-latest - python-version: '3.9' - dependencies: 'core,optional' - - os: ubuntu-latest - python-version: '3.10' - dependencies: 'core,optional' - - os: ubuntu-latest - python-version: '3.11' - dependencies: 'core,optional' - - os: ubuntu-latest - python-version: '3.12' - dependencies: 'core,optional' + - python-version: "3.10" + dependencies: "minimal" + - python-version: "3.11" + dependencies: "core" + - python-version: "3.12" + dependencies: "core" + - python-version: "3.13" + dependencies: "core" + - python-version: "3.14" + dependencies: "core" + - python-version: "3.11" + dependencies: "core,optional" + - python-version: "3.12" + dependencies: "core,optional" + - python-version: "3.13" + dependencies: "core,optional" + # TODO: Add in 3.14 optional once there is broader wheel support + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 # Fetch all history for git diff + - uses: ./.github/actions/test-python + with: + python-version: ${{ matrix.python-version }} + dependencies: ${{ matrix.dependencies }} + + test_python_macos: + needs: changes + if: ${{ needs.changes.outputs.backend == 'true' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'test-all')) }} + name: macos-latest / Py ${{ matrix.python-version }} / ${{ matrix.dependencies }} deps + runs-on: macos-latest + timeout-minutes: 25 + strategy: + matrix: + python-version: ["3.10"] + dependencies: ["core", "core,optional"] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 # Fetch all history for git diff + - uses: ./.github/actions/test-python + with: + python-version: ${{ matrix.python-version }} + dependencies: ${{ matrix.dependencies }} + + test_python_windows: + needs: changes + if: ${{ needs.changes.outputs.backend == 'true' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'test-all')) }} + name: windows-latest / Py ${{ matrix.python-version }} / ${{ matrix.dependencies }} deps + runs-on: windows-latest + timeout-minutes: 25 + strategy: + matrix: + python-version: ["3.10"] + dependencies: ["core", "core,optional"] steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 # Fetch all history for git diff + - uses: ./.github/actions/test-python + with: + python-version: ${{ matrix.python-version }} + dependencies: ${{ matrix.dependencies }} - - uses: actions/checkout@v4 + # Only run coverage on `main` so it is not blocking + test_coverage: + needs: changes + if: ${{ needs.changes.outputs.backend == 'true' && github.ref == 'refs/heads/main' && github.event_name == 'push' }} + name: Test coverage + runs-on: ubuntu-latest + timeout-minutes: 35 + permissions: + contents: read + id-token: write # required for Doppler OIDC + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - name: 🥚 Install Hatch - uses: pypa/hatch@install + - name: 🐍 Setup uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 # This step is needed since some of our tests rely on the index.html file - name: Create assets directory, copy over index.html @@ -77,22 +202,40 @@ jobs: cp frontend/index.html marimo/_static/index.html cp frontend/public/favicon.ico marimo/_static/favicon.ico - - name: Lint - if: ${{ matrix.python-version == '3.12' }} - run: hatch run lint + - name: Test with optional dependencies + run: | + uv run --group test-optional pytest \ + -v \ + tests/ \ + -k "not test_cli" \ + --durations=10 \ + --picked=first \ + --inline-snapshot=disable \ + --cov=marimo --cov-report=xml --junitxml=junit.xml -o junit_family=legacy - - name: Typecheck - if: ${{ matrix.python-version == '3.12' }} - run: hatch run typecheck:check + - name: 🔐 Fetch Doppler secrets + if: ${{ !cancelled() }} + uses: dopplerhq/secrets-fetch-action@cd2efbf9a404504316435873eff298b82f7e0562 # v1.3.0 + id: secrets + with: + auth-method: oidc + doppler-identity-id: ${{ vars.DOPPLER_IDENTITY_ID }} + doppler-project: marimo-oss + doppler-config: ci_test - # Test with minimal dependencies - - name: Test with minimal dependencies - if: ${{ matrix.dependencies == 'core' }} - run: | - hatch run +py=${{ matrix.python-version }} test:test -v tests/ -k "not test_cli" --durations=10 + # Only upload coverage on `main` so it is not blocking + # and only on `3.13`, `ubuntu-latest`, with optional deps since it will cover + # the most surface area. + - name: Upload coverage reports to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 + with: + files: ./coverage.xml + env: + CODECOV_TOKEN: ${{ steps.secrets.outputs.CODECOV_TOKEN }} - # Test with optional dependencies - - name: Test with optional dependencies - if: ${{ matrix.dependencies == 'core,optional' }} - run: | - hatch run +py=${{ matrix.python-version }} test-optional:test -v tests/ -k "not test_cli" --durations=10 + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1 + env: + CODECOV_TOKEN: ${{ steps.secrets.outputs.CODECOV_TOKEN }} diff --git a/.github/workflows/test_be_dagger.yaml.skip b/.github/workflows/test_be_dagger.yaml.skip deleted file mode 100644 index 0c26c99c6a4..00000000000 --- a/.github/workflows/test_be_dagger.yaml.skip +++ /dev/null @@ -1,42 +0,0 @@ -name: Test BE Dagger - -on: - push: - branches: [main] - pull_request: - -jobs: - changes: - runs-on: ubuntu-latest - outputs: - backend: ${{ steps.filter.outputs.backend }} - steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 - id: filter - with: - filters: | - backend: - - 'marimo/**' - - 'tests/**' - - 'pyproject.toml' - - 'dagger/**' - - test_python: - needs: changes - if: ${{ needs.changes.outputs.backend == 'true' }} - name: Daggerized Backend Test - runs-on: ubuntu-latest - timeout-minutes: 15 - continue-on-error: true - - steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 - - - uses: actions/checkout@v4 - - - uses: dagger/dagger-for-github@v6 - name: 🚀 Dagger - with: - args: backend test diff --git a/.github/workflows/test_cli.yaml b/.github/workflows/test_cli.yaml index 3c0e1546efa..e0a213fc67d 100644 --- a/.github/workflows/test_cli.yaml +++ b/.github/workflows/test_cli.yaml @@ -5,68 +5,74 @@ on: branches: [main] pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: marimo MARIMO_SKIP_UPDATE_CHECK: 1 - + UV_EXCLUDE_NEWER: "7 days" jobs: changes: runs-on: ubuntu-latest outputs: cli: ${{ steps.filter.outputs.cli }} steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 id: filter with: filters: | cli: - - '**' - - '!docs/**' - - '!examples/**' - - '!README.md' + - 'marimo/**' + - '.github/workflows/test_cli.yaml' build_wheel: needs: changes if: ${{ needs.changes.outputs.cli == 'true' }} name: Build marimo wheel runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 8 # 2024-01-18 + permissions: + contents: read + id-token: write # required for Doppler OIDC defaults: run: shell: bash steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 - - name: ⬇️ Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 # so we can run --since on the main branch and turbo can do faster cache hashing - - uses: pnpm/action-setup@v2 + - name: 🔐 Fetch Doppler secrets + # Skip on PRs from forks: id-token write is downgraded to read, so OIDC fails. + # Downstream tokens (TURBO_TOKEN, CODECOV_TOKEN) are optional and tolerate empty. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: dopplerhq/secrets-fetch-action@cd2efbf9a404504316435873eff298b82f7e0562 # v1.3.0 + id: secrets with: - version: 9 - - - name: ⎔ Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: 'pnpm' - cache-dependency-path: '**/pnpm-lock.yaml' + auth-method: oidc + doppler-identity-id: ${{ vars.DOPPLER_IDENTITY_ID }} + doppler-project: marimo-oss + doppler-config: ci_test - name: 📦 Build frontend - run: make fe - env: - NODE_OPTIONS: '--max_old_space_size=8192' + uses: ./.github/actions/build-frontend + with: + turbo-token: ${{ steps.secrets.outputs.TURBO_TOKEN }} + codecov-token: ${{ steps.secrets.outputs.CODECOV_TOKEN }} - - name: 🥚 Install Hatch - uses: pypa/hatch@install + - name: 🐍 Setup uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 - name: 📦 Build marimo wheel - run: hatch build -t wheel + run: uv build --wheel - name: Check for _static/_lsp directory in wheel run: | @@ -88,88 +94,123 @@ jobs: fi done + - name: Run LSP binary + run: ./scripts/test-lsp.sh - name: Upload wheel - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: testwheel path: dist/*.whl retention-days: 1 + test_pyodide: + needs: [changes, build_wheel] + if: ${{ needs.changes.outputs.cli == 'true' }} + name: Test Pyodide + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: 📦 Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + + - name: Download wheel + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: testwheel + path: dist + + - name: 🧪 Run Pyodide acceptance test + run: | + # Get version from package.json + pyodide_version=$(cat frontend/package.json | jq -r '.dependencies["pyodide"]') + echo "Pyodide version: $pyodide_version" + npm install --ignore-scripts pyodide@$pyodide_version + wheel_file=$(ls dist/*.whl) + echo "Testing wheel: $wheel_file" + node tests/_pyodide/test_pyodide_acceptance.mjs "$wheel_file" + test_cli: needs: [changes, build_wheel] if: ${{ needs.changes.outputs.cli == 'true' }} name: Tests CLI on ${{ matrix.os }} ${{ matrix.python-version }} runs-on: ${{ matrix.os }} - timeout-minutes: 15 + timeout-minutes: 10 # 2024-01-18 avg: 2.5m max: 4.5m strategy: matrix: - # TODO(akshayka): consider adding 3.8, 3.9 - python-version: ['3.12'] - os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.12"] + # TODO: update to latest once networking issues on macos-15 are resolved + # https://github.com/actions/runner-images/issues/10924 + os: [ubuntu-latest, macos-14, windows-latest] steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: 🐍 Setup uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 with: + enable-cache: true python-version: ${{ matrix.python-version }} - cache: 'pip' - - - name: Install Python deps - run: | - python -m pip install --upgrade pip - pip install pytest pytest-asyncio nbformat + # TODO(https://github.com/astral-sh/setup-uv/issues/226): Remove this. + prune-cache: ${{ matrix.os != 'windows-latest' }} - name: Download wheel - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: testwheel - - name: Install marimo - shell: bash - run: pip install marimo*whl - + # xdist is disabled for CLI tests because they test process-level + # behavior (spawning marimo processes, port binding) that conflicts + # with parallel execution. + # + # UV_FIND_LINKS points sandbox subprocesses at the locally-built wheel, + # so `marimo[lsp]==` resolves to it instead of PyPI. + # Without this, a marimo release published less than UV_EXCLUDE_NEWER ago + # falls outside the cutoff and sandbox resolution fails. - name: Test CLI shell: bash + env: + UV_FIND_LINKS: ${{ github.workspace }} run: | - pytest -v tests/_cli/test_cli* --maxfail=2 + uv venv + uv pip install --group=test-optional --group=dev marimo*.whl + uv run --no-project pytest -v tests/_cli/test_cli* -p no:xdist --maxfail=2 test_examples: - needs: [changes, build_wheel] - if: ${{ needs.changes.outputs.cli == 'true' }} + needs: [changes] name: Tests examples and smoke_tests runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 3 # 2024-01-18 avg: 0.3m max: 0.5m steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 - - uses: actions/checkout@v4 - - name: Set up Python 3.12 - uses: actions/setup-python@v5 - with: - python-version: '3.12' - cache: 'pip' + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: 🐍 Setup uv - uses: yezz123/setup-uv@v4 - with: - uv-venv: marimo-venv - - - name: Download wheel - uses: actions/download-artifact@v4 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 with: - name: testwheel + enable-cache: true + python-version: 3.12 - - name: Install Python deps + - name: Check example quality with marimo check + shell: bash run: | - uv pip install pytest pytest-asyncio - uv pip install ./marimo*whl + echo "Checking examples directory..." + uv run marimo check --ignore-scripts --strict examples/ - - name: Test examples + - name: Check tutorial quality with marimo check shell: bash - run: pytest -v -s tests/_smoke_tests/run_all.py - # TODO(msconick) remove continue-on-error when all tests pass - continue-on-error: true + run: | + echo "Checking tutorials..." + uv run marimo check --ignore-scripts --fix \ + marimo/_tutorials/ >> /dev/null \ + || echo "Errors expected" + # Fail if there's a git diff + if [[ -n "$(git status --porcelain marimo/_tutorials)" ]]; then + echo "marimo check found issues in tutorials." + git --no-pager diff marimo/_tutorials + exit 1 + fi diff --git a/.github/workflows/test_design_md.yaml b/.github/workflows/test_design_md.yaml new file mode 100644 index 00000000000..0f65a972319 --- /dev/null +++ b/.github/workflows/test_design_md.yaml @@ -0,0 +1,36 @@ +name: DESIGN.md + +on: + pull_request: + paths: + - "DESIGN.md" + - "Makefile" + - "frontend/src/components/editor/renderers/grid-layout/plugin.tsx" + - "frontend/src/core/config/config-schema.ts" + - "frontend/src/css/**" + - "frontend/src/plugins/impl/data-editor/themes.ts" + - "frontend/tailwind.config.cjs" + - "scripts/generate-design-md.ts" + - ".github/workflows/test_design_md.yaml" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + design-md: + name: Check DESIGN.md + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: prefix-dev/setup-pixi@82d477f15f3a381dbcc8adc1206ce643fe110fb7 # v0.9.3 + with: + pixi-version: v0.62.2 + locked: false + frozen: false + cache: false + - run: pixi run make design-md-check diff --git a/.github/workflows/test_fe.yaml b/.github/workflows/test_fe.yaml index 8a9c2b1c2e6..5b7310c8ba5 100644 --- a/.github/workflows/test_fe.yaml +++ b/.github/workflows/test_fe.yaml @@ -5,8 +5,14 @@ on: branches: [main] pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: marimo MARIMO_SKIP_UPDATE_CHECK: 1 @@ -16,74 +22,258 @@ jobs: outputs: frontend: ${{ steps.filter.outputs.frontend }} steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 id: filter with: filters: | frontend: - 'frontend/**' - - 'lsp/**' - - 'openapi/**' + - 'packages/**' + - 'pnpm-workspace.yaml' - test_frontend: + lint_frontend: needs: changes if: ${{ needs.changes.outputs.frontend == 'true' }} - name: 🖥️ Lint, test, build frontend + name: 🧹 Lint frontend runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 10 + permissions: + contents: read + id-token: write # required for Doppler OIDC defaults: run: - working-directory: ./frontend shell: bash steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 - - name: ⬇️ Checkout repo - uses: actions/checkout@v4 - with: - fetch-depth: 0 # so we can run --since on the main branch and turbo can do faster cache hashing - - - uses: pnpm/action-setup@v2 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - version: 9 + fetch-depth: 0 - - name: ⎔ Setup Node.js - uses: actions/setup-node@v4 + - name: 🔐 Fetch Doppler secrets + # Skip on PRs from forks: id-token write is downgraded to read, so OIDC fails. + # Downstream tokens (TURBO_TOKEN, CODECOV_TOKEN) are optional and tolerate empty. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: dopplerhq/secrets-fetch-action@cd2efbf9a404504316435873eff298b82f7e0562 # v1.3.0 + id: secrets with: - node-version: 20 - cache: 'pnpm' - cache-dependency-path: '**/pnpm-lock.yaml' + auth-method: oidc + doppler-identity-id: ${{ vars.DOPPLER_IDENTITY_ID }} + doppler-project: marimo-oss + doppler-config: ci_test - - name: 📥 Install dependencies - run: pnpm install + - name: 📥 Install repo + uses: ./.github/actions/install - name: 📦 pnpm dedupe if: github.event_name == 'pull_request' + id: dedupe run: pnpm dedupe --check - name: 🧹 Lint + id: lint + env: + TURBO_TOKEN: ${{ steps.secrets.outputs.TURBO_TOKEN }} run: pnpm turbo lint + - name: 🔒 Verify clean git tree after lint + id: lint_clean_tree + run: | + CHANGES="$(git status --porcelain)" + if [[ -n "$CHANGES" ]]; then + echo "Lint changed tracked files. Commit generated/autofixed files." + echo "$CHANGES" + git --no-pager diff --stat + exit 1 + fi + + - name: Summarize lint failure + if: failure() && steps.lint.outcome == 'failure' + uses: ./.github/actions/pr-comment-on-failure + with: + check-name: "lint" + command: "pnpm turbo lint" + + - name: Summarize clean-tree failure after lint + if: failure() && steps.lint_clean_tree.outcome == 'failure' + uses: ./.github/actions/pr-comment-on-failure + with: + check-name: "clean-tree" + command: "pnpm turbo lint && git status --short" + + - name: Summarize dedupe failure + if: failure() && steps.dedupe.outcome == 'failure' + uses: ./.github/actions/pr-comment-on-failure + with: + check-name: "dedupe" + command: "pnpm dedupe" + + test_frontend: + needs: changes + if: ${{ needs.changes.outputs.frontend == 'true' }} + name: 🧪 Test frontend + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write # required for Doppler OIDC + pull-requests: write # required to post the coverage report comment + defaults: + run: + working-directory: ./frontend + shell: bash + + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: 🔐 Fetch Doppler secrets + # Skip on PRs from forks: id-token write is downgraded to read, so OIDC fails. + # Downstream tokens (TURBO_TOKEN, CODECOV_TOKEN) are optional and tolerate empty. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: dopplerhq/secrets-fetch-action@cd2efbf9a404504316435873eff298b82f7e0562 # v1.3.0 + id: secrets + with: + auth-method: oidc + doppler-identity-id: ${{ vars.DOPPLER_IDENTITY_ID }} + doppler-project: marimo-oss + doppler-config: ci_test + + - name: 📥 Install repo + uses: ./.github/actions/install + + - name: 📋 Register tsc problem matcher + run: echo "::add-matcher::${{ github.workspace }}/.github/problemMatchers/tsc.json" + - name: 🔎 Type check + id: typecheck + env: + TURBO_TOKEN: ${{ steps.secrets.outputs.TURBO_TOKEN }} run: pnpm turbo typecheck + - name: 🔒 Verify clean git tree after typecheck + id: typecheck_clean_tree + run: | + CHANGES="$(git status --porcelain)" + if [[ -n "$CHANGES" ]]; then + echo "Typecheck/codegen changed tracked files. Commit generated files." + echo "$CHANGES" + git --no-pager diff --stat + exit 1 + fi + - name: 🧪 Test - run: pnpm test + id: test + env: + TURBO_TOKEN: ${{ steps.secrets.outputs.TURBO_TOKEN }} + run: pnpm turbo test:coverage + + - name: 📊 Report coverage + # Runs even when tests fail (reportOnFailure writes the summary anyway). + # Fork PRs run with a read-only GITHUB_TOKEN, so posting the comment + # fails with 403 — don't fail the check for external contributors. + if: always() + continue-on-error: true + uses: davelosert/vitest-coverage-report-action@3c50566c523e04813df28de8f7c48dd97d663f1c # v2 + with: + # Paths below are resolved relative to working-directory, so the + # effective locations are ./frontend/coverage/*.json (where vitest + # writes them — verified locally). + working-directory: ./frontend + json-summary-path: coverage/coverage-summary.json + json-final-path: coverage/coverage-final.json + + - name: Summarize typecheck failure + if: failure() && steps.typecheck.outcome == 'failure' + uses: ./.github/actions/pr-comment-on-failure + with: + check-name: "typecheck" + command: "pnpm turbo typecheck" + + - name: Summarize test failure + if: failure() && steps.test.outcome == 'failure' + uses: ./.github/actions/pr-comment-on-failure + with: + check-name: "test" + command: "pnpm turbo test" + + - name: Summarize clean-tree failure after typecheck + if: failure() && steps.typecheck_clean_tree.outcome == 'failure' + uses: ./.github/actions/pr-comment-on-failure + with: + check-name: "clean-tree" + command: "pnpm turbo typecheck && git status --short" + + build_frontend: + needs: changes + if: ${{ needs.changes.outputs.frontend == 'true' }} + name: 📦 Build frontend + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write # required for Doppler OIDC + defaults: + run: + working-directory: ./frontend + shell: bash + + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: 🔐 Fetch Doppler secrets + # Skip on PRs from forks: id-token write is downgraded to read, so OIDC fails. + # Downstream tokens (TURBO_TOKEN, CODECOV_TOKEN) are optional and tolerate empty. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: dopplerhq/secrets-fetch-action@cd2efbf9a404504316435873eff298b82f7e0562 # v1.3.0 + id: secrets + with: + auth-method: oidc + doppler-identity-id: ${{ vars.DOPPLER_IDENTITY_ID }} + doppler-project: marimo-oss + doppler-config: ci_test + + - name: 📥 Install repo + uses: ./.github/actions/install + + - name: 🔍 Check pinned deps + run: | + if ! grep -q '"react-hook-form": "7.54.2"' package.json; then + echo "Error: react-hook-form version in package.json must be exactly 7.54.2. As it breaks mo.ui.dataframe" + exit 1 + fi + if ! grep -q '"html-to-image": "1.11.13"' package.json; then + echo "Error: html-to-image version in package.json must be exactly 1.11.13. Newer versions break matplotlib toolbars" + exit 1 + fi - name: 📦 Build + id: build run: pnpm turbo build env: NODE_ENV: production + TURBO_TOKEN: ${{ steps.secrets.outputs.TURBO_TOKEN }} - name: 📦 Build islands frontend env: NODE_ENV: production - VITE_MARIMO_ISLANDS: 'true' - VITE_MARIMO_VERSION: '0.0.0' + VITE_MARIMO_ISLANDS: "true" + VITE_MARIMO_VERSION: "0.0.0" + TURBO_TOKEN: ${{ steps.secrets.outputs.TURBO_TOKEN }} run: | npm version 0.0.0 --no-git-tag-version pnpm turbo build:islands ./islands/validate.sh + + - name: Summarize build failure + if: failure() && steps.build.outcome == 'failure' + uses: ./.github/actions/pr-comment-on-failure + with: + check-name: "build" + command: "pnpm turbo build" diff --git a/.github/workflows/test_fe_dagger.yaml.skip b/.github/workflows/test_fe_dagger.yaml.skip deleted file mode 100644 index c470bfac2b9..00000000000 --- a/.github/workflows/test_fe_dagger.yaml.skip +++ /dev/null @@ -1,46 +0,0 @@ -name: Test FE Dagger - -on: - push: - branches: [main] - pull_request: - -env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - -jobs: - changes: - runs-on: ubuntu-latest - outputs: - frontend: ${{ steps.filter.outputs.frontend }} - steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 - id: filter - with: - filters: | - frontend: - - 'frontend/**' - - 'lsp/**' - - 'openapi/**' - - 'dagger/**' - - test_frontend: - needs: changes - if: ${{ needs.changes.outputs.frontend == 'true' }} - name: 🖥️ Lint, test, build frontend - runs-on: ubuntu-latest - timeout-minutes: 15 - continue-on-error: true - - steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 - - - name: ⬇️ Checkout repo - uses: actions/checkout@v4 - - - uses: dagger/dagger-for-github@v6 - name: 🚀 Dagger - with: - args: frontend test diff --git a/.github/workflows/test_marimo_lsp.yaml b/.github/workflows/test_marimo_lsp.yaml new file mode 100644 index 00000000000..807ca3a79c5 --- /dev/null +++ b/.github/workflows/test_marimo_lsp.yaml @@ -0,0 +1,84 @@ +name: Test marimo-lsp compatibility + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + issues: write + +env: + UV_EXCLUDE_NEWER: "7 days" + +jobs: + test_marimo_lsp: + runs-on: ubuntu-latest + steps: + - name: ⬇️ Checkout marimo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: ⬇️ Checkout marimo-lsp + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: marimo-team/marimo-lsp + path: marimo-lsp + + - name: 🐍 Setup uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + + - name: 🔧 Install marimo-lsp with local marimo + working-directory: marimo-lsp + run: | + uv add --editable ../ + + - name: 🔍 Typecheck + id: typecheck + working-directory: marimo-lsp + run: uv run ty check + + - name: 🧪 Run tests + if: github.event_name == 'workflow_dispatch' + id: tests + working-directory: marimo-lsp + run: uv run pytest + + - name: 🐛 Open issue on failure + if: failure() && github.event_name == 'push' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const title = '🚨 marimo-lsp compatibility check failed'; + // Check if an open issue already exists + const existing = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'marimo-lsp', + }); + if (existing.data.some(issue => issue.title === title)) { + console.log('Issue already exists, skipping creation'); + return; + } + const commit = context.sha.substring(0, 7); + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + labels: ['marimo-lsp'], + body: [ + `The marimo-lsp compatibility check failed after merging commit ${commit} into \`main\`.`, + '', + `**Workflow run:** ${runUrl}`, + '', + 'The typecheck (`uv run ty check`) in [marimo-team/marimo-lsp](https://github.com/marimo-team/marimo-lsp) failed against the latest marimo.', + '', + 'This likely means a recent change broke the marimo-lsp integration.', + ].join('\n'), + }); diff --git a/.github/workflows/test_no_build.yaml b/.github/workflows/test_no_build.yaml new file mode 100644 index 00000000000..7c7c876b2b1 --- /dev/null +++ b/.github/workflows/test_no_build.yaml @@ -0,0 +1,43 @@ +name: Test Pre-built Wheel + +permissions: + contents: read + +on: + push: + branches: + - main + paths: + - .github/workflows/test_no_build.yaml + - pyproject.toml + - marimo/_cli/** + pull_request: + branches: + - main + paths: + - .github/workflows/test_no_build.yaml + - pyproject.toml + - marimo/_cli/** + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + UV_EXCLUDE_NEWER: "7 days" + +jobs: + Test: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + - run: | + uv build --wheel + WHEEL=$(ls dist/*.whl) + uv tool run --no-build --python=${{ matrix.python-version }} --from "$WHEEL" marimo --version diff --git a/.github/workflows/test_schemas.yaml b/.github/workflows/test_schemas.yaml new file mode 100644 index 00000000000..55b4caaae8a --- /dev/null +++ b/.github/workflows/test_schemas.yaml @@ -0,0 +1,137 @@ +name: Schema Breaking Changes Check + +on: + pull_request: + paths: + - "marimo/_schemas/**/*.yaml" + - "packages/openapi/api.yaml" + - ".github/workflows/test_schemas.yaml" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + openapi: ${{ steps.filter.outputs.openapi }} + session: ${{ steps.filter.outputs.session }} + notebook: ${{ steps.filter.outputs.notebook }} + notifications: ${{ steps.filter.outputs.notifications }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + id: filter + with: + filters: | + openapi: + - 'packages/openapi/api.yaml' + - '.github/workflows/test_schemas.yaml' + session: + - 'marimo/_schemas/generated/session.yaml' + - '.github/workflows/test_schemas.yaml' + notebook: + - 'marimo/_schemas/generated/notebook.yaml' + - '.github/workflows/test_schemas.yaml' + notifications: + - 'marimo/_schemas/generated/notifications.yaml' + - '.github/workflows/test_schemas.yaml' + + check-schemas: + needs: changes + runs-on: ubuntu-latest + strategy: + matrix: + schema: + - name: openapi + path: packages/openapi/api.yaml + display_name: OpenAPI specification + condition: ${{ needs.changes.outputs.openapi == 'true' }} + - name: session + path: marimo/_schemas/generated/session.yaml + display_name: Session Schema + condition: ${{ needs.changes.outputs.session == 'true' }} + - name: notebook + path: marimo/_schemas/generated/notebook.yaml + display_name: Notebook Schema + condition: ${{ needs.changes.outputs.notebook == 'true' }} + - name: notifications + path: marimo/_schemas/generated/notifications.yaml + display_name: Notifications Schema + condition: ${{ needs.changes.outputs.notifications == 'true' }} + steps: + - name: Checkout current branch + if: ${{ matrix.schema.condition }} + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + path: current + + - name: Checkout main branch + if: ${{ matrix.schema.condition }} + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: main + path: main + + - name: Set up Java + if: ${{ matrix.schema.condition }} + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Install openapi-diff + if: ${{ matrix.schema.condition }} + run: | + # Download openapi-diff CLI (all.jar includes all dependencies and manifest) + curl -fsSL https://repo1.maven.org/maven2/org/openapitools/openapidiff/openapi-diff-cli/2.1.6/openapi-diff-cli-2.1.6-all.jar -o openapi-diff.jar + + - name: Compare ${{ matrix.schema.display_name }} + if: ${{ matrix.schema.condition }} + id: compare + run: | + set +e + OUTPUT=$(java -jar openapi-diff.jar main/${{ matrix.schema.path }} current/${{ matrix.schema.path }} --fail-on-incompatible 2>&1) + EXIT_CODE=$? + set -e + + if [ $EXIT_CODE -ne 0 ]; then + echo "breaking_changes<> $GITHUB_OUTPUT + echo "$OUTPUT" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "Breaking changes detected:" + echo "$OUTPUT" + exit 1 + else + echo "No breaking changes detected" + echo "$OUTPUT" + fi + + - name: Comment on PR + if: ${{ failure() && matrix.schema.condition }} + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + env: + BREAKING_CHANGES: ${{ steps.compare.outputs.breaking_changes }} + DISPLAY_NAME: ${{ matrix.schema.display_name }} + with: + script: | + const breakingChanges = process.env.BREAKING_CHANGES || 'No details available'; + const displayName = process.env.DISPLAY_NAME || 'Schema'; + + const output = `Breaking changes detected in the ${displayName}! + + \`\`\` + ${breakingChanges} + \`\`\``; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: output + }) diff --git a/.github/workflows/test_typos.yaml b/.github/workflows/test_typos.yaml index cff819242ce..c30b872c483 100644 --- a/.github/workflows/test_typos.yaml +++ b/.github/workflows/test_typos.yaml @@ -4,12 +4,17 @@ name: Typos on: pull_request: {} +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: typos: name: Check for typos runs-on: ubuntu-latest steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 - - uses: actions/checkout@v4 - - uses: crate-ci/typos@v1.23.6 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: crate-ci/typos@db35ee91e80fbb447f33b0e5fbddb24d2a1a884f # v1.29.10 diff --git a/.gitignore b/.gitignore index 5a679caa944..4f5af9ae13e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ *.txt !tests/**/*.txt +node_modules/ +.turbo/ + # Byte-compiled / optimized / DLL files __pycache__ __pycache__/ @@ -71,9 +74,6 @@ docs/_build/ profile_default/ ipython_config.py -# pyenv -.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 @@ -89,6 +89,11 @@ venv/ ENV/ env.bak/ venv.bak/ +uv.lock + +# WIP files +.wip/ +wip/ # editors *.swp @@ -104,12 +109,30 @@ npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* +.pnpm-store/ marimo/_static/ marimo/_lsp/ .vscode +.vercel +.idea +.claude .marimo.toml .mypy_cache/ __marimo__ +.aider* + +# pixi environments +.pixi +*.egg-info +# lockfiles normally get commited but pixi's lockfile algorithm is so bad +# that is almost always creates merge conflicts +pixi.lock + +# mkdocs +site + +.agents/ +*skills* diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000000..f70a0ab3ebe --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,31 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "useTabs": false, + "tabWidth": 2, + "printWidth": 80, + "singleQuote": false, + "jsxSingleQuote": false, + "quoteProps": "as-needed", + "trailingComma": "all", + "semi": true, + "arrowParens": "always", + "bracketSameLine": false, + "bracketSpacing": true, + "singleAttributePerLine": false, + "endOfLine": "lf", + "ignorePatterns": [ + "*.md", + "frontend/**/*.html", + "*.json", + "*.jsonc", + "tests/**", + "docs/**", + "examples/**", + "marimo/**", + ".github/**", + "**/e2e-tests/**", + "*.yaml", + "*.toml", + "*.yml" + ] +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 460eeee60f7..3ae754ecd82 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,49 +2,72 @@ fail_fast: false repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-yaml - repo: https://github.com/rhysd/actionlint - rev: v1.7.4 + rev: v1.7.12 hooks: - id: actionlint args: [-ignore, SC] - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.23 + rev: v0.25 hooks: - id: validate-pyproject - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.43.0 + rev: v0.49.1 hooks: - id: markdownlint-fix args: [-c, configs/.markdownlint.yaml, --fix, --disable, MD028] exclude: ^marimo/_tutorials/.*\.md - repo: https://github.com/crate-ci/typos - rev: typos-dict-v0.11.37 + rev: v1 hooks: - id: typos + pass_filenames: false + + - repo: local + hooks: + - id: no-double-backticks-in-docstrings + name: no double backticks in docstrings + entry: python scripts/check_docstring_backticks.py + language: python + types: [python] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.1 + rev: v0.16.1 hooks: # Run the linter - - id: ruff + - id: ruff-check args: [--fix] # Run the formatter - id: ruff-format - - repo: https://github.com/biomejs/pre-commit - rev: v0.5.0 + # TODO: add back. this throws errors: thread 'tokio-rt-worker' (169) panicked at crates/oxc_allocator/src/pool/fixed_size.rs:112:67 + # - repo: https://github.com/oxc-project/mirrors-oxlint + # rev: v1.56.0 + # hooks: + # - id: oxlint + # args: [--fix, --quiet] + # files: ^(frontend/src/|packages/) + + - repo: https://github.com/oxc-project/mirrors-oxfmt + rev: v0.45.0 + hooks: + - id: oxfmt + args: [--write, --config, .oxfmtrc.json] + files: ^(frontend/src/|packages/) + "types_or": [javascript, jsx, ts, tsx] + + - repo: local hooks: - - id: biome-check - args: [--config-path, biome.json, --diagnostic-level, warn] - additional_dependencies: ['@biomejs/biome@1.9.4'] - exclude: | - (?x)^( - docs/_static/.*| - )$ + - id: unique-test-filenames + name: Enforce unique test filenames + entry: bash -c 'if command -v uv >/dev/null 2>&1; then uv run python scripts/check_test_filenames.py; else python scripts/check_test_filenames.py; fi' + language: system + pass_filenames: false + files: ^tests/.*\.py$ diff --git a/.python-version b/.python-version new file mode 100644 index 00000000000..24ee5b1be99 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 699ed73319b..91f1f51ab0e 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,10 @@ { - "recommendations": ["biomejs.biome"] + "recommendations": [ + // Python + "charliermarsh.ruff", + + // TypeScript/JavaScript/React + "void-zero.vite-plus-extension-pack", + "ms-vscode.vscode-typescript-next", + ], } diff --git a/.vscode/settings.json b/.vscode/settings.json index eb10ce73db7..5d6df23d0d9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,14 +1,36 @@ { - "[javascript]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[typescript]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[typescriptreact]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[json]": { - "editor.defaultFormatter": "biomejs.biome" - } + // These match our linters + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + }, + "[typescript]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[json]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[markdown]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": ["tests", "-v", "--tb=short"], + "python.testing.autoTestDiscoverOnSaveEnabled": true, + + // Other settings that match our linters + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..dbbf58430b7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,65 @@ +# marimo Development Guidelines + +marimo is a reactive notebook for Python with a modern web frontend. + +## Your primary responsibility is to the project and its users + +Being an open source library, the public API, abstractions, documentation, and the code itself are the product and deserve careful consideration. + +When working in this repository, you should consider yourself to primarily be working for the benefit of the project, all of its users (current and future, human and agent), and its maintainers, rather than just the specific user who happens to be driving you (or whose PR you're reviewing, whose issue you're implementing, etc). + +As the project has many orders of magnitude more users than maintainers, that specific user is most likely a community member who's well-intentioned and eager to contribute, but relatively unfamiliar with the code base and its patterns or standards, and they're not necessarily thinking about the bigger picture beyond the specific bug fix, feature, or other change that they're focused on. + +Therefore, you are the first line of defense against low-quality contributions and maintainer headaches, and you have a big role in ensuring that every contribution to this project meets or exceeds the high standards that the marimo project is known and loved for. You should write: + +- modern, idiomatic, concise React and Python code +- end-to-end type-safety and test coverage +- thoughtful, tasteful, consistent API design +- delightful developer experience +- comprehensive well-written documentation + +## Quick Setup + +```bash +make fe && make py +make dev +``` + +See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed setup options. + +## Development Commands + +```bash +# Python +make py-check # Typecheck and lint Python +uv run --group test pytest tests/path/to/test.py +uv run --group test-optional pytest tests/path/to/test.py # with optional deps +uv run --group test --python 3.11 pytest tests/path/to/test.py # specific python version + +# Frontend +make fe-check # Typecheck and lint frontend +cd frontend && pnpm test src/path/to/file.test.ts +``` + +## Commits + +- Run `make check` before committing + +## Pull Requests + +- DO NOT open a pull request autonomously, without explicit instructions from a human +- Autonomous AI agents such as OpenClaw, Nanobot, NanoClaw, ZeroClaw are NOT permitted to make PRs +- You MUST disclose that you are an agent at the very top of your PR description: "**This pull request was authored by a coding agent.**" +- You MUST mark your PRs as drafts +- See [CONTRIBUTING.md](CONTRIBUTING.md) for other PR guidelines and CLA + +## Conventions + +### Python + +Most style rules are enforced by ruff (`pyproject.toml`) and pre-commit; run +`make py-check`. Here are some that the linter may not catch, or for which +we may not have autofixes. + +- Docstrings are rendered as Markdown, not reStructuredText. Use single + backticks for inline code (`value`), never double backticks. diff --git a/CITATION.cff b/CITATION.cff index 629cf40cb24..783978337e7 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -26,7 +26,7 @@ keywords: - notebooks - dag type: software -url: 'https://marimo.io/' +url: "https://marimo.io/" license: Apache-2.0 message: "If you use this software, please cite it using these metadata." repository-code: "https://github.com/marimo-team/marimo" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..43c994c2d36 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000000..7f8b5374ca6 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,4 @@ +/docs/ @akshayka +/README.md @akshayka +/tests/snapshots/api.txt @akshayka +/tests/snapshots/dependencies.txt @akshayka diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index aa8be6f7579..d14fefdf123 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -55,7 +55,7 @@ further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at akshay@marimo.io. All +reported by contacting the project team at contact@marimo.io. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4bc0510aac..d08332627b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing Guide -We welcome all kinds of contributions. _You don't need to be an expert +We welcome contributions. _You don't need to be an expert in frontend or Python development to help out._ ## Checklist @@ -9,108 +9,137 @@ Contributions are made through [pull requests](https://help.github.com/articles/using-pull-requests/). Before sending a pull request, make sure to do the following: +- [Obtain maintainer approval](#maintainer-approval) - [Lint, typecheck, and format](#lint-typecheck-format) your code - [Write tests](#tests) - [Run tests](#tests) and check that they pass +- Read the [CLA](https://marimo.io/cla) -_Please reach out to the marimo team before starting work on a large -contribution._ Get in touch at -[GitHub issues](https://github.com/marimo-team/marimo/issues) -or [on Discord](https://marimo.io/discord?ref=contributing). +## Maintainer approval -## Prerequisites +Contributors must obtain maintainer approval before making +pull requests with substantial changes. Substance is not measured only +in lines of code. Here is a non-exhaustive list of changes we consider substantial: -To build marimo from source, you'll need to have Node.js, pnpm, GNU make, Python (>=3.9), and Hatch installed. +1. changes to the public API; +2. changes to required or optional dependencies; +3. changes to CI workflows; +4. changes with large internal refactors; +5. changes to our documentation architecture; +6. changes to default configuration; +7. opinionated changes to the user interface or user experience; +8. changes to the semantics of marimo's runtime; +9. changes to marimo's file format; +10. changes with many lines of code. -- Install [Node.js](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm#using-a-node-version-manager-to-install-nodejs-and-npm) >= 18 - - We use Node.js version 20 -- Install [pnpm](https://github.com/pnpm/pnpm) == 9.x - - `npm install -g pnpm@9` -- Install [GNU Make](https://www.gnu.org/software/make/) (you may already have it installed) -- Install [Python](https://www.python.org/) >= 3.9. (You may already it installed. To see your version, use `python -V` at the command line.) -- Install [Hatch](https://hatch.pypa.io/latest/install/). Some installation options: - - `brew install hatch` - - `pipx install hatch` +To obtain approval, open a [GitHub +issue](https://github.com/marimo-team/marimo/issues) describing the change you +would like to make and discuss it with a maintainer. If you would like to make +a PR for an issue that already exists, join the conversation in that issue. -And you'll need [pre-commit](https://pre-commit.com/) to run some validation checks: +**Labels.** We use GitHub [labels](https://docs.github.com/en/issues/using-labels-and-milestones-to-track-work/managing-labels) to categorize issues into two states: -```bash -pipx install pre-commit -# or `pip install pre-commit` if you have a virtualenv -# or `brew install pre-commit` -``` +- `needs discussion` — not ready for a PR +- `ready` — ready for a PR -You can optionally install pre-commit hooks to automatically run the validation checks -when making a commit: +If an issue does not have one of these two labels, assume it is **not ready** for a PR and requires discussion. -```bash -pre-commit install -``` +### Why is maintainer approval required? -> [!NOTE] -> -> As an alternative to building from source, you can try developing -> [in Gitpod](https://gitpod.io/#https://github.com/marimo-team/marimo). -> Note that developing in Gitpod is not officially supported by the marimo -> team. +**Deliberate design.** marimo is an intentionally designed project. We +put just as much thought into the features we exclude as the ones we include, +in order to provide our users with a simple, consistent, delightful, and +powerful experience. + +For examples of the thought we put in, see our [MEPs +repo](https://github.com/marimo-team/meps) and read our [founding +essays](https://docs.marimo.io/reading/). + +**Consistency.** Each feature affects the whole product. A change that seems +locally helpful can make the system less consistent and therefore harder to +learn, explain, or evolve. -## Building from source +**Small changes can have large consequences.** Unlike traditional software, in +open source, some changes are irreversible. Even seemingly minor changes — like +adjusting a function signature or adding “just one more option” — can +have long-lasting consequences. -Be sure to install the dependencies above before building from source. +**Maintenance burden.** New features create new work. What may seem like a +small change in lines of code can have a disproportionately large maintenance +cost when integrated over time. -### Build from source +**Review burden.** Though the cost of writing code has decreased, the cost of +reviewing code has not (in fact, it has increased). Asking a maintainer or +community member to review your changes imposes a cost on their time. Building +consensus before making the pull request shows respect. -After installing the dependencies, you can use either the traditional method (installing an editable wheel in your current venv) or use Hatch: +**Early consensus prevents wasted effort.** Seeking approval before implementing a +substantial change increases the chance that your work will be merged. -Traditional method: +## Setup + +_Note: We recommend that Windows developers use [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) and clone the marimo repository [into the WSL environment and not the Windows mount](https://learn.microsoft.com/en-us/windows/wsl/filesystems)._ + +### Prerequisites + +- [uv](https://docs.astral.sh/uv/getting-started/installation/) (Python package manager) +- [Node.js](https://nodejs.org/) 22+ +- [pnpm](https://pnpm.io/installation) 10+ + +### Getting started ```bash make fe && make py +make dev ``` -Using Hatch: +This will build the frontend, install Python dependencies in editable mode, and launch the dev server (backend on port 2718, frontend on port 3000). -```bash -make fe -hatch shell -``` +> [!TIP] +> On the marimo team we use `uv` + `node`/`pnpm` directly. Alternatively, [pixi](https://github.com/prefix-dev/pixi) can manage the Python and Node toolchains for you (`pixi shell` then proceed as above), and [Gitpod](https://gitpod.io/#https://github.com/marimo-team/marimo) provides a cloud-based dev environment — but we don't officially support either of these and recommend the setup above. -`make fe` builds the frontend. `make py` does an [editable install](https://setuptools.pypa.io/en/latest/userguide/development_mode.html) of marimo, while `hatch shell` creates a Hatch environment with an editable install of marimo. +### `pre-commit` hooks -(All `make` commands should be run in the project's root directory.) +You can optionally install [pre-commit](https://pre-commit.com/) hooks to automatically run the validation checks when making a commit: -### Building from source, unminified +```bash +uvx pre-commit install +``` To build the frontend unminified, run: ```bash -NODE_OPTIONS=--max_old_space_size=8192 NODE_ENV=development make fe -B +NODE_ENV=development make fe -B ``` ## `make` commands +> [!NOTE] +> Refer to the [Makefile](Makefile) for the implementation details + | Command | Category | Description | | -------------- | --------- | -------------------------------------------------------------- | -| `help` | General | Show this help | -| `py` | Setup | Editable python install; only need to run once | -| `install-all` | Setup | Install everything; takes a long time due to editable install | -| `fe` | Build | Package frontend into `marimo/` | -| `wheel` | Build | Build wheel | -| `check` | Test | Run all checks | -| `check-test` | Test | Run all checks and tests | -| `test` | Test | Run all tests | -| `fe-check` | Lint/Test | Check frontend | -| `fe-test` | Test | Test frontend | -| `e2e` | Test | Test end-to-end | -| `fe-lint` | Lint | Lint frontend | -| `fe-typecheck` | Lint | Typecheck frontend | -| `py-check` | Lint | Check python | -| `py-test` | Test | Test python | -| `py-snapshots` | Test | Update HTML snapshots | -| `storybook` | Docs | Run Storybook | -| `docs` | Docs | Build docs. Use `make ARGS="-a" docs` to force docs to rebuild | -| `docs-serve` | Docs | Serve docs | -| `docs-clean` | Docs | Remove built docs | +| `help` | General | 📖 Show available commands | +| `install-all` | Setup | 🚀 First-time setup: Install all dependencies (frontend & Python) | +| `check-prereqs`| Setup | ✓ Check if all required tools are installed | +| `py` | Setup | 🐍 Install Python dependencies in editable mode | +| `fe` | Build | 🔧 Build frontend assets | +| `test` | Test | 🧪 Run all tests (frontend, Python, end-to-end) | +| `check` | Test | 🧹 Run all checks | +| `fe-check` | Lint/Test | 🧹 Check frontend (lint, typecheck) | +| `fe-test` | Test | 🧪 Test frontend | +| `e2e` | Test | 🧪 Test end-to-end | +| `fe-lint` | Lint | 🧹 Lint frontend | +| `fe-typecheck` | Lint | 🔍 Typecheck frontend | +| `fe-codegen` | Build | 🔄 Generate frontend API | +| `py-check` | Lint | 🔍 Typecheck, lint, format python | +| `typos` | Lint | 🔍 Check for typos | +| `py-test` | Test | 🧪 Test python | +| `py-snapshots` | Test | 📸 Update snapshots | +| `wheel` | Build | 📦 Build wheel | +| `docs` | Docs | 📚 Build docs | +| `docs-serve` | Docs | 📚 Serve docs | +| `storybook` | Docs | 🧩 Start Storybook for UI development | ## Lint, Typecheck, Format @@ -128,19 +157,22 @@ make fe-check **Python.** -Using Make: - -```bash -make py-check -``` - -Using Hatch: - -```bash -hatch run lint -hatch run format -hatch run typecheck:check -``` + + + + + + + + + +
Using makeUsing uv
+
make py-check         
+
+
uv run ruff check --fix
+uv run ruff format
+uv run --only-group typecheck mypy marimo --exclude=marimo/_tutorials/
+
## Tests @@ -167,40 +199,32 @@ make fe-test ### Python -Using Make: +We use [pytest syntax](https://docs.pytest.org/en/stable/how-to/usage.html) for Python tests. + +#### Using Make ```bash make py-test ``` -#### Using Hatch - -**Run a specific tests.** +#### Using uv -```bash -hatch run test:test tests/_ast/ -``` - -**Run a specific tests with optional dependencies.** +Run a specific test ```bash -hatch run test-optional:test tests/_ast/ +uv run --python 3.13 --group test pytest tests/_ast/ ``` -**Run tests with a specific Python version.** +Run all changed tests ```bash -hatch run +py=3.10 test:test tests/_ast/ -# or -hatch run +py=3.10 test-optional:test tests/_ast/ +uv run --python 3.13 --group test pytest --picked ``` -**Run all tests across all Python versions.** - -Not recommended since it takes a long time. +Run tests with optional dependencies ```bash -hatch run test:test +uv run --python 3.13 --group test-optional pytest tests/_ast/ ``` ### End-to-end @@ -236,57 +260,71 @@ pnpm playwright test --ui In `frontend/`: -```bash -pnpm playwright test --ui -# e.g. -pnpm playwright test cells.test.ts --ui -``` - -or - -```bash -pnpm playwright test --debug -``` + + + + + + + + + +
Without debuggerWith debugger
+
pnpm playwright test $FILENAME        
+
+
pnpm playwright test --debug $FILENAME
+
## Storybook To open Storybook, run the following: ```bash -cd frontend/ -pnpm storybook +cd frontend && pnpm storybook ``` ## Hot reloading / development mode You can develop on marimo with hot reloading on the frontend and/or development mode on the server (which automatically restarts the server on code changes). -These modes especially helpful when you're making many small changes and +These modes are especially helpful when you're making many small changes and want to see changes end-to-end very quickly. -For the frontend, you can run either - -```bash -# starts a dev server on localhost:3000 and proxy requests to your marimo server -# has hot reloading and the fastest way to develop the frontend -# read caveats below -pnpm dev -``` - -### OR - -```bash -# OR, in order to test closer to production, you can build the frontend and watch for changes -pnpm build:watch -``` +For the frontend, you can choose to run slower hot reloading for an environment closer to production. + + + + + + + + + + +
Production-esqueDevelopment
+
pnpm build:watch      
+
+
pnpm dev              
+
For the backend, we recommend running without auth (`--no-token`): -```bash -marimo edit --no-token -# or in debug mode -marimo -d edit --no-token -``` + + + + + + + + + +
ProductionDebug
+
marimo edit --no-token   
+
+
marimo -d edit --no-token
+
+ +### FAQ - **When to run with hot-reloading?**: When you are developing on the frontend and want to see changes immediately. This is useful for styling, layout, new @@ -310,19 +348,15 @@ marimo server. This means that: develop on the frontend, but you will not be able to test the frontend in the same way that it will be used in production. -## Editor settings - -If use use vscode, you might find the following `settings.json` useful: - -```json -{ - "editor.formatOnSave": true, - "editor.formatOnPaste": false, - "[typescript]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[typescriptreact]": { - "editor.defaultFormatter": "biomejs.biome" - } -} -``` + +## PRs + +When submitting a pull request, marimo will run: lint, typecheck, and test jobs. + +We have some labels which can influence which tests are run: + +- `test-all`: Run all tests across unchanged files as well. + +## Your first PR + +Marimo has a variety of CI jobs that run on pull requests. All new PRs will fail until you have signed the [CLA](https://marimo.io/cla). Don't fret. You can sign the CLA by leaving a comment in the PR with text of `I have read the CLA Document and I hereby sign the CLA` diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000000..68a12b472e1 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,215 @@ +--- +version: alpha +name: marimo +description: Design system for marimo, a reactive Python notebook for reproducible, git-friendly, deployable work. +colors: + background: "#FFFFFF" + background-dark: "#181C1A" + foreground: "#0F172A" + foreground-dark: "#ECEEED" + surface-muted: "#F1F5F9" + surface-muted-dark: "#020303" + muted-foreground: "#64748B" + muted-foreground-dark: "#AAB2AF" + popover: "#FFFFFF" + popover-dark: "#252927" + popover-foreground: "#0F172A" + popover-foreground-dark: "#AAB2AF" + card: "#FFFFFF" + card-dark: "#252927" + card-foreground: "#0F172A" + card-foreground-dark: "#C0C6C3" + border: "#E2E8F0" + border-dark: "#3B403E" + input: "#A3A3A3" + input-dark: "#474C4A" + primary: "#0880EA" + primary-dark: "#28879F" + on-primary: "#F8FAFC" + on-primary-dark: "#B6ECF7" + secondary: "#F1F5F9" + secondary-dark: "#ECEEED" + on-secondary: "#0F172A" + on-secondary-dark: "#252927" + accent: "#EDF6FF" + accent-dark: "#1D5B6A" + on-accent: "#0B68CB" + on-accent-dark: "#B6ECF7" + ring: "#94A3B8" + destructive: "#FF6666" + on-destructive: "#F8FAFC" + error: "#EA5D5D" + on-error: "#F8FAFC" + success: "#66FF7F" + on-success: "#F8FAFC" + action: "#FEF2A5" + action-hover: "#FFF8BB" + on-action: "#946800" + link: "#0B68CB" + link-dark: "#479BF5" + link-visited: "#8E4EC6" + link-visited-dark: "#BF9BDF" + stale: "#EBE2CC" + stale-dark: "#3E3720" + code-background: "#FFFFFF" + code-background-dark: "#282C34" + surface: "#FFFFFF" + surface-dark: "#252927" + code-foreground: "#000000" + code-foreground-dark: "#ABB2BF" + data-grid-accent: "#7C3AED" +typography: + body-md: + fontFamily: PT Sans + fontSize: 1rem + fontWeight: "400" + lineHeight: 1.75rem + letterSpacing: 0em + body-sm: + fontFamily: PT Sans + fontSize: 0.875rem + fontWeight: "400" + lineHeight: 1.25rem + letterSpacing: 0em + label-md: + fontFamily: PT Sans + fontSize: 0.875rem + fontWeight: "500" + lineHeight: 1.25rem + letterSpacing: 0em + label-xs: + fontFamily: PT Sans + fontSize: 0.75rem + fontWeight: "600" + lineHeight: 1rem + letterSpacing: 0em + markdown-heading: + fontFamily: Lora + fontSize: 1.875rem + fontWeight: "600" + lineHeight: 2.25rem + letterSpacing: -0.025em + code-editor: + fontFamily: Fira Mono + fontSize: 14px + fontWeight: "400" + lineHeight: 1.25rem + letterSpacing: 0em + slide-h1: + fontFamily: PT Sans + fontSize: 4.375rem + fontWeight: "600" + lineHeight: "1.2" + letterSpacing: 0em +rounded: + sm: 4px + DEFAULT: 8px + md: 6px + lg: 8px + cell: 10px + xl: 0.75rem + full: 9999px +spacing: + unit: 0.5rem + xs: 0.25rem + sm: 0.5rem + md: 1rem + lg: 1.5rem + xl: 3rem + content-compact: 740px + content-medium: 1110px + content-wide: 1400px + grid-row-height: 20px + grid-columns: "24" +components: + app-shell: + backgroundColor: "{colors.background}" + textColor: "{colors.foreground}" + typography: "{typography.body-md}" + cell: + backgroundColor: "{colors.surface}" + textColor: "{colors.foreground}" + typography: "{typography.body-md}" + rounded: "{rounded.cell}" + width: 100% + output-area: + backgroundColor: "{colors.surface}" + textColor: "{colors.foreground}" + typography: "{typography.body-md}" + padding: 1rem + width: 100% + code-editor: + backgroundColor: "{colors.code-background}" + textColor: "{colors.code-foreground}" + typography: "{typography.code-editor}" + width: 100% + button-primary: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + typography: "{typography.label-md}" + rounded: "{rounded.md}" + height: 2.25rem + button-action: + backgroundColor: "{colors.action}" + textColor: "{colors.on-action}" + typography: "{typography.label-md}" + rounded: "{rounded.md}" + height: 2.25rem + input: + backgroundColor: "{colors.background}" + textColor: "{colors.foreground}" + typography: "{typography.body-sm}" + rounded: "{rounded.sm}" + height: 1.5rem + data-table: + backgroundColor: "{colors.surface}" + textColor: "{colors.foreground}" + typography: "{typography.body-sm}" + width: 100% +--- + +## Brand Assets + +- Logo SVG: https://raw.githubusercontent.com/marimo-team/marimo/main/docs/_static/marimo-logotype-thick.svg +- Preserve the original aspect ratio. +- Do not recolor unless explicitly requested. + +## Visual Character + +- Compact, software-native, and utilitarian. +- White or near-black work surfaces with slate borders and muted secondary text. +- Restrained blue for primary interaction; yellow for action, stale, or needs-run states. +- Avoid decorative gradients, marketing-style heroes, nested cards, and one-off palettes. + +## Color + +- Use background, surface, foreground, border, and muted tokens for structure. +- Use primary for primary actions, selection, progress, and clear focus only. +- Use action and stale colors for manual action or freshness state, not generic warning. +- Use destructive, error, and success colors only for their semantic states. +- Preserve light and dark token pairs whenever a color appears in both modes. + +## Typography + +- Use PT Sans for UI and prose, Lora for authored markdown headings, and Fira Mono for code-like values. +- Keep control text compact and legible. +- Do not add viewport-based type scaling beyond marimo's app defaults. + +## Surfaces + +- Use borders first and subtle shadows second. +- Cells, outputs, editors, markdown, tables, and data grids should be full-width and overflow-safe. +- Keep data UI dense and inspectable: stable columns, predictable overflow, readable headers, and no decorative framing around tables or charts. +- Cards are for repeated items, dialogs, or genuinely framed tools; do not style page sections as cards. + +## Components + +- Buttons should be compact, label-like, focusable, and drawn from primary, secondary, or action semantics. +- Icon buttons should use familiar existing icons and tooltips for unclear actions. +- Inputs, selects, and textareas should be compact, bordered, readable, and use code typography only for code-like values. +- Tabs, menus, popovers, dialogs, and tooltips should use semantic surfaces, borders, focus states, and restrained shadow. +- Runtime states should pair color with labels, icons, borders, position, or shape. + +## Motion + +Use short transitions for hover, focus, loading, resize, drag, and stale-output changes. Avoid decorative animation. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 95c497d3bd6..2093851cb72 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -41,6 +41,7 @@ current Project Maintainers are: - Akshay Agrawal ([@akshaya](https://github.com/akshayka)) - Myles Scolnick ([@mscolnick](https://github.com/mscolnick)) +- Bennet Meyers ([@bmeyers](https://github.com/bmeyers)) A new maintainer may be added by consensus of the current Project Maintainers and notification to the Steering Committee. @@ -71,6 +72,7 @@ The current members of the Steering Committee are: - Akshay Agrawal ([@akshaya](https://github.com/akshayka)) - Myles Scolnick ([@mscolnick](https://github.com/mscolnick)) +- Bennet Meyers ([@bmeyers](https://github.com/bmeyers)) A member of the Steering Committee may leave the committee by notifying the Steering Committee and Project Maintainers. The remaining Steering Committee @@ -185,4 +187,10 @@ additional channels for specific long-term projects in marimo’s development. The Steering Committee hosts semiannual developer calls to discuss marimo-related business. -The precise time of a given call is determined one month ahead of time by discussions on the “developer-calls” channel of the marimo Discord Server. +The precise time of a given call is determined one month ahead of time by discussions on the "developer-calls" channel of the marimo Discord Server. + +### **Contributor License Agreement** + +All contributors to marimo must sign a Contributor License Agreement (CLA) before their contributions can be accepted. The CLA is a legal agreement that assigns copyright of contributions to Marimo Inc. This helps protect the project and its users by ensuring clear ownership and licensing of all contributions. + +The CLA can be found at and must be signed before any pull requests can be merged. diff --git a/Makefile b/Makefile index 3898e94b709..e68111f040d 100644 --- a/Makefile +++ b/Makefile @@ -1,108 +1,205 @@ +# Makefile for marimo - Development and build tasks +# Prerequisites: +# - uv: for Python dependency management, testing, and building +# - pnpm: for frontend development +# - Node.js: for frontend development + +DENO_VERSION := 2.7.14 +DENO ?= uv tool run --from deno==$(DENO_VERSION) deno +DESIGN_MD_DENO_FLAGS := --no-config --no-lock --node-modules-dir=none +DESIGN_MD_READ_PATHS := frontend/src/css/app/App.css,frontend/src/css/app/Cell.css,frontend/src/core/codemirror/theme/dark.ts,frontend/src/core/codemirror/theme/light.ts,frontend/src/core/config/config-schema.ts,frontend/src/plugins/impl/data-editor/themes.ts,frontend/src/css/globals.css,frontend/src/components/editor/renderers/grid-layout/plugin.tsx,frontend/tailwind.config.cjs +DESIGN_MD_DENO_RUN_FLAGS := $(DESIGN_MD_DENO_FLAGS) --allow-read=$(DESIGN_MD_READ_PATHS) --allow-env=CI +DESIGN_MD_LINTER ?= pnpm --silent dlx @google/design.md@0.1.1 + .PHONY: help -# show this help +# 📖 Show available commands help: - @# prints help for rules preceded with comment - @# https://stackoverflow.com/a/35730928 - @awk '/^#/{c=substr($$0,3);next}c&&/^[[:alpha:]][[:alnum:]_-]+:/{print substr($$1,1,index($$1,":")),c}1{c=0}' Makefile | column -s: -t + @printf "\nMarimo Development Commands:\n\n" + @awk '/^#/{c=substr($$0,3);next}c&&/^[[:alpha:]][[:alnum:]_-]+:/{printf " \033[36m%-20s\033[0m %s\n", substr($$1,1,index($$1,":")-1),c}1{c=0}' $(MAKEFILE_LIST) + @printf "\nRun 'make check-prereqs' and 'make install-all' to get started!\n\n" -# package frontend into marimo/ -.PHONY: fe -fe: marimo/_static marimo/_lsp +############### +# Setup Tasks # +############### -# install/build frontend if anything under frontend/src or (top-level) -# frontend/ has changed -marimo/_static: $(shell find frontend/src) $(wildcard frontend/*) - cd frontend; pnpm install; cd ..; ./scripts/buildfrontend.sh +.PHONY: install-all +# 🚀 First-time setup: Install all dependencies (frontend & Python) +install-all: fe py -# install/build lsp if anything in lsp/ has changed -marimo/_lsp: $(shell find lsp) - cd lsp; pnpm install; cd ..; ./scripts/buildlsp.sh +.PHONY: check-prereqs +# ✓ Check if all required tools are installed +check-prereqs: + @command -v pnpm >/dev/null 2>&1 || { echo "pnpm is required. See https://pnpm.io/installation"; exit 1; } + @pnpm -v | grep -vq "^[0-9]\." || { echo "pnpm v10+ is required. Current version: $(shell pnpm -v)"; exit 1; } + @command -v uv >/dev/null 2>&1 || { echo "uv is required. See https://docs.astral.sh/uv/getting-started/installation/"; exit 1; } + @command -v node >/dev/null 2>&1 || { echo "Node.js is required. See https://nodejs.org/en/download/"; exit 1; } + @node -v | grep -q "v2[0-9]" || { echo "Node.js v20+ is required. Current version: $(shell node -v)"; exit 1; } + @echo "✅ All prerequisites are installed!" .PHONY: py -# editable python install; only need to run once +# 🐍 Install Python dependencies in editable mode py: - pip install -e ".[dev]" + @command -v uv >/dev/null 2>&1 || { echo "uv is required. See https://docs.astral.sh/uv/getting-started/installation/"; exit 1; } + uv pip install --group=dev -e . -.PHONY: check -# run all checks -check: fe-check py-check +###################### +# Development Tasks # +###################### + +.PHONY: fe +# 🔧 Build frontend assets +fe: marimo/_static marimo/_lsp -.PHONY: check-test -# run all checks and tests -check-test: check fe-test py-test e2e +# 🔧 Install/build frontend if anything under frontend/ +marimo/_static: $(shell find frontend/src) $(wildcard frontend/*) + ./scripts/buildfrontend.sh + +# 🔧 Install/build lsp if anything in lsp/ has changed +marimo/_lsp: $(shell find packages/lsp) + ./scripts/buildlsp.sh + +.PHONY: dev +dev: fe-codegen + @echo "Starting development servers..." + @# Start both processes, with marimo in background + @(trap 'kill %1; exit' INT; \ + uv run marimo edit --no-token --headless /tmp --port 2718 & \ + pnpm dev) +dev-sandbox: + @echo "Starting development servers..." + @# Start both processes, with marimo in background + @(trap 'kill %1; exit' INT; \ + uv run marimo edit /tmp/notebook.py --no-token --headless --sandbox & \ + pnpm dev) + +############# +# Testing # +############# .PHONY: test -# run all checks and tests +# 🧪 Run all tests (frontend, Python, end-to-end) test: fe-test py-test e2e +.PHONY: check +# 🧹 Run all checks +check: fe-check py-check + .PHONY: fe-check -# check frontend +# 🧹 Check frontend (lint, typecheck) fe-check: fe-lint fe-typecheck .PHONY: fe-test -# test frontend +# 🧪 Test frontend fe-test: - cd frontend; CI=true pnpm turbo test + CI=true pnpm turbo --filter @marimo-team/frontend test -- --run .PHONY: e2e -# test end-to-end +# 🧪 Test end-to-end e2e: cd frontend; pnpm playwright install; pnpm playwright test .PHONY: fe-lint +# 🧹 Lint frontend fe-lint: - cd frontend/src && typos && cd - && cd frontend && pnpm lint + cd frontend/src && uv run typos && cd - && pnpm --filter @marimo-team/frontend lint .PHONY: fe-typecheck +# 🔍 Typecheck frontend fe-typecheck: - cd frontend; pnpm turbo typecheck + pnpm turbo --filter @marimo-team/frontend typecheck .PHONY: fe-codegen +# 🔄 Generate frontend API fe-codegen: - cd openapi; pnpm install; pnpm codegen + uv run --isolated --python=3.12 --with-editable . marimo development openapi > packages/openapi/api.yaml + pnpm run codegen + pnpm format packages/openapi/ + +.PHONY: design-md +# 🔄 Generate DESIGN.md +design-md: + $(DENO) run $(DESIGN_MD_DENO_RUN_FLAGS) scripts/generate-design-md.ts > DESIGN.md + +.PHONY: design-md-check +# 🔍 Check DESIGN.md generation +design-md-check: + $(DENO) check $(DESIGN_MD_DENO_FLAGS) scripts/generate-design-md.ts + $(DENO) lint --no-config scripts/generate-design-md.ts + @tmp=$$(mktemp); \ + trap 'rm -f "$$tmp"' EXIT; \ + $(DENO) run $(DESIGN_MD_DENO_RUN_FLAGS) scripts/generate-design-md.ts > "$$tmp"; \ + diff -u DESIGN.md "$$tmp" || { \ + echo "DESIGN.md is not up to date. Run 'make design-md' to update it."; \ + exit 1; \ + } + $(DESIGN_MD_LINTER) lint DESIGN.md .PHONY: py-check -# typecheck, lint, format python +# 🔍 Typecheck, lint, format python py-check: ./scripts/pycheck.sh +.PHONY: typos +# 🔍 Check for typos +typos: + uv run typos + .PHONY: py-test -# test python +# 🧪 Test python py-test: - cd marimo && typos && cd - && hatch run +py=3.12 test-optional:test + uv run typos + ./scripts/pytest.sh --optional $(ARGS) + +.PHONY: py-test-narwhals +# 🧪 Test narwhals-related tests (used externally by narwhals repo) +py-test-narwhals: + uv run --group test pytest \ + tests/_data/ \ + tests/_plugins/ui/_impl/ \ + tests/_utils/test_narwhals_utils.py .PHONY: py-snapshots -# update html snapshots +# 📸 Update snapshots py-snapshots: - hatch run +py=3.12 test:test \ + uv run --group test pytest \ tests/_server/templates/test_templates.py \ - tests/_server/api/endpoints/test_export.py + tests/_server/api/endpoints/test_export.py \ + tests/test_api.py -.PHONY: install-all -# install everything; takes a long time due to editable install -install-all: fe py +############## +# Packaging # +############## .PHONY: wheel -# build wheel +# 📦 Build wheel wheel: - hatch build + uv build -.PHONY: storybook -storybook: - cd frontend; pnpm storybook + +################# +# Documentation # +################# .PHONY: docs -# build docs -# use make ARGS="-a" docs to force docs to rebuild, useful when -# modifying static files / assets +# 📚 Build docs docs: - hatch run docs:build $(ARGS) + uv run --group docs mkdocs build .PHONY: docs-serve +# 📚 Serve docs docs-serve: - hatch run docs:serve $(ARGS) + uv run --group docs mkdocs serve --clean -.PHONY: docs-clean -# remove built docs -docs-clean: - hatch run docs:clean +.PHONY: docs-llms +# 📚 Build docs + generate per-page .md files and the llms.txt index into site/ +docs-llms: docs + uv run --group docs python docs/scripts/html_to_markdown.py \ + --input-dir site --base-url https://docs.marimo.io + uv run --group docs python docs/scripts/generate_llms_txt.py \ + --input-dir site --base-url https://docs.marimo.io \ + --output-index site/llms.txt + +.PHONY: storybook +# 🧩 Start Storybook for UI development +storybook: + pnpm --filter @marimo-team/frontend storybook diff --git a/README.md b/README.md index cbb555b6672..a286837036d 100644 --- a/README.md +++ b/README.md @@ -4,30 +4,40 @@

A reactive Python notebook that's reproducible, git-friendly, and deployable as scripts or apps. +

Docs · Discord · - Examples + Examples · + Gallery · + YouTube

- English | + English + | + 繁體中文 + | 简体中文 + | + 日本語 + | + Español

- - -discord -Pepy Total Downloads -Conda Downloads - + + + discord + Pepy Total Downloads + Conda Downloads +

**marimo** is a reactive Python notebook: run a cell or interact with a UI element, and marimo automatically runs dependent cells (or marks them as stale), keeping code and outputs -consistent. marimo notebooks are stored as pure Python, executable as scripts, +consistent. marimo notebooks are stored as pure Python (with first-class SQL support), executable as scripts, and deployable as apps. **Highlights**. @@ -35,20 +45,24 @@ and deployable as apps. - 🚀 **batteries-included:** replaces `jupyter`, `streamlit`, `jupytext`, `ipywidgets`, `papermill`, and more - ⚡️ **reactive**: run a cell, and marimo reactively [runs all dependent cells](https://docs.marimo.io/guides/reactivity.html) or marks them as stale - 🖐️ **interactive:** [bind sliders, tables, plots, and more](https://docs.marimo.io/guides/interactivity.html) to Python — no callbacks required -- 🔬 **reproducible:** [no hidden state](https://docs.marimo.io/guides/reactivity.html#no-hidden-state), deterministic execution, [built-in package management](https://docs.marimo.io/guides/editor_features/package_management.html) +- 🐍 **git-friendly:** stored as `.py` files +- 🛢️ **designed for data**: query dataframes, databases, warehouses, or lakehouses [with SQL](https://docs.marimo.io/guides/working_with_data/sql.html), filter and search [dataframes](https://docs.marimo.io/guides/working_with_data/dataframes.html) +- 🤖 **AI-native**: [pair with AI agents](https://marimo.io/pair) like Claude Code, or use our editor's [built-in AI features](https://docs.marimo.io/guides/editor_features/ai_completion/) +- 🔬 **reproducible:** [no hidden state](https://docs.marimo.io/guides/reactivity.html#no-hidden-state), deterministic execution, [built-in package management](https://docs.marimo.io/guides/package_management/) - 🏃 **executable:** [execute as a Python script](https://docs.marimo.io/guides/scripts.html), parameterized by CLI args - 🛜 **shareable**: [deploy as an interactive web app](https://docs.marimo.io/guides/apps.html) or [slides](https://docs.marimo.io/guides/apps.html#slides-layout), [run in the browser via WASM](https://docs.marimo.io/guides/wasm.html) -- 🛢️ **designed for data**: query dataframes and databases [with SQL](https://docs.marimo.io/guides/working_with_data/sql.html), filter and search [dataframes](https://docs.marimo.io/guides/working_with_data/dataframes.html) -- 🐍 **git-friendly:** notebooks are stored as `.py` files -- ⌨️ **a modern editor**: [GitHub Copilot](https://docs.marimo.io/guides/editor_features/ai_completion.html#github-copilot), [AI assistants](https://docs.marimo.io/guides/editor_features/ai_completion.html#using-ollama), vim keybindings, variable explorer, and [more](https://docs.marimo.io/guides/editor_features/index.html) +- 🧩 **reusable:** [import functions and classes](https://docs.marimo.io/guides/reusing_functions/) from one notebook to another +- 🧪 **testable:** [run pytest](https://docs.marimo.io/guides/testing/) on notebooks +- ⌨️ **a modern editor**: [GitHub Copilot](https://docs.marimo.io/guides/editor_features/ai_completion.html#github-copilot), [AI assistants](https://docs.marimo.io/guides/editor_features/ai_completion.html), vim keybindings, variable explorer, and [more](https://docs.marimo.io/guides/editor_features/index.html) +- 🧑‍💻 **use your favorite editor**: run in [VS Code/Cursor](https://marketplace.visualstudio.com/items?itemName=marimo-team.vscode-marimo) or [PyCharm](https://plugins.jetbrains.com/plugin/32416-marimo), or edit in neovim, Zed, [or any other text editor](https://docs.marimo.io/guides/editor_features/watching/) ```python pip install marimo && marimo tutorial intro ``` -_Try marimo at [our online playground](https://marimo.app/l/c7h6pz), which runs entirely in the browser!_ - -_Jump to the [quickstart](#quickstart) for a primer on our CLI._ +_Get started instantly with [**mo**lab, our free online +notebook](https://molab.marimo.io/notebooks). Or jump to the +[quickstart](#quickstart) for a primer on our CLI._ ## A reactive programming environment @@ -86,38 +100,57 @@ millions of rows blazingly fast, no code required. -**Performant runtime.** marimo runs only those cells that need to be run by -statically analyzing your code. +**Generate cells with data-aware AI.** Collaborate on marimo notebooks with +your favorite agent, such as Claude Code, Codex, or OpenCode, using [marimo +pair](https://docs.marimo.io/guides/generate_with_ai/marimo_pair/). Or, +generate code [in the marimo editor with an AI +assistant](https://docs.marimo.io/guides/editor_features/ai_completion/) that +is highly specialized for working with data, with context about your variables +in memory. Customize the system prompt, bring your own API keys, or use local +models. -**Dynamic markdown and SQL.** Use markdown to tell dynamic stories that depend on -Python data. Or build [SQL](https://docs.marimo.io/guides/working_with_data/sql.html) queries -that depend on Python values and execute them against dataframes, databases, + + +**Query data with SQL.** Build [SQL](https://docs.marimo.io/guides/working_with_data/sql.html) queries +that depend on Python values and execute them against dataframes, databases, lakehouses, CSVs, Google Sheets, or anything else using our built-in SQL engine, which returns the result as a Python dataframe. -Your notebooks are still pure Python, even if they use markdown or SQL. +Your notebooks are still pure Python, even if they use SQL. -**Deterministic execution order.** Notebooks are executed in a deterministic -order, based on variable references instead of cells' positions on the page. -Organize your notebooks to best fit the stories you'd like to tell. +**Dynamic markdown.** Use markdown parametrized by Python variables to tell +dynamic stories that depend on Python data. **Built-in package management.** marimo has built-in support for all major -package managers, letting you install packages on import. marimo can even +package managers, letting you [install packages on import](https://docs.marimo.io/guides/editor_features/package_management.html). marimo can even [serialize package -requirements](https://docs.marimo.io/guides/editor_features/package_management.html#auto-tracking-inline-script-metadata) +requirements](https://docs.marimo.io/guides/package_management/inlining_dependencies/) in notebook files, and auto install them in -[isolated venv sandboxes](https://docs.marimo.io/guides/editor_features/package_management.html#running-marimo-in-a-sandbox-environment-uv-only). +isolated venv sandboxes. + +**Deterministic execution order.** Notebooks are executed in a deterministic +order, based on variable references instead of cells' positions on the page. +Organize your notebooks to best fit the stories you'd like to tell. + +**Performant runtime.** marimo runs only those cells that need to be run by +statically analyzing your code. **Batteries-included.** marimo comes with GitHub Copilot, AI assistants, Ruff code formatting, HTML export, fast code completion, a [VS Code extension](https://marketplace.visualstudio.com/items?itemName=marimo-team.vscode-marimo), +a [PyCharm plugin](https://plugins.jetbrains.com/plugin/32416-marimo), an interactive dataframe viewer, and [many more](https://docs.marimo.io/guides/editor_features/index.html) quality-of-life features. ## Quickstart +_The [marimo concepts +playlist](https://www.youtube.com/watch?v=3N6lInzq5MI&list=PLNJXGo8e1XT9jP7gPbRdm1XwloZVFvLEq) +on our [YouTube channel](https://www.youtube.com/@marimo-team) gives an +overview of many features._ + **Installation.** In a terminal, run ```bash @@ -125,6 +158,13 @@ pip install marimo # or conda install -c conda-forge marimo marimo tutorial intro ``` +To install with additional dependencies that unlock SQL cells, AI completion, and more, +run + +```bash +pip install "marimo[recommended]" +``` + **Create notebooks.** Create or edit notebooks with @@ -165,6 +205,10 @@ List all tutorials: marimo tutorial --help ``` +**Share cloud-based notebooks.** Use +[molab](https://molab.marimo.io/notebooks), a cloud-based marimo notebook +service similar to Google Colab, to create and share notebook links. + ## Questions? See the [FAQ](https://docs.marimo.io/faq.html) at our docs. @@ -173,12 +217,12 @@ See the [FAQ](https://docs.marimo.io/faq.html) at our docs. marimo is easy to get started with, with lots of room for power users. For example, here's an embedding visualizer made in marimo -([video](https://marimo.io/videos/landing/full.mp4)): +([try the notebook live on molab!](https://molab.marimo.io/notebooks/nb_jJiFFtznAy4BxkrrZA1o9b/app?show-code=true)): Check out our [docs](https://docs.marimo.io), -the [`examples/`](examples/) folder, and our [gallery](https://marimo.io/gallery) to learn more. +[usage examples](https://docs.marimo.io/examples/), and our [gallery](https://marimo.io/gallery) to learn more. @@ -219,23 +263,23 @@ the [`examples/`](examples/) folder, and our [gallery](https://marimo.io/gallery @@ -260,8 +304,17 @@ We're building a community. Come hang out with us! - ✏️ [Start a GitHub Discussion](https://github.com/marimo-team/marimo/discussions) - 🦋 [Follow us on Bluesky](https://bsky.app/profile/marimo.io) - 🐦 [Follow us on Twitter](https://twitter.com/marimo_io) +- 🎥 [Subscribe on YouTube](https://www.youtube.com/@marimo-team) +- 🤖 [Follow us on Reddit](https://www.reddit.com/r/marimo_notebook) - 🕴️ [Follow us on LinkedIn](https://www.linkedin.com/company/marimo-io) +**A NumFOCUS affiliated project.** marimo is a core part of the broader Python +ecosystem and is a member of the NumFOCUS community, which includes projects +such as NumPy, SciPy, and Matplotlib. + + + + ## Inspiration ✨ marimo is a **reinvention** of the Python notebook as a reproducible, interactive, @@ -285,6 +338,13 @@ a greater movement toward reactive dataflow programming. From declarative, and reactive programming are transforming a broad range of tools for the better. +Finally, we would like to acknowledge [Bennet +Meyers](https://bmeyers.github.io/about/) and [David +Chassin](https://www.chassin.org/about/) for believing in marimo from the very +beginning: this work was supported in part by U.S. DOE Office of Critical +Minerals and Energy Innovation (CMEI) Integrated Energy Systems Office (IESO), +Agreement 34368. +

diff --git a/README_Chinese.md b/README_Chinese.md index 3f2dd597164..0d610ca485b 100644 --- a/README_Chinese.md +++ b/README_Chinese.md @@ -4,45 +4,60 @@

这是一款响应式的Python笔记本,具有优秀的可复现性,原生支持Git,并可作为脚本或应用程序部署。 +

用户手册 · Discord 社区 · - 示例 + 示例 · + 展示廊 · + YouTube

- English - | 简体中文 + English + | + 繁體中文 + | + 简体中文 + | + 日本語 + | + Español

- - - + + + discord + Pepy Total Downloads + Conda Downloads +

**Marimo** 是一款响应式 Python 笔记本:运行单元格可与 UI 元素交互,marimo 会自动更新依赖于它的单元格(或将其标记为过时单元格),从而保持代码和输出的一致性。**marimo** 笔记本以纯 Python 格式存储,可作为脚本执行,也可作为应用程序部署。 **为什么选择 marimo** -- 🚀 **功能齐全:** 替代 `jupyter`、`streamlit`、`jupytext`、`ipywidgets`、`papermill` 等更多工具 -- ⚡️ **响应式**: 运行一个单元格,marimo 会响应式地[运行所有依赖单元格](https://docs.marimo.io/guides/reactivity.html) 或 将它们标记为陈旧 -- 🖐️ **交互性:** [绑定滑块、表格、图表等 UI 元素](https://docs.marimo.io/guides/interactivity.html) 到 Python——无需回调 -- 🔬 **可复现:** [无隐藏状态](https://docs.marimo.io/guides/reactivity.html#no-hidden-state),确定性执行 -- 🏃‍♂️ **可执行:** [作为 Python 脚本执行](https://docs.marimo.io/guides/scripts.html),通过命令行调整参数 -- 🛜 **可分享**: [部署为交互式 Web 应用](https://docs.marimo.io/guides/apps.html) 或 [幻灯片](https://docs.marimo.io/guides/apps.html#slides-layout),[通过 WASM 在浏览器中运行](https://docs.marimo.io/guides/wasm.html) -- 🛢️ **为数据设计**: 使用 [SQL](https://docs.marimo.io/guides/working_with_data/sql.html) 查询数据框和数据库,过滤和搜索 [数据框](https://docs.marimo.io/guides/working_with_data/dataframes.html) -- 🐍 **支持 Git:** 笔记本以 `.py` 文件格式存储 -- ⌨️ **现代编辑器**: GitHub Copilot、AI 助手、vim 快捷键、变量浏览器,和 [更多功能](https://docs.marimo.io/guides/editor_features/index.html) +- 🚀 **功能齐全**:替代 `jupyter`、`streamlit`、`jupytext`、`ipywidgets`、`papermill` 等更多工具 +- ⚡️ **响应式**:运行一个单元格,marimo会响应式地[运行所有依赖单元格](https://docs.marimo.io/guides/reactivity.html)或将它们标记为过时 +- 🖐️ **交互性**:[绑定滑块、表格、图表等UI元素](https://docs.marimo.io/guides/interactivity.html)到Python代码——无需回调函数 +- 🐍 **支持Git版本控制**:笔记本以`.py`文件格式存储 +- 🛢️ **为数据设计**:使用[SQL](https://docs.marimo.io/guides/working_with_data/sql.html)查询数据框和数据库,过滤和搜索[数据框](https://docs.marimo.io/guides/working_with_data/dataframes.html) +- 🔬 **可复现**:[无隐藏状态](https://docs.marimo.io/guides/reactivity.html#no-hidden-state),确定性执行,[内置包管理](https://docs.marimo.io/guides/editor_features/package_management.html) +- 🏃 **可执行**:[作为Python脚本执行](https://docs.marimo.io/guides/scripts.html),通过命令行参数进行配置 +- 🛜 **可分享**:[部署为交互式Web应用](https://docs.marimo.io/guides/apps.html)或[幻灯片](https://docs.marimo.io/guides/apps.html#slides-layout),[通过WASM在浏览器中运行](https://docs.marimo.io/guides/wasm.html) +- 🧩 **可复用:** 可从一个笔记本[导入函数和类](https://docs.marimo.io/guides/reusing_functions/)到另一个笔记本 +- 🧪 **便于测试:** 可在笔记本上运行 [pytest](https://docs.marimo.io/guides/testing/) +- ⌨️ **现代编辑器**:[GitHub Copilot](https://docs.marimo.io/guides/editor_features/ai_completion.html#github-copilot)、[AI助手](https://docs.marimo.io/guides/editor_features/ai_completion.html#using-ollama)、vim快捷键、变量浏览器和[更多功能](https://docs.marimo.io/guides/editor_features/index.html) ```python pip install marimo && marimo tutorial intro ``` -_在浏览器中运行[在线体验平台](https://marimo.app/l/c7h6pz)!_ +_在我们的[在线体验平台](https://marimo.app/l/c7h6pz)试用marimo,完全在浏览器中运行!_ -_跳转到[快速起步](#快速起步),了解命令行工具。_ +_跳转到[快速入门](#快速入门)了解我们的命令行工具。_ ## 响应式编程环境 @@ -55,23 +70,29 @@ Marimo 确保了您的代码、输出和程序的状态始的一致性,解决 -**与计算成本高昂的笔记兼容** marimo 允许你将运行时配置为 “懒惰”模式,将受影响的单元标记为过时单元,而不是自动运行它们。这样既能保证程序状态,又能防止意外执行昂贵的单元。 +**兼容计算密集型笔记本**。marimo允许您[将运行时配置为延迟模式](https://docs.marimo.io/guides/configuration/runtime_configuration.html),将受影响的单元格标记为过时而不是自动运行它们。这既能保证程序状态的完整性,又能防止意外执行计算密集型单元格。 -**同步的 UI 元素** 与滑块、下拉菜单和数据框转换器等 UI 元素交互,使用这些元素的单元格会自动以最新值重新运行。 +**同步的UI元素**。与[UI元素](https://docs.marimo.io/guides/interactivity.html)如[滑块](https://docs.marimo.io/api/inputs/slider.html#slider)、[下拉菜单](https://docs.marimo.io/api/inputs/dropdown.html)、[数据框转换器](https://docs.marimo.io/api/inputs/dataframe.html)和[聊天界面](https://docs.marimo.io/api/inputs/chat.html)交互时,使用它们的单元格会自动以最新值重新运行。 -**高效运行** 通过静态分析代码,marimo 只运行需要运行的单元。 +**交互式数据框**。[分页浏览、搜索、过滤和排序](https://docs.marimo.io/guides/working_with_data/dataframes.html)数百万行数据,极速运行,无需编写代码。 + + -**动态的 Markdown 与 SQL** 使用 Markdown 编写 Python 代码的输出动态进行更新的文档。同时,使用内置 [SQL](https://docs.marimo.io/guides/working_with_data/sql.html) 引擎,可创建依赖于 Python 值的 SQL 查询,并针对数据框、数据库、CSV、Google Sheets 或其他任何内容执行查询,SQL 引擎会将结果返回为 Python 数据框。 +**高效运行时**。marimo通过静态分析代码,只运行需要运行的单元格。 + +**动态Markdown和SQL**。使用Markdown创建依赖Python数据的动态文档。或者构建依赖Python值的[SQL](https://docs.marimo.io/guides/working_with_data/sql.html)查询,并针对数据框、数据库、CSV、Google Sheets或其他数据源执行,使用我们内置的SQL引擎将结果作为Python数据框返回。 -即使笔记本(notebook)使用了 markdown 或 SQL,它仍然是纯 Python 程序。 +即使使用了Markdown或SQL,您的笔记本仍然是纯Python代码。 + +**确定性执行顺序**。笔记本按照基于变量引用而非单元格页面位置的确定性顺序执行。您可以根据想要讲述的故事组织笔记本。 -**确定性的执行顺序** 笔记本的执行顺序是确定的,基于变量引用,而不是单元格在页面上的位置。根据你顺序逻辑来组织笔记本。 +**内置包管理**。marimo内置支持所有主要的包管理器,允许您[在导入时安装包](https://docs.marimo.io/guides/editor_features/package_management.html)。marimo甚至可以[序列化包依赖](https://docs.marimo.io/guides/package_management/inlining_dependencies/)到笔记本文件中,并在隔离的venv沙箱中自动安装它们。 -**易用且强大** Marimo 集成了包括 GitHub Copilot、Ruff 代码格式化、HTML 导出、快速代码补全、[VSCode 扩展](https://marketplace.visualstudio.com/items?itemName=marimo-team.vscode-marimo)、交互式数据框查看器等非常有用的功能。 +**功能齐全**。marimo集成了GitHub Copilot、AI助手、Ruff代码格式化、HTML导出、快速代码补全、[VS Code扩展](https://marketplace.visualstudio.com/items?itemName=marimo-team.vscode-marimo)、交互式数据框查看器和[更多](https://docs.marimo.io/guides/editor_features/index.html)便捷功能。 ## 快速起步 @@ -82,11 +103,11 @@ pip install marimo # or conda install -c conda-forge marimo marimo tutorial intro ``` -**或者在 Gitpod 运行** +要安装包含额外依赖项的版本(启用SQL单元格、AI补全等功能),运行: -单击此链接以在 Gitpod 工作区中打开存储库: - -[https://gitpod.io/#https://github.com/marimo-team/marimo](https://gitpod.io/#https://github.com/marimo-team/marimo) +```bash +pip install marimo[recommended] +``` **创建新的笔记本** @@ -96,7 +117,7 @@ marimo tutorial intro marimo edit ``` -**运行应用** 将笔记本作为网络应用程序运行,隐藏 Python 代码,且不可编辑: +**运行应用** 将笔记本作为Web应用运行,隐藏并锁定Python代码: ```bash marimo run your_notebook.py @@ -136,8 +157,8 @@ Marimo 很容易上手,为高级用户提供了很大的空间。 例如,这 -参阅我们的 [用户手册](https://docs.marimo.io), -在 `examples/` 文件夹下, 以及我们的[精选示例](https://marimo.io/@public)。 +查看我们的[文档](https://docs.marimo.io)、 +[使用示例](https://docs.marimo.io/examples/)和[展示廊](https://marimo.io/gallery)了解更多。
- - + + - - + + - - + + - - + +
@@ -167,13 +188,13 @@ Marimo 很容易上手,为高级用户提供了很大的空间。 例如,这 教程 @@ -205,19 +226,21 @@ Marimo 很容易上手,为高级用户提供了很大的空间。 例如,这 我们感谢所有人的贡献! 这是为所有人设计的工具,我们真挚的欢迎任何人的任何意见! 请参阅[CONTRIBUTING.md](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md) 获取更多信息,了解如何参与到这个项目中来。 -> 看到这里,如果你有任何的想法或者问题,欢迎加入我们的 [Discord](https://marimo.io/discord?ref=readme)! +> 有问题?请[在Discord上联系我们](https://marimo.io/discord?ref=readme)。 ## 社区 我们也正在建设 marimo 社区,来和我们一起玩吧! -- 🌟 [给我们的项目点一颗星星](https://github.com/marimo-team/marimo) -- 💬 [在 Discord 上与我们交流](https://marimo.io/discord?ref=readme) -- 📧 [订阅我们的最新动态](https://marimo.io/newsletter) -- ☁️ [加入我们的云服务器候补名单](https://marimo.io/cloud) -- ✏️ [在 github 上开始一个讨论话题](https://github.com/marimo-team/marimo/discussions) -- 🐦 [在推特上关注我们](https://twitter.com/marimo_io) -- 🕴️ [在领英上关注我们](https://www.linkedin.com/company/marimo-io) +- 🌟 [在GitHub上为我们点赞](https://github.com/marimo-team/marimo) +- 💬 [在Discord上与我们交流](https://marimo.io/discord?ref=readme) +- 📧 [订阅我们的通讯](https://marimo.io/newsletter) +- ☁️ [加入我们的云服务候补名单](https://marimo.io/cloud) +- ✏️ [在GitHub上开始讨论](https://github.com/marimo-team/marimo/discussions) +- 🦋 [在Bluesky上关注我们](https://bsky.app/profile/marimo.io) +- 🐦 [在Twitter上关注我们](https://twitter.com/marimo_io) +- 🎥 [在YouTube上订阅](https://www.youtube.com/@marimo-team) +- 🕴️ [在LinkedIn上关注我们](https://www.linkedin.com/company/marimo-io) ## 愿景 ✨ diff --git a/README_Japanese.md b/README_Japanese.md new file mode 100644 index 00000000000..ccaaa839e05 --- /dev/null +++ b/README_Japanese.md @@ -0,0 +1,267 @@ +

+ +

+ +

+ 再現性が高く、Git に優しく、スクリプトやアプリとして展開できるリアクティブな Python ノートブック。 +

+ +

+ ドキュメント · + Discord · + サンプル · + ギャラリー · + YouTube +

+ +

+ English + | + 繁體中文 + | + 简体中文 + | + 日本語 + | + Español +

+ +

+ + + discord + Pepy Total Downloads + Conda Downloads + +

+ +**marimo** はリアクティブな Python ノートブックです。セルを実行したり UI 要素を操作すると、marimo は依存するセルを自動的に実行する(または影響を受けるセルを古いものとしてマークする)ことで、コードと出力の一貫性を保ちます。marimo のノートブックは純粋な Python として保存され、スクリプトとして実行でき、アプリとしてデプロイできます。 + +**Highlights(主な特徴)** + +- 🚀 **batteries-included:** `jupyter`、`streamlit`、`jupytext`、`ipywidgets`、`papermill` などの代替を目指します。 +- ⚡️ **reactive:** セルを実行すると marimo はリアクティブに[すべての依存セルを実行](https://docs.marimo.io/guides/reactivity.html)するか、影響を受けるセルを古いものとしてマークします。 +- 🖐️ **interactive:** [スライダー、テーブル、プロットなど](https://docs.marimo.io/guides/interactivity.html)を Python にバインドできます(コールバック不要)。 +- 🐍 **git-friendly:** ノートブックは `.py` ファイルとして保存されます。 +- 🛢️ **designed for data:** データフレーム、データベース、ウェアハウス、レイクハウスを [SQL でクエリ](https://docs.marimo.io/guides/working_with_data/sql.html)したり、[データフレームをフィルタ・検索](https://docs.marimo.io/guides/working_with_data/dataframes.html)できます。 +- 🤖 **AI-native:** データ作業に特化した AI でセルを[生成](https://docs.marimo.io/guides/generate_with_ai/)できます。 +- 🔬 **reproducible:** [隠れた状態なし](https://docs.marimo.io/guides/reactivity.html#no-hidden-state)、決定論的な実行、[組み込みパッケージ管理](https://docs.marimo.io/guides/package_management/)を備えています。 +- 🏃 **executable:** ノートブックを [Python スクリプトとして実行](https://docs.marimo.io/guides/scripts.html)でき、CLI 引数でパラメータ化できます。 +- 🛜 **shareable:** インタラクティブな Web アプリとしてデプロイしたり、[スライド](https://docs.marimo.io/guides/apps.html#slides-layout)に変換したり、[WASM でブラウザ実行](https://docs.marimo.io/guides/wasm.html)できます。 +- 🧩 **reusable:** ノートブック間で関数やクラスを[インポートして再利用](https://docs.marimo.io/guides/reusing_functions/)できます。 +- 🧪 **testable:** ノートブックに対して [pytest を実行](https://docs.marimo.io/guides/testing/)できます。 +- ⌨️ **a modern editor:** GitHub Copilot、AI アシスタント、Ruff によるコード整形、高速補完、[VS Code 拡張](https://marketplace.visualstudio.com/items?itemName=marimo-team.vscode-marimo) などのエディタ機能を備えています。 +- 🧑‍💻 **use your favorite editor:** VS Code や Cursor、neovim、Zed、その他のテキストエディタで編集できます。 + +```python +pip install marimo && marimo tutorial intro +``` + +_ブラウザのみで動作するオンラインプレイグラウンドでも marimo を試すことができます: https://marimo.app/l/c7h6pz_ + +_クイックスタートは次のセクションを参照してください。_ + +## A reactive programming environment + +marimo はノートブックのコード、出力、プログラム状態の一貫性を保証します。これにより Jupyter のような従来のノートブックに関連する多くの問題が解決されます(詳細は [FAQ](https://docs.marimo.io/faq.html#faq-problems) を参照)。 + +**A reactive programming environment.** +セルを実行すると marimo は _反応_ し、その変数を参照するセルを自動的に再実行することで、手動でセルを再実行することに起因するミスを防ぎます。セルを削除すると、marimo はその変数をメモリから削除し、隠れた状態を排除します。 + + + +**Compatible with expensive notebooks.** marimo はランタイムを遅延評価に設定でき、影響を受けるセルを自動実行する代わりに古いものとしてマークできます。これにより、高コストなセルの誤実行を防ぎつつプログラム状態の保証を提供します。 + +**Synchronized UI elements.** [スライダーやドロップダウン、データフレーム変換、チャットインターフェースなどの UI 要素](https://docs.marimo.io/guides/interactivity.html)を操作すると、それらを使うセルが自動的に最新の値で再実行されます。 + + + +**Interactive dataframes.** 数百万行のデータをコード不要でページング、検索、フィルタ、ソートできます。 + + + +**Generate cells with data-aware AI.** データに文脈を持った AI アシスタントでコードを生成したり、ノートブック全体をゼロショットで生成できます。システムプロンプトのカスタマイズや独自 API キーの利用、ローカルモデルの使用にも対応します。 + + + +**Query data with SQL.** Python 値に依存する SQL クエリを組み立て、データフレーム、データベース、CSV、Google Sheets などに対して実行できます。組み込みの SQL エンジンは結果を Python のデータフレームとして返します。 + + + +ノートブックは SQL を使っていても純粋な Python のままです。 + +**Dynamic markdown.** Python 変数でパラメタライズされた Markdown を使って動的なストーリーを作成できます。 + +**Built-in package management.** marimo は主要なパッケージマネージャをサポートし、インポート時にパッケージをインストールしたり、ノートブック内に依存関係を埋め込んで再現可能な環境を構築できます。 + +**Deterministic execution order.** ノートブックの実行順序はセルのページ上の位置ではなく、変数参照に基づいて決定されます。 + +**Performant runtime.** 静的解析により、実行が必要なセルのみを効率的に実行します。 + +**Batteries-included.** GitHub Copilot、AI アシスタント、Ruff による整形、HTML 出力、インタラクティブなデータフレームビューア、そして [VS Code 拡張](https://marketplace.visualstudio.com/items?itemName=marimo-team.vscode-marimo) など、多数の便利機能が含まれています。 + +## Quickstart + +_[marimo の基本的な概念を説明するプレイリスト](https://www.youtube.com/watch?v=3N6lInzq5MI&list=PLNJXGo8e1XT9jP7gPbRdm1XwloZVFvLEq) が公式 YouTube にあります。_ + +**Installation.** ターミナルで次を実行します。 + +```bash +pip install marimo # または conda install -c conda-forge marimo +marimo tutorial intro +``` + +SQL セルや AI 補完などの追加機能を含めてインストールするには、次を実行します。 + +```bash +pip install "marimo[recommended]" +``` + +**Create notebooks.** + +ノートブックを作成または編集するには: + +```bash +marimo edit +``` + +**Run apps.** ノートブックを Web アプリとして実行し、Python コードを非表示にして編集不可にできます: + +```bash +marimo run your_notebook.py +``` + + + +**Execute as scripts.** ノートブックをスクリプトとしてコマンドラインから実行できます: + +```bash +python your_notebook.py +``` + +**Automatically convert Jupyter notebooks.** CLI で Jupyter ノートブックを marimo ノートブックに変換できます: + +```bash +marimo convert your_notebook.ipynb > your_notebook.py +``` + +または [ウェブインターフェース](https://marimo.io/convert) を使えます。 + +**Tutorials.** + +すべてのチュートリアルを一覧表示するには: + +```bash +marimo tutorial --help +``` + +**Share cloud-based notebooks.** + +[molab](https://molab.marimo.io/notebooks) は marimo のクラウドサービスで、ノートブックの共有や実行が可能です。 + +## Questions? + +詳細はドキュメントの [FAQ](https://docs.marimo.io/faq.html) を参照してください。 + +## Learn more + +marimo は導入が簡単で、パワーユーザーにも多くの機能を提供します。例えば、marimo で作られた埋め込み可視化ツールの例があります([動画](https://marimo.io/videos/landing/full.mp4))。 + + + +詳細は [ドキュメント](https://docs.marimo.io)、[使用例](https://docs.marimo.io/examples/) や [ギャラリー](https://marimo.io/gallery) をご覧ください。 + +
- 自定义输入 + 输入控件 - 自定义绘图 + 绘图 - 自定义布局 + 布局
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + +
+ Tutorial + + Inputs + + Plots + + Layout +
+ + + + + + + + + + + + + + + +
+ +## Contributing + +貢献を歓迎します。詳しい開始方法は [CONTRIBUTING.md](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md) を参照してください。 + +> 質問がありますか?[Discord](https://marimo.io/discord?ref=readme) でお問い合わせください。 + +## Community + +コミュニティに参加してください! + +- 🌟 [GitHub でスターをつける](https://github.com/marimo-team/marimo) +- 💬 [Discord でチャット](https://marimo.io/discord?ref=readme) +- 📧 [ニュースレターを購読](https://marimo.io/newsletter) +- ☁️ [クラウドウェイトリストに参加](https://marimo.io/cloud) +- ✏️ [GitHub でディスカッションを開始](https://github.com/marimo-team/marimo/discussions) +- 🦋 [Bluesky でフォロー](https://bsky.app/profile/marimo.io) +- 🐦 [Twitter でフォロー](https://twitter.com/marimo_io) +- 🎥 [YouTube で購読](https://www.youtube.com/@marimo-team) +- 🕴️ [LinkedIn でフォロー](https://www.linkedin.com/company/marimo-io) + +**A NumFOCUS affiliated project.** marimo は NumFOCUS コミュニティに所属しています。 + +## インスピレーション ✨ + +marimo は、エラーが発生しやすい JSON のスクラッチパッドではなく、再現性が高く、インタラクティブで、共有可能な Python プログラムとしての Python ノートブックの**再発明**です。 + +私たちは、使用するツールが思考のあり方を形作ると信じています — より良いツールは、より良い思考を促します。marimo を通じて、研究とその伝達、コードの実験と共有、計算科学の学習と教育に適した、より良いプログラミング環境を Python コミュニティに提供したいと考えています。 + +私たちのインスピレーションは多くの場所やプロジェクトから来ています。特に [Pluto.jl](https://github.com/fonsp/Pluto.jl)、[ObservableHQ](https://observablehq.com/tutorials)、および [Bret Victor のエッセイ](http://worrydream.com/) から多くを学びました。marimo はリアクティブなデータフロープログラミングへの大きなムーブメントの一部です。IPyflow、streamlit、TensorFlow、PyTorch、JAX、React といったプロジェクトから、関数型・宣言型・リアクティブプログラミングの考え方が多くのツールをより良く変えているのを見ています。 + +

+ +

diff --git a/README_Spanish.md b/README_Spanish.md new file mode 100644 index 00000000000..7f9cfc69423 --- /dev/null +++ b/README_Spanish.md @@ -0,0 +1,276 @@ +

+ +

+ +

+ Un cuaderno (notebook) de Python reactivo que es reproducible, compatible con Git y desplegable como scripts o aplicaciones. +

+ +

+ Documentos · + Discord · + Ejemplos · + Galería · + YouTube +

+ +

+ English + | + 繁體中文 + | + 简体中文 + | + 日本語 + | + Español +

+ +

+ + + discord + Pepy Total Downloads + Conda Downloads + +

+ +**marimo** es un cuaderno (notebook) de Python: ejecuta una celda o interactúa con un elemento de la interfaz de usuario y marimo ejecuta automáticamente las celdas dependientes (o las marca como desactualizadas), manteniendo el código y los resultados consistentes. Los cuadernos (notebooks) de marimo se almacenan como código Python puro, ejecutables como scripts y desplegables como aplicaciones. + +**Puntos Destacados**. + +- 🚀 **baterías incluidas:** reemplaza `jupyter`, `streamlit`, `jupytext`, `ipywidgets`, `papermill` y más +- ⚡️ **reactive**: ejecuta una celda y marimo reactivamente [ejecuta las celdas dependientes](https://docs.marimo.io/guides/reactivity.html) o las marca como desactualizadas +- 🖐️ **interaction:** [vincula deslizadores, tablas, gráficas y más](https://docs.marimo.io/guides/interactivity.html) a Python — sin "callbacks" requeridos +- 🔬 **reproducible:** [sin estado oculto](https://docs.marimo.io/guides/reactivity.html#no-hidden-state), ejecución determinística, [gestión de paquetes integrada](https://docs.marimo.io/guides/editor_features/package_management.html) +- 🏃 **ejecutable:** [se ejecuta como script de Python](https://docs.marimo.io/guides/scripts.html), parametrizable mediante arguments de la línea de commandos (CLI) +- 🛜 **compartible**: [se despliega como una aplicación web interactiva](https://docs.marimo.io/guides/apps.html) o [diapositivas](https://docs.marimo.io/guides/apps.html#slides-layout), [ejecutar en navegador via WASM](https://docs.marimo.io/guides/wasm.html) +- 🛢️ **diseñado para datos**: consulta marcos de datos y bases de datos [con SQL](https://docs.marimo.io/guides/working_with_data/sql.html), filtrar y buscar [marcos de datos](https://docs.marimo.io/guides/working_with_data/dataframes.html) +- 🐍 **compatible con git:** cuadernos (notebooks) son almacenados como archivos `.py` +- ⌨️ **un editor moderno**: [GitHub Copilot](https://docs.marimo.io/guides/editor_features/ai_completion.html#github-copilot), [asistentes IA](https://docs.marimo.io/guides/editor_features/ai_completion.html#using-ollama), atajos de teclado de vim, explorador de variables y [más](https://docs.marimo.io/guides/editor_features/index.html) + +```python +pip install marimo && marimo tutorial intro +``` + +_¡Prueba marimo en [nuestro entorno de pruebas](https://marimo.app/l/c7h6pz), se ejecuta completamente en el navegador!_ + +_[Inicia rápido](#quickstart) para una introducción sobre nuestro CLI._ + +## Un entorno de programación reactivo + +marimo garantiza que el código de tu notebook, los resultados y el estado del program sean consistentes. Esto [resuelve muchos problems](https://docs.marimo.io/faq.html#faq-problems) asociados con notebooks tradicionales como Jupyter. + +**Un entorno de programación reactivo.** +Ejecuta una celda y marimo reacciona ejecutando automáticamente las celdas que referencian sus variables, eliminando la tarea propensa a errores de volver a ejecutar celdas manualmente. Elimina una celda y marimo borra sus variables de la memoria del program, eliminando el estado oculto. + + + + + +**Compatible con cuadernos (notebooks) pesados.** marimo te permite [configurar el runtime +para que sea +lazy](https://docs.marimo.io/guides/configuration/runtime_configuration.html), +marcando las celdas afectadas como obsoletas en lugar de ejecutarlas automáticamente. Esto te da garantías sobre el estado del program mientras previene la ejecución accidental de celdas costosas. + +**Elementos UI sincronizados.** Interactúa con [ +elementos UI](https://docs.marimo.io/guides/interactivity.html) como [sliders](https://docs.marimo.io/api/inputs/slider.html#slider), +[dropdowns](https://docs.marimo.io/api/inputs/dropdown.html), [transformadores de dataframes](https://docs.marimo.io/api/inputs/dataframe.html), e [ +interfaces de chat](https://docs.marimo.io/api/inputs/chat.html), y las celdas que los usan se vuelven a ejecutar automáticamente con sus valores más recientes. + + + +**Marcos de datos interactivos.** [Navega, busca, filtra, y +ordena](https://docs.marimo.io/guides/working_with_data/dataframes.html) +millones de filas increíblemente rápido, sin necesidad de codigo. + + + +**Tiempo de ejecución eficiente.** marimo ejecuta solo las celdas que necesitan set ejecutadas analizando estáticamente tu código. + +**Markdown dinámico y SQL.** Usa markdown para contar historias dinámicas que dependen de +datos de Python. O construye consultas [SQL](https://docs.marimo.io/guides/working_with_data/sql.html) +que dependen de valores de Python y ejecútalas contra dataframes, bases de datos, CSVs, Google Sheets, o cualquier otra cosa usando nuestro motor SQL integrado, que devuelve el resultado como un dataframe de Python. + + + +Tus notebooks siguen siendo Python puro, incluso si usan markdown o SQL. + +**Orden de ejecución determinístico.** Los notebooks se ejecutan en un orden determinístico, basado en referencias de variables en lugar de las posiciones de las celdas en la página. +Organiza tus notebooks para que se ajusten mejor a las historias que quieres contar. + +**Gestión de paquetes integrada.** marimo tiene soporte integrado para todos los gestores de paquetes principles, permitiéndote [instalar paquetes al importarlos](https://docs.marimo.io/guides/editor_features/package_management.html). marimo puede incluso +[serializar los requisitos de paquetes](https://docs.marimo.io/guides/package_management/inlining_dependencies/) +en archivos de notebook, e instalarlos automáticamente en sandboxes venv aislados. + +**Baterías incluidas.** marimo viene con GitHub Copilot, asistentes de IA, formateo de código con Ruff, exportación HTML, autocompletado rápido, una [extensión de VS Code](https://marketplace.visualstudio.com/items?itemName=marimo-team.vscode-marimo), +un visor interaction de dataframes, y [muchas más](https://docs.marimo.io/guides/editor_features/index.html) +características de calidad de vida. + +## Inicio rápido + +**Instalación.** En una terminal, ejecuta + +```bash +pip install marimo # or conda install -c conda-forge marimo +marimo tutorial intro +``` + +Para instalar con dependencies adicionales que desbloquean celdas SQL, completado con IA y más, ejecuta + +```bash +pip install marimo[recommended] +``` + +**Crear cuadernos (notebooks).** + +Crea o edita notebooks con + +```bash +marimo edit +``` + +**Ejecutar aplicaciones.** Ejecuta tu notebook como una aplicación web, con el código Python oculto y no editable: + +```bash +marimo run your_notebook.py +``` + + + +**Ejecutar como scripts.** Ejecuta un notebook como un script en la línea de commandos: + +```bash +python your_notebook.py +``` + +**Convertir cuadernos (notebooks) de Jupyter automáticamente.** Convierte automáticamente notebooks de Jupyter a notebooks de marimo con el CLI: + +```bash +marimo convert your_notebook.ipynb > your_notebook.py +``` + +o usa nuestra [interfaz web](https://marimo.io/convert). + +**Tutorials.** +Lista de todos los tutorials: + +```bash +marimo tutorial --help +``` + +## ¿Preguntas? + +Consulta las [FAQ](https://docs.marimo.io/faq.html) en nuestra documentation. + +## Aprende más + +marimo es fácil para empezar, con mucho espacio para usuarios avanzados. Por ejemplo, aquí hay un visualizador de embeddings hecho en marimo +([video](https://marimo.io/videos/landing/full.mp4)): + + + +Revisa nuestra [documentation](https://docs.marimo.io), +la carpeta [`examples/`](examples/), y nuestra [galeria](https://marimo.io/gallery) para aprender mas. + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + +
+ Tutorial + + Inputs + + Plots + + Layout +
+ + + + + + + + + + + + + + + +
+ +## Contribuir + +¡Apreciamos todas las contribuciones! No necesitas set un experto para ayudar. Por favor consulta [CONTRIBUTING.md](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md) para más detalles sobre cómo empezar. + +> Dudas? Acércate a nosotros [en Discord](https://marimo.io/discord?ref=readme). + +## Comunidad + +Estamos construyendo una comunidad. ¡Ven a pasar el rato con nosotros! + +- 🌟 [Star us on GitHub](https://github.com/marimo-team/marimo) +- 💬 [Chat with us on Discord](https://marimo.io/discord?ref=readme) +- 📧 [Subscribe to our Newsletter](https://marimo.io/newsletter) +- ☁️ [Join our Cloud Waitlist](https://marimo.io/cloud) +- ✏️ [Start a GitHub Discussion](https://github.com/marimo-team/marimo/discussions) +- 🦋 [Follow us on Bluesky](https://bsky.app/profile/marimo.io) +- 🐦 [Follow us on Twitter](https://twitter.com/marimo_io) +- 🎥 [Subscribe on YouTube](https://www.youtube.com/@marimo-team) +- 🕴️ [Follow us on LinkedIn](https://www.linkedin.com/company/marimo-io) + +## Inspiración ✨ + +marimo es una **reinvención** de Python como un program Python reproducible, interaction y compartible, en lugar de un bloc de notas JSON propenso a errores. + +Creemos que las herramientas que usamos dan forma a nuestra manera de pensar — mejores herramientas, para mentes mejores. Con marimo, esperamos proporcionar a la comunidad de Python un mejor entorno de programación para hacer investigación y comunicarla; para experimentar con código y compartirlo; para aprender ciencia computational y enseñarla. + +Nuestra inspiración viene de muchos lugares y proyectos, especialmente +[Pluto.jl](https://github.com/fonsp/Pluto.jl), +[ObservableHQ](https://observablehq.com/tutorials), y +[los ensayos de Bret Victor](http://worrydream.com/). marimo es parte de +un movimiento mayor hacia la programación reactiva de flujo de datos. Desde +[IPyflow](https://github.com/ipyflow/ipyflow), [streamlit](https://github.com/streamlit/streamlit), +[TensorFlow](https://github.com/tensorflow/tensorflow), +[PyTorch](https://github.com/pytorch/pytorch/tree/main), +[JAX](https://github.com/google/jax), y +[React](https://github.com/facebook/react), las ideas de programación functional, declarativa y reactiva están transformando una amplia gama de herramientas para mejor. + +

+ +

diff --git a/README_Traditional_Chinese.md b/README_Traditional_Chinese.md new file mode 100644 index 00000000000..39fd26aef9a --- /dev/null +++ b/README_Traditional_Chinese.md @@ -0,0 +1,292 @@ +

+ +

+ +

+ 一個響應式的 Python 筆記本,可重現、支援 Git 版本控制,並可部署為腳本或應用程式。 +

+ +

+ 文件 · + Discord · + 範例 · + 展示廊 · + YouTube +

+ +

+ English + | + 繁體中文 + | + 简体中文 + | + 日本語 + | + Español +

+ +

+ + + discord + Pepy Total Downloads + Conda Downloads + +

+ +**marimo** 是一個響應式的 Python 筆記本:執行單元格或與 UI 元素互動時,marimo 會自動執行相依的單元格(或將其標記為過時),保持程式碼和輸出的一致性。marimo 筆記本以純 Python 格式儲存(具有一流的 SQL 支援),可作為腳本執行,並可部署為應用程式。 + +**亮點** + +- 🚀 **功能齊全:** 可取代 `jupyter`、`streamlit`、`jupytext`、`ipywidgets`、`papermill` 等工具 +- ⚡️ **響應式:** 執行一個單元格,marimo 會響應式地[執行所有相依單元格](https://docs.marimo.io/guides/reactivity.html)或將其標記為過時 +- 🖐️ **互動性:** [綁定滑桿、表格、圖表等](https://docs.marimo.io/guides/interactivity.html)至 Python — 無需回呼函式 +- 🐍 **支援 Git 版本控制:** 以 `.py` 檔案格式儲存 +- 🛢️ **為資料設計:** 使用 [SQL](https://docs.marimo.io/guides/working_with_data/sql.html) 查詢資料框、資料庫、資料倉儲或資料湖倉,過濾和搜尋[資料框](https://docs.marimo.io/guides/working_with_data/dataframes.html) +- 🤖 **AI 原生:** [與 AI agents 結對](https://marimo.io/pair)(如 Claude Code),或使用編輯器的[內建 AI 功能](https://docs.marimo.io/guides/editor_features/ai_completion/) +- 🔬 **可重現:** [無隱藏狀態](https://docs.marimo.io/guides/reactivity.html#no-hidden-state)、確定性執行、[內建套件管理](https://docs.marimo.io/guides/package_management/) +- 🏃 **可執行:** [作為 Python 腳本執行](https://docs.marimo.io/guides/scripts.html),透過 CLI 參數化 +- 🛜 **可分享:** [部署為互動式網頁應用程式](https://docs.marimo.io/guides/apps.html)或[簡報](https://docs.marimo.io/guides/apps.html#slides-layout),[透過 WASM 在瀏覽器中執行](https://docs.marimo.io/guides/wasm.html) +- 🧩 **可重用:** [匯入函式和類別](https://docs.marimo.io/guides/reusing_functions/)從一個筆記本到另一個筆記本 +- 🧪 **可測試:** 在筆記本上[執行 pytest](https://docs.marimo.io/guides/testing/) +- ⌨️ **現代化編輯器:** [GitHub Copilot](https://docs.marimo.io/guides/editor_features/ai_completion.html#github-copilot)、[AI 助手](https://docs.marimo.io/guides/editor_features/ai_completion.html)、vim 鍵盤綁定、變數瀏覽器,以及[更多功能](https://docs.marimo.io/guides/editor_features/index.html) +- 🧑‍💻 **使用您喜愛的編輯器**:在 [VS Code 或 Cursor](https://marketplace.visualstudio.com/items?itemName=marimo-team.vscode-marimo) 或 [PyCharm](https://plugins.jetbrains.com/plugin/32416-marimo) 中執行,或在 neovim、Zed [或任何其他文字編輯器](https://docs.marimo.io/guides/editor_features/watching/)中編輯 + +```python +pip install marimo && marimo tutorial intro +``` + +_立即使用 [**mo**lab,我們的免費線上 notebook](https://molab.marimo.io/notebooks) 開始體驗。或跳至[快速開始](#快速開始)了解 CLI 使用方式。_ + +## 響應式程式設計環境 + +marimo 保證您的筆記本程式碼、輸出和程式狀態保持一致。這[解決了許多問題](https://docs.marimo.io/faq.html#faq-problems),這些問題與傳統筆記本(如 Jupyter)相關。 + +**響應式程式設計環境。** +執行一個單元格,marimo 會_響應式地_自動執行所有引用其變數的單元格,省去手動重新執行單元格這種容易出錯的步驟。刪除一個單元格,marimo 會從程式記憶體中清除其變數,消除隱藏狀態。 + + + + + +**與高成本筆記本相容。** marimo 讓您[設定執行環境為惰性模式](https://docs.marimo.io/guides/configuration/runtime_configuration.html),將受影響的單元格標記為過時,而不是自動執行它們。這為您提供了程式狀態的保證,同時防止意外執行高成本的單元格。 + +**同步的 UI 元素。** 與 [UI 元素](https://docs.marimo.io/guides/interactivity.html)互動,如[滑桿](https://docs.marimo.io/api/inputs/slider.html#slider)、[下拉選單](https://docs.marimo.io/api/inputs/dropdown.html)、[資料框轉換器](https://docs.marimo.io/api/inputs/dataframe.html)和[聊天介面](https://docs.marimo.io/api/inputs/chat.html),使用它們的單元格會自動以最新值重新執行。 + + + +**互動式資料框。** [翻頁瀏覽、搜尋、篩選和排序](https://docs.marimo.io/guides/working_with_data/dataframes.html)數百萬行資料,速度極快,無需編寫程式碼。 + + + +**使用資料感知 AI 生成單元格。** 使用您最喜愛 agent(如 Claude Code、Codex 或 OpenCode),透過 [marimo pair](https://docs.marimo.io/guides/generate_with_ai/marimo_pair/) 協作編輯 marimo 筆記本。或者,透過 [marimo 編輯器的 AI 助手](https://docs.marimo.io/guides/editor_features/ai_completion/)生成程式碼,該助手專門為資料處理而設計,具有記憶體中變數的上下文。自訂系統提示,使用您自己的 API 金鑰,或使用本地模型。 + + + +**使用 SQL 查詢資料。** 建構依賴於 Python 值的 [SQL](https://docs.marimo.io/guides/working_with_data/sql.html) 查詢,並使用我們內建的 SQL 引擎對資料框、資料庫、資料湖、CSV、Google 試算表或任何其他資料來源執行查詢,結果會以 Python 資料框返回。 + + + +您的筆記本仍然是純 Python,即使使用了 SQL。 + +**動態 Markdown。** 使用由 Python 變數參數化的 Markdown,講述依賴於 Python 資料的動態故事。 + +**內建套件管理。** marimo 內建支援所有主要的套件管理器,讓您[在匯入時安裝套件](https://docs.marimo.io/guides/editor_features/package_management.html)。marimo 甚至可以在筆記本檔案中[序列化套件需求](https://docs.marimo.io/guides/package_management/inlining_dependencies/),並在隔離的 venv 沙盒中自動安裝它們。 + +**確定性執行順序。** 筆記本以確定性順序執行,基於變數引用而不是單元格在頁面上的位置。組織您的筆記本以最佳方式講述您想要的故事。 + +**高效能執行環境。** marimo 透過靜態分析您的程式碼,只執行需要執行的單元格。 + +**功能齊全。** marimo 附帶 GitHub Copilot、AI 助手、Ruff 程式碼格式化、HTML 匯出、快速程式碼自動完成、[VS Code 擴充套件](https://marketplace.visualstudio.com/items?itemName=marimo-team.vscode-marimo)、[PyCharm 擴充套件](https://plugins.jetbrains.com/plugin/32416-marimo)、互動式資料框檢視器,以及[更多](https://docs.marimo.io/guides/editor_features/index.html)便利功能。 + +## 快速開始 + +_我們 [YouTube 頻道](https://www.youtube.com/@marimo-team)上的 [marimo 概念播放清單](https://www.youtube.com/watch?v=3N6lInzq5MI&list=PLNJXGo8e1XT9jP7gPbRdm1XwloZVFvLEq)提供了許多功能的概覽。_ + +**安裝** + +在終端機中執行 + +```bash +pip install marimo # 或 conda install -c conda-forge marimo +marimo tutorial intro +``` + +若要安裝包含額外相依套件以解鎖 SQL 單元格、AI 自動完成等功能,請執行 + +```bash +pip install "marimo[recommended]" +``` + +**建立新筆記本** + +使用以下指令建立或編輯筆記本 + +```bash +marimo edit +``` + +**作為應用程式執行** + +將您的筆記本作為網頁應用程式執行,Python 程式碼將被隱藏且不可編輯: + +```bash +marimo run your_notebook.py +``` + + + +**作為腳本執行** + +在終端機中將 notebook 作為腳本執行 + +```bash +python your_notebook.py +``` + +**自動轉換 Jupyter 筆記本** + +使用 CLI 自動將 Jupyter 筆記本轉換為 marimo 筆記本 + +```bash +marimo convert your_notebook.ipynb > your_notebook.py +``` + +或使用我們的[網頁介面](https://marimo.io/convert)。 + +**教學** + +列出所有教學: + +```bash +marimo tutorial --help +``` + +**分享雲端筆記本。** + +使用 [molab](https://molab.marimo.io/notebooks),一個類似於 Google Colab 的雲端 marimo 筆記本服務, +來建立和分享 notebook 連結。 + +## 有問題嗎? + +請參閱我們文件中的[常見問題](https://docs.marimo.io/faq.html)。 + +## 了解更多 + +marimo 容易上手,並為進階使用者提供了許多強大功能。 +例如,這是一個使用 marimo 製作的嵌入視覺化工具 +([在 molab 上即時試用 notebook!](https://molab.marimo.io/notebooks/nb_jJiFFtznAy4BxkrrZA1o9b/app?show-code=true)): + + + +查看我們的[文件](https://docs.marimo.io)、 +[使用範例](https://docs.marimo.io/examples/),以及我們的[展示廊](https://marimo.io/gallery)以了解更多。 + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + +
+ 教學 + + 輸入 + + 繪圖 + + 佈局 +
+ + + + + + + + + + + + + + + +
+ +## 貢獻 + +我們感謝所有的貢獻!您不需要是專家即可提供協助。 +請參閱 [CONTRIBUTING.md](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md) 以獲取更多關於如何開始的詳細資訊。 + +> 有問題嗎?請在 [Discord](https://marimo.io/discord?ref=readme) 上與我們聯繫。 + +## 社群 + +我們正在建立一個社群。歡迎來與我們交流! + +- 🌟 [在 GitHub 上為我們加星](https://github.com/marimo-team/marimo) +- 💬 [在 Discord 上與我們聊天](https://marimo.io/discord?ref=readme) +- 📧 [訂閱我們的電子報](https://marimo.io/newsletter) +- ☁️ [加入我們的雲端服務候補名單](https://marimo.io/cloud) +- ✏️ [在 GitHub 上發起討論](https://github.com/marimo-team/marimo/discussions) +- 🦋 [在 Bluesky 上追蹤我們](https://bsky.app/profile/marimo.io) +- 🐦 [在 Twitter 上追蹤我們](https://twitter.com/marimo_io) +- 🎥 [在 YouTube 上訂閱](https://www.youtube.com/@marimo-team) +- 🤖 [在 Reddit 上追蹤我們](https://www.reddit.com/r/marimo_notebook) +- 🕴️ [在 LinkedIn 上追蹤我們](https://www.linkedin.com/company/marimo-io) + +**NumFOCUS 附屬專案。** marimo 是更廣泛 Python 生態系統的核心部分,也是 NumFOCUS 社群的成員,該社群包括 NumPy、SciPy 和 Matplotlib 等專案。 + + + + +## 靈感 ✨ + +marimo 是 Python 筆記本的**重新發明**,作為一個可重現、互動且可分享的 Python 程式,而非容易出錯的 JSON 草稿本。 + +我們相信工具會影響我們的思考方式——更好的工具,造就更好的思維。透過 marimo,我們希望為 Python 社群提供一個更好的程式設計環境,用以進行研究並傳達成果;實驗程式碼並分享它;學習計算科學並教授它。 + +我們的靈感來自許多地方和專案,特別是 [Pluto.jl](https://github.com/fonsp/Pluto.jl)、[ObservableHQ](https://observablehq.com/tutorials) 和 [Bret Victor 的文章](http://worrydream.com/)。marimo 是朝向響應式資料流程式設計更大運動的一部分。從 [IPyflow](https://github.com/ipyflow/ipyflow)、[streamlit](https://github.com/streamlit/streamlit)、[TensorFlow](https://github.com/tensorflow/tensorflow)、[PyTorch](https://github.com/pytorch/pytorch/tree/main)、[JAX](https://github.com/google/jax) 到 [React](https://github.com/facebook/react),函數式、聲明式和響應式程式設計的理念正在改善廣泛的工具。 + +最後,我們要感謝 [Bennet Meyers](https://bmeyers.github.io/about/) 和 [David Chassin](https://www.chassin.org/about/) 從一開始就相信 marimo:這項工作部分得到美國能源部關鍵礦產與能源創新辦公室(CMEI)整合能源系統辦公室(IESO)第 34368 號協議的支持。 + +

+ +

+ +--- + +> **Note**: This is a community-contributed translation. The [English README](README.md) is the authoritative and most up-to-date version. + +> **注意**:這是社群貢獻的翻譯。[英文 README](README.md) 是最權威且最新的版本。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000000..7026e120975 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,80 @@ +# Security Policy + +marimo is committed to providing a secure environment for notebook development and application deployment. For a comprehensive overview of marimo's security model and features, see our [Security documentation](https://docs.marimo.io/security/). + +## Supported Versions + +We provide security patches for the latest stable release only. We encourage all users to stay on the latest version. + +## Reporting a Vulnerability + +To report a security vulnerability, [please draft an advisory through +GitHub](https://github.com/marimo-team/marimo/security/advisories/new), or +email the marimo team; security [at] marimo [dot] io. + +Please include: +- A description of the vulnerability and its potential impact +- Steps to reproduce or a proof-of-concept +- Any suggested mitigations if known + +### What to Expect + +- **Acknowledgement**: We will respond within 3 business days to confirm receipt +- **Triage**: We will assess severity and scope within 7 days +- **Patch & disclosure**: We aim to release a fix and publish a CVE/advisory simultaneously, typically within 90 days of the initial report + +We will keep you informed throughout the process and credit you in the advisory unless you prefer to remain anonymous. + +### What Warrants a Security Advisory + +We issue CVEs and security advisories when: + +- A vulnerability could affect long-running app deployments +- End-users are directly impacted +- The issue has security implications beyond normal bug fixes + +General safety improvements and hardening work are documented in our [release notes](https://github.com/marimo-team/marimo/releases) without formal advisories. + +Advisories will be escalated to a CVE and/or a general advisory issued if end-users +are directly impacted. Attribution for any actionable report will be provided +in the section below (unless anonymity is preferred). + +### molab Security + +For security issues affecting [molab](https://docs.marimo.io/guides/molab/) (our hosted platform): + +- Reports can be submitted through the same channels above +- We handle disclosure on a case-by-case basis +- General security improvements are disclosed publicly when applicable +- User-specific issues are handled privately through direct notification + +## Recognition and Thanks + +We deeply appreciate the security research community's efforts to improve marimo's security. Responsible disclosure helps protect all marimo users, and we recognize the time and expertise that goes into security research. + +We would like to acknowledge and thank the following individuals for their responsible disclosure of security issues: + + - @AlexanderBarabanov + - @pwntester + - @s-celles + - @acepace + - @devgi + - @W-M-T (Ward Theunisse) + - @doredry + - @q1uf3ng from OneKey Anzen Lab + - @Fushuling @RacerZ-fighting + - @GCXWLP + - @Vincent550102 + - @l3tchupkt + - @wooseokdotkim + - @Jvr2022 + - @offset + - @jeremysommerfeld8910-cpu + - @jinyimeng01 @boom-dy @zz-yy + - @elvinsuleymanov + - @thientd + - @Wang-Haimin + - @Grg0rry + - @tomasilluminati + +Your contributions help keep marimo safe for the entire community. We encourage security researchers to report issues and welcome your help in making marimo more secure. diff --git a/biome.json b/biome.json deleted file mode 100644 index 728e69dfdd4..00000000000 --- a/biome.json +++ /dev/null @@ -1,133 +0,0 @@ -{ - "$schema": "frontend/node_modules/@biomejs/biome/configuration_schema.json", - "formatter": { - "enabled": true, - "formatWithErrors": false, - "indentStyle": "space", - "indentWidth": 2, - "lineEnding": "lf", - "lineWidth": 80, - "attributePosition": "auto", - "ignore": [ - "dist", - "lib", - "pnpm-lock.yaml", - "storybook-static", - "package.json" - ] - }, - "organizeImports": { - "ignore": [ - "dist", - "lib", - "pnpm-lock.yaml", - "storybook-static", - "package.json" - ], - "enabled": false - }, - "linter": { - "ignore": [ - "dist", - "lib", - "pnpm-lock.yaml", - "storybook-static", - "package.json" - ], - "enabled": true, - "rules": { - "recommended": true, - "a11y": { - "noAutofocus": "off", - "noSvgWithoutTitle": "info", - "useButtonType": "info", - "useIframeTitle": "info", - "useKeyWithClickEvents": "off", - "useGenericFontNames": "off", - "useSemanticElements": "warn", - "useMediaCaption": "info" - }, - "complexity": { - "noBannedTypes": "off", - "noForEach": "off", - "useArrowFunction": "error" - }, - "correctness": { - "noInnerDeclarations": "error", - "noUnusedImports": "error", - "useExhaustiveDependencies": "off", - "useHookAtTopLevel": "info" - }, - "security": { - "noDangerouslySetInnerHtml": "off" - }, - "style": { - "noCommaOperator": "error", - "noNonNullAssertion": "off", - "noParameterAssign": "off", - "noUnusedTemplateLiteral": "error", - "useNamingConvention": { - "level": "warn", - "options": { - "strictCase": false, - "conventions": [ - { - "selector": { "kind": "const", "scope": "global" }, - "match": ".*" - }, - { - "selector": { "kind": "let", "scope": "global" }, - "match": ".*" - }, - { - "selector": { "kind": "function" }, - "formats": ["camelCase", "PascalCase"] - }, - { - "selector": { "kind": "variable" }, - "formats": ["camelCase", "PascalCase", "CONSTANT_CASE"] - }, - { - "selector": { "kind": "typeLike" }, - "formats": ["PascalCase", "CONSTANT_CASE", "camelCase"] - }, - { - "match": ".*" - } - ] - } - }, - "useConsistentArrayType": "off", - "useImportType": "error", - "useNodejsImportProtocol": "error", - "useSingleVarDeclarator": "error", - "useTemplate": "error" - }, - "suspicious": { - "noArrayIndexKey": "off", - "noAssignInExpressions": "info", - "noConsoleLog": "off", - "noDoubleEquals": "error", - "noEmptyBlockStatements": "info", - "noExplicitAny": "off", - "noImplicitAnyLet": "error", - "noRedeclare": "info", - "noShadowRestrictedNames": "error" - } - } - }, - "javascript": { - "jsxRuntime": "reactClassic", - "formatter": { - "jsxQuoteStyle": "double", - "quoteProperties": "asNeeded", - "trailingCommas": "all", - "semicolons": "always", - "arrowParentheses": "always", - "bracketSpacing": true, - "bracketSameLine": false, - "quoteStyle": "double", - "attributePosition": "auto" - } - } -} diff --git a/codecov.yml b/codecov.yml index 0db450d2228..14007e0334a 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,3 +1,7 @@ +comment: + require_bundle_changes: true + bundle_change_threshold: "10Kb" + coverage: status: project: @@ -14,3 +18,5 @@ coverage: # this allows a 50% drop from the previous base commit coverage # we'll decrease this as our testing matures threshold: 50% + ignore: + - 'marimo/_snippets/data/**' diff --git a/configs/.vimrc b/configs/.vimrc new file mode 100644 index 00000000000..2626379946c --- /dev/null +++ b/configs/.vimrc @@ -0,0 +1,2 @@ +" For testing " +imap kj diff --git a/dagger.json b/dagger.json deleted file mode 100644 index c6632e9191e..00000000000 --- a/dagger.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "marimo", - "sdk": "python", - "source": "dagger", - "engineVersion": "v0.13.0" -} diff --git a/dagger/.gitattributes b/dagger/.gitattributes deleted file mode 100644 index 827418463fc..00000000000 --- a/dagger/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -/sdk/** linguist-generated diff --git a/dagger/.gitignore b/dagger/.gitignore deleted file mode 100644 index 78f6c8dd398..00000000000 --- a/dagger/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/sdk diff --git a/dagger/README.md b/dagger/README.md deleted file mode 100644 index 3826cb590fd..00000000000 --- a/dagger/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Dagger - -This directory contains Dagger scripts for building and testing marimo in a -containerized environment. This allows for running tests and builds in an -environment that is similar to CI. - -To run a Dagger script, you need to have the Dagger CLI installed. You can install -it by following the instructions [here](https://docs.dagger.io/install). - -To run a Dagger script, you can use the following command: - -```bash -dagger functions # see what functions are available in the module -dagger call make # run make task in a dev environment -dagger call frontend test # run frontend tests -dagger call backend test # run backend tests -# run frontend tests on a remote pull request -dagger call --src https://github.com/marimo-team/marimo\#pull/2542/head frontend test -``` diff --git a/dagger/pyproject.toml b/dagger/pyproject.toml deleted file mode 100644 index e9bfc92bd4e..00000000000 --- a/dagger/pyproject.toml +++ /dev/null @@ -1,9 +0,0 @@ -[project] -name = "main" -version = "0.0.0" -dependencies = [] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - diff --git a/dagger/requirements.lock b/dagger/requirements.lock deleted file mode 100644 index c88329fcf55..00000000000 --- a/dagger/requirements.lock +++ /dev/null @@ -1,94 +0,0 @@ -# This file was autogenerated by uv via the following command: -# uv pip compile --universal -o requirements.lock sdk/pyproject.toml pyproject.toml -anyio==4.4.0 - # via - # dagger-io (sdk/pyproject.toml) - # gql - # httpx -attrs==24.2.0 - # via cattrs -backoff==2.2.1 - # via gql -beartype==0.18.5 - # via dagger-io (sdk/pyproject.toml) -cattrs==24.1.0 - # via dagger-io (sdk/pyproject.toml) -certifi==2024.8.30 - # via - # httpcore - # httpx -deprecated==1.2.14 - # via - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-grpc - # opentelemetry-semantic-conventions -googleapis-common-protos==1.65.0 - # via opentelemetry-exporter-otlp-proto-grpc -gql==3.5.0 - # via dagger-io (sdk/pyproject.toml) -graphql-core==3.2.4 - # via gql -grpcio==1.66.1 - # via opentelemetry-exporter-otlp-proto-grpc -h11==0.14.0 - # via httpcore -httpcore==1.0.5 - # via httpx -httpx==0.27.2 - # via gql -idna==3.8 - # via - # anyio - # httpx - # yarl -importlib-metadata==8.4.0 - # via opentelemetry-api -markdown-it-py==3.0.0 - # via rich -mdurl==0.1.2 - # via markdown-it-py -multidict==6.0.5 - # via yarl -opentelemetry-api==1.27.0 - # via - # opentelemetry-exporter-otlp-proto-grpc - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-otlp-proto-common==1.27.0 - # via opentelemetry-exporter-otlp-proto-grpc -opentelemetry-exporter-otlp-proto-grpc==1.27.0 - # via dagger-io (sdk/pyproject.toml) -opentelemetry-proto==1.27.0 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-grpc -opentelemetry-sdk==1.27.0 - # via - # dagger-io (sdk/pyproject.toml) - # opentelemetry-exporter-otlp-proto-grpc -opentelemetry-semantic-conventions==0.48b0 - # via opentelemetry-sdk -platformdirs==4.3.2 - # via dagger-io (sdk/pyproject.toml) -protobuf==4.25.4 - # via - # googleapis-common-protos - # opentelemetry-proto -pygments==2.18.0 - # via rich -rich==13.8.0 - # via dagger-io (sdk/pyproject.toml) -sniffio==1.3.1 - # via - # anyio - # httpx -typing-extensions==4.12.2 - # via - # dagger-io (sdk/pyproject.toml) - # opentelemetry-sdk -wrapt==1.16.0 - # via deprecated -yarl==1.11.0 - # via gql -zipp==3.20.1 - # via importlib-metadata diff --git a/dagger/src/main/__init__.py b/dagger/src/main/__init__.py deleted file mode 100644 index 628253650d9..00000000000 --- a/dagger/src/main/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""A module for Marimo functions""" - -from .main import Marimo - -__all__ = ["Marimo"] diff --git a/dagger/src/main/backend.py b/dagger/src/main/backend.py deleted file mode 100644 index d3df1fef522..00000000000 --- a/dagger/src/main/backend.py +++ /dev/null @@ -1,67 +0,0 @@ -from typing import Annotated - -import dagger -from dagger import Doc, field, function, object_type - -from .env import Env - - -@object_type -class Backend: - src: Annotated[dagger.Directory, Doc("The marimo source tree to use")] = ( - field() - ) - - @function - async def test(self) -> dagger.Container: - env = ( - Env() - .py() - .with_directory("/src", self.src, owner="nonroot") - .with_workdir("/src") - # from GHA: This step is needed since some of our tests rely on the index.html file - .with_exec(["mkdir", "-p", "marimo/_static/assets"]) - .with_exec( - ["cp", "frontend/index.html", "marimo/_static/index.html"] - ) - .with_exec( - [ - "cp", - "frontend/public/favicon.ico", - "marimo/_static/favicon.ico", - ] - ) - .with_exec(["make", "py"]) - .with_exec(["hatch", "run", "lint"]) - .with_exec(["hatch", "run", "typecheck:check"]) - ) - - await env.sync() - - # test:test - await env.with_exec( - [ - "hatch", - "run", - "+py=3.12", - "test:test", - "-v", - "tests/", - "-k", - "not test_cli", - ] - ).sync() - - # test-optional:test - return env.with_exec( - [ - "hatch", - "run", - "+py=3.12", - "test-optional:test", - "-v", - "tests/", - "-k", - "not test_cli", - ] - ) diff --git a/dagger/src/main/cli.py b/dagger/src/main/cli.py deleted file mode 100644 index 18c77dc6d3c..00000000000 --- a/dagger/src/main/cli.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Annotated - -import dagger -from dagger import DefaultPath, Doc, Ignore, dag, field, function, object_type - - -@object_type -class Cli: - src: Annotated[ - dagger.Directory, - Doc("The marimo source tree to use"), - DefaultPath("/"), - Ignore(["/dagger", ".venv"]), - ] = field() - - @function - def test(self) -> dagger.Container: - return ( - # python base - dag.container().from_("python:3-bookworm") - ) diff --git a/dagger/src/main/env.py b/dagger/src/main/env.py deleted file mode 100644 index c4cd91e5bc3..00000000000 --- a/dagger/src/main/env.py +++ /dev/null @@ -1,78 +0,0 @@ -import dagger -from dagger import dag, function, object_type - - -@object_type -class Env: - @function - def dev(self) -> dagger.Container: - """Dev container with dependencies for the full stack""" - return ( - # python base - self.py() - .with_mounted_cache( - "/root/.local/share/pnpm", dag.cache_volume("pnpm") - ) - # package deps - .with_exec(["apt", "update"]) - .with_exec(["apt", "install", "-y", "curl"]) - # install node 20+ - .with_exec( - [ - "sh", - "-c", - "curl -fsSL https://deb.nodesource.com/setup_20.x | bash -", # noqa: E501 - ] - ) - .with_exec(["apt", "install", "-y", "nodejs"]) - # install pnpm@9 - .with_exec(["npm", "install", "-g", "pnpm@9"]) - .with_env_variable("NODE_OPTIONS", "--max-old-space-size=8192") - ) - - @function - def py(self) -> dagger.Container: - """Python container with dependencies for the backend""" - return ( - # python base - dag.container() - .from_("python:3.12-bookworm") - .with_exec(["apt-get", "update"]) - .with_exec( - [ - "apt-get", - "install", - "-y", - "make", - "libgdal-dev", - "python3-gdal", - ] - ) - .with_exec(["adduser", "nonroot"]) - .with_mounted_cache( - "/home/nonroot/.cache/pip", - dag.cache_volume("python-312"), - owner="nonroot", - ) - .with_mounted_cache( - "/home/nonroot/.cache/uv", - dag.cache_volume("uv-python-312"), - owner="nonroot", - ) - .with_exec(["pip", "install", "hatch", "typos"]) - .with_user("nonroot") - ) - - @function - def pnpm(self) -> dagger.Container: - """A pnpm container with dependencies for the frontend""" - return ( - dag.container() - .from_("node:20-slim") - .with_env_variable("CI", "true") - .with_env_variable("NODE_OPTIONS", "--max-old-space-size=8192") - .with_mounted_cache( - "/root/.local/share/pnpm", dag.cache_volume("pnpm") - ) - .with_exec(["corepack", "enable"]) # this enables pnpm - ) diff --git a/dagger/src/main/frontend.py b/dagger/src/main/frontend.py deleted file mode 100644 index 0a87879979c..00000000000 --- a/dagger/src/main/frontend.py +++ /dev/null @@ -1,43 +0,0 @@ -from typing import Annotated - -import dagger -from dagger import Doc, dag, field, function, object_type - -from .env import Env - - -@object_type -class Frontend: - src: Annotated[dagger.Directory, Doc("The marimo source tree to use")] = ( - field() - ) - - @function - def test( - self, - turbo_token: dagger.Secret = dag.set_secret("DEFAULT", ""), # noqa: B008 - ) -> dagger.Container: - """ - Replace .github/workflows/test_fe.yaml - """ - return ( - Env() - .pnpm() - .with_env_variable("MARIMO_SKIP_UPDATE_CHECK", "true") - .with_secret_variable("TURBO_TOKEN", turbo_token) - .with_env_variable("TURBO_TEAM", "marimo") - .with_workdir("/src/frontend") - .with_directory("/src", self.src) - .with_exec(["pnpm", "install"]) - .with_exec(["pnpm", "dedupe", "--check"]) - .with_exec(["pnpm", "turbo", "lint"]) - .with_exec(["pnpm", "turbo", "typecheck"]) - .with_exec(["pnpm", "test"]) - .with_env_variable("NODE_ENV", "production") - .with_exec(["pnpm", "turbo", "build"]) - .with_env_variable("VITE_MARIMO_ISLANDS", "true") - .with_env_variable("VITE_MARIMO_VERSION", "0.0.0") - .with_exec(["npm", "version", "0.0.0", "--no-git-tag-version"]) - .with_exec(["pnpm", "turbo", "build:islands"]) - .with_exec(["./islands/validate.sh"]) - ) diff --git a/dagger/src/main/main.py b/dagger/src/main/main.py deleted file mode 100644 index b5c9a15d720..00000000000 --- a/dagger/src/main/main.py +++ /dev/null @@ -1,54 +0,0 @@ -from typing import Annotated - -import dagger -from dagger import DefaultPath, Doc, Ignore, field, function, object_type - -from .backend import Backend -from .cli import Cli -from .env import Env -from .frontend import Frontend - - -@object_type -class Marimo: - """A collection of tasks for the Marimo project.""" - - src: Annotated[dagger.Directory, Doc("The marimo source tree to use")] = ( - field() - ) - frontend: Annotated[Frontend, Doc("Frontend components")] = field() - backend: Annotated[Backend, Doc("Backend components")] = field() - cli: Annotated[Cli, Doc("CLI components")] = field() - env: Annotated[Env, Doc("Test and Build environments")] = field() - - @classmethod - def create( - cls, - src: Annotated[ - dagger.Directory, - Doc("The marimo source tree to use"), - DefaultPath("/"), - Ignore(["**/dagger", "**/.venv"]), - ], - ) -> "Marimo": - return cls( - src=src, - frontend=Frontend(src=src), - backend=Backend(src=src), - cli=Cli(src=src), - env=Env(), - ) - - @function - def make( - self, - task: Annotated[str, Doc("The make task to run")], - ) -> dagger.Container: - """A container that runs a make task.""" - return ( - self.env.dev() - .with_directory("/src", self.src) - .with_workdir("/src") - .with_exec(["make", "install-all"]) - .with_exec(["make", task]) - ) diff --git a/development_docs/adding_backend_and_mcp_tools.md b/development_docs/adding_backend_and_mcp_tools.md new file mode 100644 index 00000000000..8e604fb5dd7 --- /dev/null +++ b/development_docs/adding_backend_and_mcp_tools.md @@ -0,0 +1,702 @@ + +# Adding Backend and MCP Tools to marimo + +This guide explains how to create tools that are accessible via both the backend (chat panel) and MCP (Model Context Protocol) server endpoints. + +## Overview + +marimo provides a unified framework for creating tools that can be used by AI assistants to interact with notebooks. These tools are automatically registered in both: + +1. **Backend Tools**: Used by the marimo chat panel (ask/agent modes) +2. **MCP Tools**: Exposed via the MCP server endpoint for external AI clients (like Claude Desktop) + +The unified architecture means you write a tool once and it works in both contexts. + +## Step-by-Step Implementation + +### 1. Create the Tool File + +Create a new file in `marimo/_ai/_tools/tools/your_tool.py` for your tool implementation. + +### 2. Define Input and Output Types + +Create dataclasses for your tool's arguments and output. Place these at the top of your tool file. + +**Template:** + +--- +```python +from dataclasses import dataclass, field +from marimo._ai._tools.types import SuccessResult +from marimo._types.ids import SessionId + + +@dataclass +class YourToolArgs: + """Arguments for your tool.""" + session_id: SessionId + # Add other required parameters + optional_param: str = "default_value" + + +@dataclass +class YourToolOutput(SuccessResult): + """Output from your tool.""" + # Add your output fields + data: dict = field(default_factory=dict) + count: int = 0 +``` +--- + +**Important Type Patterns:** + +- **Naming Convention**: Input dataclasses must end with `Args`, output dataclasses must end with `Output` +- Input args should use plain dataclasses +- Output should inherit from `SuccessResult` (provides `status`, `next_steps`, `message`, etc.) +- Use `field(default_factory=...)` for mutable defaults (lists, dicts) +- Use marimo types like `SessionId`, `CellId_t` for consistency + +### 3. Create the Tool Class + +Implement your tool class in the same file: + +**Template:** + +--- +```python +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from marimo._ai._tools.base import ToolBase +from marimo._ai._tools.types import SuccessResult, ToolGuidelines +from marimo._ai._tools.utils.exceptions import ToolExecutionError +from marimo._types.ids import SessionId + +if TYPE_CHECKING: + from marimo._session import Session + + +@dataclass +class YourToolArgs: + """Arguments for your tool.""" + session_id: SessionId + # Add parameters here + + +@dataclass +class YourToolOutput(SuccessResult): + """Output from your tool.""" + # Add output fields here + sample_dict: dict = field(default_factory=dict) + + +class YourTool(ToolBase[YourToolArgs, YourToolOutput]): + """Brief description of what this tool does. + + More detailed explanation of the tool's purpose and functionality. + This docstring becomes the tool's description shown to AI assistants. + + Args: + session_id: The session ID of the notebook + # Document other args + + Returns: + A success result containing [describe what it returns]. + """ + + guidelines = ToolGuidelines( + when_to_use=[ + "When [describe primary use case]", + ], + prerequisites=[ + "You must [describe args that need additional explanation]", + ], + avoid_if=[ + "When [describe when not to use]", + ], + additional_info=( + "Any additional context or warnings about tool usage." + ), + ) + + def handle(self, args: YourToolArgs) -> YourToolOutput: + """Implement your tool logic here.""" + # ToolContext provides access to sessions, notebooks, and all marimo state + context = self.context + session_id = args.session_id + session = context.get_session(session_id) + + # Implement your logic + sample_dict = self._do_work(session) + + return YourToolOutput( + sample_dict=sample_dict, + next_steps=[ + "Review the results", + "Consider next actions", + ], + message="Optionally add this is the results require more explanation", + ) + + # Helper methods (prefix with _) + def _do_work(self, session: Session) -> dict: + """Private helper method.""" + # Implementation details + return {} +``` +--- + +### 4. Understanding ToolContext + +`ToolContext` is your gateway to all marimo state—sessions, notebooks, cells, errors, and more. It's available via `self.context` in your tool. + +#### How to access ToolContext in your tool + +Access via `self.context` in your `handle()` method: + +--- +```python +def handle(self, args: YourToolArgs) -> YourToolOutput: + # Access ToolContext + context = self.context + + # Use context methods + session = context.get_session(args.session_id) + errors = context.get_notebook_errors(args.session_id) +``` +--- + +#### When to add to ToolContext vs a helper method + +**Add to ToolContext when:** +- The functionality will be used by **multiple tools** +- It accesses core marimo state (sessions, cells, errors) +- It provides a common pattern that should be consistent across tools + +**Use helper methods when:** +- The logic is specific to **your tool only** +- It's a one-off data transformation or validation +- It doesn't need to access marimo state beyond what you already have + +**Example:** + +--- +```python +class YourTool(ToolBase[YourToolArgs, YourToolOutput]): + def handle(self, args: YourToolArgs) -> YourToolOutput: + # Use ToolContext for common operations + session = self.context.get_session(args.session_id) + errors = self.context.get_notebook_errors(args.session_id) + + # Use helper methods for tool-specific logic + filtered_data = self._filter_by_criteria(errors, args.criteria) + + return YourToolOutput(data=filtered_data) + + def _filter_by_criteria(self, errors: list, criteria: str) -> list: + """Tool-specific logic as a helper method.""" + return [e for e in errors if criteria in e.message] +``` +--- + +#### Available ToolContext Methods + +For the current and complete list of available methods, see `marimo/_ai/_tools/base.py` in the `ToolContext` class. Common methods include: +- `get_session(session_id)` - Get a notebook session +- `get_notebook_errors(session_id, include_stderr)` - Get all errors in a notebook +- `get_cell_errors(session_id, cell_id)` - Get errors for a specific cell +- `get_active_sessions_internal()` - Get list of active notebook sessions + +### 5. Understanding ToolGuidelines + +`ToolGuidelines` help AI assistants understand when and how to use your tool. Customize based on your tool's specific use case. + +**Fields:** + +- **`when_to_use`**: List specific scenarios where your tool is appropriate + - Example: `"When the user needs to inspect cell outputs"` + +- **`avoid_if`**: List scenarios where your tool should NOT be used + - Example: `"When the session hasn't been started yet"` + +- **`prerequisites`**: Required state or information before using the tool + - Example: `"Valid session ID from an active notebook"` (only if accessing notebook data) + +- **`side_effects`**: Any state changes your tool makes + - Example: `"Modifies notebook cells"`, `"Triggers cell re-execution"` + +- **`additional_info`**: Additional context or warnings (single string) + - Example: `"This tool provides static analysis only"` + +**⚠️ Warning:** Too many guidelines can confuse the AI agent. Less is more—only add guidelines when you clearly understand the use cases. If you're unsure, keep it minimal + +### 6. Error Handling + +#### When to Use Try/Except + +**Only use try/except when you need to catch a specific error and provide tailored guidance to the AI agent.** + +- ✅ **Use try/except**: For expected errors where you want to guide the agent (e.g., "Use get_lightweight_cell_map to find valid cell IDs") +- ❌ **Don't use try/except**: For unexpected errors—they're automatically wrapped in `ToolExecutionError` and surfaced to the agent + +#### Using ToolExecutionError + +Use `ToolExecutionError` for expected failures: + +--- +```python +from marimo._ai._tools.utils.exceptions import ToolExecutionError + +# Raise structured errors +raise ToolExecutionError( + "Clear description of what went wrong", + code="ERROR_CODE", # Machine-readable code + is_retryable=True, # Can the user retry? + suggested_fix="How to fix the issue", # User-friendly guidance + meta={"session_id": session_id}, # Additional context +) +``` +--- + +**Common Error Codes:** + +- `SESSION_NOT_FOUND`: Session ID doesn't exist +- `CELL_NOT_FOUND`: Cell ID doesn't exist +- `BAD_ARGUMENTS`: Invalid arguments passed +- `OPERATION_FAILED`: Generic operation failure +- `UNEXPECTED_ERROR`: Uncaught exception (handled automatically) + +**Error Handling Best Practices:** + +--- +```python +def handle(self, args: YourToolArgs) -> YourToolOutput: + # ToolContext methods automatically raise ToolExecutionError if session not found + session = self.context.get_session(args.session_id) + + # Validate inputs - raise ToolExecutionError directly for validation errors + if args.count < 0: + raise ToolExecutionError( + "Count must be non-negative", + code="INVALID_COUNT", + is_retryable=False, + suggested_fix="Provide a count >= 0", + ) + + # Only use try/except for specific expected errors where you want to guide the agent + try: + result = self._operation_that_might_fail() + except ValueError as e: + # Caught specific error - provide tailored guidance + raise ToolExecutionError( + f"Invalid cell ID: {e}", + code="INVALID_CELL_ID", + is_retryable=False, + suggested_fix="Use get_lightweight_cell_map to find valid cell IDs", + ) + + # Don't wrap everything in try/except - unexpected errors are handled automatically + return YourToolOutput(data=result) +``` +--- + +### 7. Register the Tool + +Add your tool to the registry in `marimo/_ai/_tools/tools_registry.py`: + +--- +```python +from marimo._ai._tools.tools.your_tool import YourTool + +SUPPORTED_BACKEND_AND_MCP_TOOLS: list[type[ToolBase[Any, Any]]] = [ + GetMarimoRules, + GetActiveNotebooks, + # ... existing tools ... + YourTool, # Add your tool here +] +``` +--- + +**That's it!** Your tool is now automatically registered in both backend and MCP contexts. + +### 8. Add Args and Output to msgspec tests + +Add your tool's Args and Output classes to the `TOOL_IO_CLASSES` list in `tests/_utils/test_msgspec_basestruct.py`. This ensures type compatibility between our serialization system and pydantic (used by the python mcp sdk). + +--- +```python +from marimo._ai._tools.tools.your_tool import ( + YourToolArgs, + YourToolOutput, +) + +TOOL_IO_CLASSES = [ + # ... existing classes ... + YourToolArgs, + YourToolOutput, +] +``` +--- + +### 9. Create Tests + +#### Unit Tests + +Create `tests/_ai/tools/tools/test_your_tool.py`: + +--- +```python +from __future__ import annotations + +from unittest.mock import Mock + +import pytest + +from marimo._ai._tools.base import ToolContext +from marimo._ai._tools.tools.your_tool import ( + YourTool, + YourToolArgs, +) +from marimo._ai._tools.utils.exceptions import ToolExecutionError +from marimo._types.ids import SessionId + + +@pytest.fixture +def tool() -> YourTool: + """Create a YourTool instance.""" + return YourTool(ToolContext()) + + +@pytest.fixture +def mock_context() -> Mock: + """Create a mock ToolContext.""" + return Mock(spec=ToolContext) + + +def test_your_tool_basic_case(mock_context: Mock) -> None: + """Test basic functionality.""" + # Setup mock + mock_session = Mock() + mock_context.get_session.return_value = mock_session + + tool = YourTool(ToolContext()) + tool.context = mock_context + + # Execute tool + result = tool.handle(YourToolArgs(session_id=SessionId("test"))) + + # Assertions + assert result.status == "success" + assert result.data is not None + + +def test_your_tool_error_handling(mock_context: Mock) -> None: + """Test error handling.""" + # Setup mock to raise error + mock_context.get_session.side_effect = ToolExecutionError( + "Session not found", + code="SESSION_NOT_FOUND", + ) + + tool = YourTool(ToolContext()) + tool.context = mock_context + + # Should raise ToolExecutionError + with pytest.raises(ToolExecutionError) as exc_info: + tool.handle(YourToolArgs(session_id=SessionId("invalid"))) + + assert exc_info.value.code == "SESSION_NOT_FOUND" + +# if necessary +def test_your_tool_with_edge_cases(mock_context: Mock) -> None: + """Test edge cases and boundary conditions.""" + # Test your tool with edge cases + pass +``` +--- + +### 10. Run Tests + +Run tests: + +--- +```bash +# Run all tool tests +uv run --python 3.12 --group test pytest tests/_ai/tools + +# Run your specific test +uv run --python 3.12 --group test pytest tests/_ai/tools/tools/test_your_tool.py + +# Run with verbose output +uv run --python 3.12 --group test pytest tests/_ai/tools/tools/test_your_tool.py -v +``` +--- + +### 11. Update Documentation + +Add your tool to the user-facing documentation in `docs/guides/editor_features/tools.md`. Add a row to the appropriate category table: + +--- +```markdown +## Available tools + +### [Appropriate Category] + +| Tool | Description | +|------|-------------| +| **your_tool_name** | Brief description of what the tool does. Takes `param1` and `param2` parameters. Returns description of output. | +``` +--- + +Choose the appropriate category: +- **Inspection**: Tools for exploring notebook structure and runtime +- **Data**: Tools for accessing variables and database information +- **Debugging**: Tools for finding and fixing issues +- **Reference**: Tools for accessing marimo documentation + +## Best Practices + +### Type Safety + +- **Use dataclasses** for all input/output types +- **Add type hints** for all methods and attributes +- **Use TYPE_CHECKING** for imports only needed for type checking +- **Import from marimo types** (`SessionId`, `CellId_t`, etc.) +- **Keep types in your tool file** unless they're used by multiple tools—only add to `marimo/_ai/_tools/types.py` if shared across many files + +### Documentation + +- **Write clear docstrings** following the template +- **Document all Args** in the class docstring +- **Describe Returns** in the class docstring +- **Provide ToolGuidelines** to help AI assistants +- **Include examples** in docstrings when helpful + +### Output Design + +Design helpful outputs: + +--- +```python +return YourToolOutput( + data=result, + # Provide actionable next steps + next_steps=[ + "Use get_cell_runtime_data to inspect cells", + "Check errors with get_notebook_errors", + ], + # Optional user-facing message + message="Found 5 items matching your query", + # Optional metadata + meta={"query_time": 0.5}, +) +``` +--- + +### Helper Methods + +- **Prefix private methods with `_`** +- **Keep handle() method focused** on orchestration +- **Extract complex logic** into helper methods +- **Reuse ToolContext methods** instead of duplicating logic + + +## Common Pitfalls + +### ❌ Don't: Duplicate ToolContext Logic + +--- +```python +# Bad: Reimplementing context logic +def handle(self, args: Args) -> Output: + session = self.context.get_session(args.session_id) + cell_notifications = session.session_view.cell_notifications + errors = [] + for cell_id, op in cell_notifications.items(): + if op.output and op.output.channel == CellChannel.MARIMO_ERROR: + errors.append(...) # Duplicating error extraction +``` +--- + +### ✅ Do: Use ToolContext Methods + +--- +```python +# Good: Using context methods +def handle(self, args: Args) -> Output: + errors = self.context.get_notebook_errors( + args.session_id, + include_stderr=True + ) +``` +--- + +### ❌ Don't: Raise Generic Exceptions + +--- +```python +# Bad: Using generic exceptions +if not found: + raise ValueError("Not found") +``` +--- + +### ✅ Do: Raise ToolExecutionError + +--- +```python +# Good: Structured error with metadata +if not found: + raise ToolExecutionError( + "Cell not found in session", + code="CELL_NOT_FOUND", + is_retryable=False, + suggested_fix="Use get_lightweight_cell_map to find valid cell IDs", + ) +``` +--- + +### ❌ Don't: Return Unstructured Data + +--- +```python +# Bad: Returning raw data +def handle(self, args: Args) -> Output: + return {"data": [...], "count": 5} # type: ignore +``` +--- + +### ✅ Do: Use Typed Dataclass Output + +--- +```python +# Good: Structured output with SuccessResult +def handle(self, args: Args) -> Output: + return YourToolOutput( + data=[...], + count=5, + next_steps=["Review the results"], + ) +``` +--- + +### ❌ Don't: Use TypedDict or Other Type Annotations + +--- +```python +# Bad: Using TypedDict for tool input/output +from typing import TypedDict + +class YourToolArgs(TypedDict): + session_id: str + count: int +``` +--- + +### ✅ Do: Use Dataclasses + +--- +```python +# Good: Using dataclasses as required +from dataclasses import dataclass + +@dataclass +class YourToolArgs: + session_id: SessionId + count: int = 0 +``` +--- + +**Why?** The tool system requires dataclasses for proper serialization, validation, and compatibility with both backend and MCP contexts. + +## Advanced Topics + +### Async Tools + +For operations that need async/await: + +--- +```python +class AsyncTool(ToolBase[Args, Output]): + """Tool with async operations.""" + + async def handle(self, args: Args) -> Output: # type: ignore[override] + """Note: Add type: ignore[override] for async handle.""" + session = self.context.get_session(args.session_id) + result = await self._async_work(session) + return Output(result=result) +``` +--- + +### Tools with Side Effects + +Generally it's better to avoid side effects in your tool. If it can't be avoided make sure to document side effects in guidelines: + +--- +```python +guidelines = ToolGuidelines( + side_effects=[ + "Modifies notebook cells", + "Triggers cell re-execution", + ], +) +``` +--- + +### Complex Return Types + +Use nested dataclasses for complex outputs: + +--- +```python +@dataclass +class CellInfo: + cell_id: str + code: str + + +@dataclass +class ComplexOutput(SuccessResult): + cells: list[CellInfo] = field(default_factory=list) + summary: dict[str, Any] = field(default_factory=dict) +``` +--- + +## Review Checklist + +Before submitting your tool: + +- [ ] Tool class inherits from `ToolBase[ArgsT, OutT]` +- [ ] Input args are dataclasses ending with `Args` +- [ ] Output inherits from `SuccessResult` and ends with `Output` +- [ ] `handle()` method is implemented +- [ ] Tool is registered in `tools_registry.py` +- [ ] Args and Output added to `TOOL_IO_CLASSES` in `tests/_utils/test_msgspec_basestruct.py` +- [ ] Comprehensive docstring with Args/Returns +- [ ] `ToolGuidelines` provided (only if use cases are clear) +- [ ] Error handling uses `ToolExecutionError` for expected failures only +- [ ] Unit tests cover happy path and errors +- [ ] Tests mock `ToolContext` appropriately +- [ ] All tests pass +- [ ] Type hints are complete +- [ ] Documentation updated in `docs/guides/editor_features/tools.md` + +## Additional Resources + +- **Base Tool Class**: `marimo/_ai/_tools/base.py` +- **Tool Context**: `marimo/_ai/_tools/base.py` (`ToolContext`) +- **Exception Handling**: `marimo/_ai/_tools/utils/exceptions.py` +- **Type Definitions**: `marimo/_ai/_tools/types.py` +- **MCP Server Setup**: `marimo/_mcp/server/main.py` +- **Backend Tool Manager**: `marimo/_server/ai/tools/tool_manager.py` + +## Questions? + +If you have questions or run into issues: + +1. Check existing tools in `marimo/_ai/_tools/tools/` for examples +2. Review tests in `tests/_ai/tools/tools/` for testing patterns +3. Ask in the marimo community channels diff --git a/development_docs/adding_lint_rules.md b/development_docs/adding_lint_rules.md new file mode 100644 index 00000000000..de50b605fca --- /dev/null +++ b/development_docs/adding_lint_rules.md @@ -0,0 +1,434 @@ +# Adding Lint Rules to marimo + +This guide explains how to add new lint rules to marimo's linting system. + +## Overview + +marimo's lint system helps users write better, more reliable notebooks by detecting various issues that could prevent notebooks from running correctly. The system is organized around four severity levels: + +- **Breaking (MB)**: Errors that prevent notebook execution +- **Runtime (MR)**: Issues that may cause runtime problems +- **Formatting (MF)**: Style and formatting issues +- **WASM (MW)**: Compatibility issues for WASM/Pyodide notebooks *(off by default)* + +## Rule Code Assignment + +Rule codes follow a specific pattern: `M[severity][number]` + +### Current Code Ranges + +- **MB001-MB099**: Breaking rules +- **MR001-MR099**: Runtime rules +- **MF001-MF099**: Formatting rules +- **MW001-MW099**: WASM compatibility rules *(off by default)* + +### Assigning New Codes + +When adding a new rule: + +1. **Determine severity**: Choose Breaking, Runtime, or Formatting based on impact +2. **Find next available code**: Check existing rules in the appropriate category +3. **Use sequential numbering**: MB005, MB006, etc. + +**Example assignments**: +- MB001: unparsable-cells +- MB002: multiple-definitions +- MB003: cycle-dependencies +- MB004: setup-cell-dependencies +- MF001: general-formatting +- MF002: parse-stdout +- MF003: parse-stderr + +## Step-by-Step Implementation + +### 1. Create the Rule Class + +Create your rule in the appropriate directory: +- Breaking rules: `marimo/_lint/rules/breaking/` +- Runtime rules: `marimo/_lint/rules/runtime/` +- Formatting rules: `marimo/_lint/rules/formatting/` +- WASM rules: `marimo/_lint/rules/wasm/` + +**Template for a new rule**: + +```python +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from typing import TYPE_CHECKING + +from marimo._lint.diagnostic import Diagnostic, Severity +from marimo._lint.rules.base import LintRule + +if TYPE_CHECKING: + from marimo._lint.context import RuleContext + + +class YourNewRule(LintRule): + """MB005: Brief description of what this rule checks. + + Detailed explanation of what this rule does and why it's important. + This should explain the technical details of how the rule works. + + ## What it does + + Clear, concise explanation of what the rule detects. + + ## Why is this bad? + + Explanation of why this issue is problematic: + - Impact on notebook execution + - Potential for bugs or confusion + - Effect on reproducibility + + ## Examples + + **Problematic:** + ```python + # Example of code that violates this rule + bad_code = "example" + ``` + + **Solution:** + ```python + # Example of how to fix the violation + good_code = "example" + ``` + + ## References + + - [Understanding Errors](https://docs.marimo.io/guides/understanding_errors/) + - [Relevant Guide](https://docs.marimo.io/guides/...) + """ + + code = "MB005" # Your assigned code + name = "your-rule-name" # Kebab-case name + description = "Brief description for rule listings" + severity = Severity.BREAKING # Or RUNTIME/FORMATTING + fixable = False # True if rule can auto-fix issues + + async def check(self, ctx: RuleContext) -> None: + """Implement your rule logic here.""" + # Iterate through notebook cells + for cell in ctx.notebook.cells: + # Your detection logic here + if self._detect_violation(cell): + diagnostic = Diagnostic( + message="Description of the specific violation", + line=cell.lineno, + column=cell.col_offset + 1, + code=self.code, + name=self.name, + severity=self.severity, + fixable=self.fixable, + ) + await ctx.add_diagnostic(diagnostic) + + def _detect_violation(self, cell) -> bool: + """Helper method for detection logic.""" + # Implement your specific detection logic + return False +``` + +### 2. Register the Rule + +Add your rule to the appropriate `__init__.py` file: + +**For Breaking rules** (`marimo/_lint/rules/breaking/__init__.py`): + +```python +from marimo._lint.rules.breaking.your_file import YourNewRule + +BREAKING_RULE_CODES: dict[str, type[LintRule]] = { + "MB001": UnparsableRule, + "MB002": MultipleDefinitionsRule, + "MB003": CycleDependenciesRule, + "MB004": SetupCellDependenciesRule, + "MB005": YourNewRule, # Add your rule here +} + +__all__ = [ + # ... existing rules ... + "YourNewRule", # Add to exports + "BREAKING_RULE_CODES", +] +``` + +### 3. Create Test Files + +#### a) Create a test notebook file + +Create `tests/_lint/test_files/your_rule_name.py`: + +```python +import marimo + +__generated_with = "0.15.2" +app = marimo.App() + + +@app.cell +def _(): + # Code that should trigger your rule + problematic_code = "example" + return + + +@app.cell +def _(): + # Additional test cases + return + + +if __name__ == "__main__": + app.run() +``` + +#### b) Add snapshot test + +Add to `tests/_lint/test_snapshot.py`: + +```python +def test_your_rule_snapshot(): + """Test snapshot for your new rule.""" + file = "tests/_lint/test_files/your_rule_name.py" + with open(file) as f: + code = f.read() + + notebook = parse_notebook(code, filepath=file) + errors = lint_notebook(notebook) + + # Format errors for snapshot + error_output = [] + for error in errors: + error_output.append(error.format()) + + snapshot("your_rule_name_errors.txt", "\n".join(error_output)) +``` + +#### c) Add unit tests (optional but recommended) + +For more rigorous testing, create `tests/_lint/test_your_rule.py`: + +```python +import pytest +from marimo._ast.parse import parse_notebook +from marimo._lint.context import LintContext +from marimo._lint.rules.breaking import YourNewRule + + +class TestYourNewRule: + """Test cases for YourNewRule.""" + + async def test_detects_violation(self): + """Test that the rule detects violations correctly.""" + code = """import marimo +app = marimo.App() + +@app.cell +def _(): + # Code that should trigger the rule + return +""" + notebook = parse_notebook(code) + ctx = LintContext(notebook) + + rule = YourNewRule() + await rule.check(ctx) + + diagnostics = await ctx.get_diagnostics() + assert len(diagnostics) > 0 + assert diagnostics[0].code == "MB005" + assert diagnostics[0].severity == Severity.BREAKING + + async def test_no_false_positives(self): + """Test that the rule doesn't trigger on valid code.""" + code = """import marimo +app = marimo.App() + +@app.cell +def _(): + # Valid code that should not trigger the rule + return +""" + notebook = parse_notebook(code) + ctx = LintContext(notebook) + + rule = YourNewRule() + await rule.check(ctx) + + diagnostics = await ctx.get_diagnostics() + assert len(diagnostics) == 0 +``` + +### 4. Generate Documentation + +The documentation is automatically generated from your rule's docstring. Run: + +```bash +uv run scripts/generate_lint_docs.py +``` + +This will create: +- `docs/guides/lint_rules/rules/your_rule_name.md` +- Updated `docs/guides/lint_rules/index.md` + +### 5. Run Tests + +```bash +# Run lint tests +uv run --group test pytest tests/_lint + +# Run your specific test +uv run --group test pytest tests/_lint/test_your_rule.py + +# Update snapshots if needed +uv run --group test pytest tests/_lint/test_snapshots.py --snapshot-update +``` + +## Rule Implementation Guidelines + +### Detection Logic + +- **Prefer AST analysis** over string matching when possible +- **Use context information** from `RuleContext` (notebook, graph, etc.) +- **Be specific** in error messages - help users understand the exact issue +- **Consider edge cases** - test with various code patterns + +### Error Messages + +- **Be descriptive**: Explain what the issue is +- **Be actionable**: Suggest how to fix it +- **Be consistent**: Follow patterns from existing rules + +Good: `"Variable 'x' is defined in multiple cells"` +Bad: `"Multiple definition error"` + +### Performance + +- **Avoid expensive operations** in the hot path +- **Cache results** when checking multiple cells (add to context if needed) +- **Early return** when possible + +### Fixability + +Safely fixable errors are applied by default via re-serialization. However, +rules may implement unsafe fixes (mutating the notebook structure) that require +the `--unsafe-fixes` flag. To do this, implement an `async def +apply_unsafe_fixes(self, notebook, diagnostics) -> Notebook` method in your +rule class, and inherit from `UnsafeFixRule` instead of `LintRule`. + +## Default vs Opt-in Rules + +Rules in `RULE_CODES` are the full set of all rules. Rules in `DEFAULT_RULE_CODES` are those enabled when no `--select` is specified. + +To make a category **off by default** (like WASM rules), include it in `RULE_CODES` but exclude it from `DEFAULT_RULE_CODES` in `marimo/_lint/rules/__init__.py`: + +```python +# Rules enabled by default (excludes opt-in categories like WASM). +DEFAULT_RULE_CODES: dict[str, type[LintRule]] = ( + BREAKING_RULE_CODES | RUNTIME_RULE_CODES | FORMATTING_RULE_CODES +) + +# All known rules (including opt-in). Used when --select is provided. +RULE_CODES: dict[str, type[LintRule]] = DEFAULT_RULE_CODES | WASM_RULE_CODES +``` + +Users opt in via `marimo check --select MW` or `--select ALL`. The `resolve_rules()` function in `rule_selector.py` uses `DEFAULT_RULE_CODES` when no `select` is specified, and `RULE_CODES` when it is. + +## Common Patterns + +### Checking All Cells + +```python +async def check(self, ctx: RuleContext) -> None: + for cell in ctx.notebook.cells: + if self._check_cell(cell): + # Create diagnostic +``` + +### Using the Dependency Graph + +```python +async def check(self, ctx: RuleContext) -> None: + graph = ctx.get_graph() + for cell_id, cell_data in graph.cells.items(): + # Analyze dependencies +``` + +## Testing Best Practices + +### Snapshot Tests + +- **Include in snapshot tests** for regression protection +- **Use realistic examples** that demonstrate the rule clearly +- **Test edge cases** in separate unit tests + +### Unit Tests + +- **Test positive cases** (rule triggers correctly) +- **Test negative cases** (no false positives) +- **Test edge cases** (empty cells, syntax errors, etc.) +- **Test multiple violations** in one notebook + +### Test File Structure + +``` +tests/_lint/ +├── test_files/ # Test notebooks +│ └── your_rule_name.py +├── snapshots/ # Expected outputs +│ └── your_rule_name_errors.txt +├── test_your_rule.py # Unit tests +└── test_snapshots.py # Snapshot tests +``` + +## Documentation Requirements + +Your rule's docstring should include: + +1. **Rule code and brief description** in the first line +2. **## What it does** - Technical explanation +3. **## Why is this bad?** - Impact explanation +4. **## Examples** - Code samples (problematic and fixed) +5. **## References** - Links to relevant documentation + +The documentation system will automatically: +- Generate individual rule pages +- Update the main rules index +- Create proper navigation links +- Use human-readable filenames + +## Review Checklist + +Before submitting your rule: + +- [ ] Rule code follows numbering convention +- [ ] Rule is registered in appropriate `__init__.py` +- [ ] Comprehensive docstring with all required sections +- [ ] Unit tests cover positive and negative cases +- [ ] Snapshot test included +- [ ] Documentation generates correctly +- [ ] All lint tests pass +- [ ] Error messages are clear and actionable +- [ ] Performance is reasonable for large notebooks + +## Examples + +### Simple Rule Implementation + +Example of a simple rule that checks for syntax errors: https://github.com/marimo-team/marimo/pull/6384 + +### Rule Implementation with `--unsafe-fixes` + +Some rules may have "fixes" that mutate the notebook structure. +An example of a rule that mutates the notebook structure (i.e. an unsafe fix) by removing empty cells: https://github.com/marimo-team/marimo/pull/6398 + +### Rule Implementation with Log Context + +Some parsing issues result in log warnings or errors. +An example of a rule that hooks into issued logs can be found here: + +**Note**: Resist intentionally adding log statements such that they trigger +lint rules. Log statements should be added only when they provide useful +context, on notebook startup. diff --git a/development_docs/openapi.md b/development_docs/openapi.md index 63c1f4fae2f..50b7386d3c3 100644 --- a/development_docs/openapi.md +++ b/development_docs/openapi.md @@ -1,5 +1,7 @@ # marimo + openapi +The frontend of marimo communicates with the backend through an OpenAPI specification, which can be found in [`packages/openapi/api.yaml`](/packages/openapi/api.yaml). + ## Printing the OpenAPI schema ```bash @@ -8,8 +10,10 @@ marimo development openapi ## Writing a new OpenAPI schema +To modify the schema, add the type under `_generate_server_api_schema` in [`commands.py`](/marimo/_cli/development/commands.py) and run + ```bash -marimo development openapi > openapi/api.yaml +marimo development openapi > packages/openapi/api.yaml ``` ## Validating an OpenAPI schema @@ -24,3 +28,10 @@ marimo development openapi | openapi-spec-validator - ```bash make fe-codegen ``` + +You will then need to reinstall the package in `/frontend`: + +```bash +cd frontend +pnpm update @marimo-team/marimo-api +``` diff --git a/development_docs/prompts.md b/development_docs/prompts.md index 6e22001cc3d..a9f1d4723d8 100644 --- a/development_docs/prompts.md +++ b/development_docs/prompts.md @@ -9,5 +9,5 @@ Prompts are generated using the `Prompter` class in `marimo/_server/ai/prompts.p You can create snapshots of the prompts using the `snapshot` function in `tests/_server/ai/test_prompts.py`. These snapshots are checked into the repo, and you can use them to verify that the prompts have not changed. To run these snapshots, run: ```bash -hatch run +py=3.12 test-optional:test tests/_server/ai/test_prompts.py +uv run --python 3.12 --group test-optional pytest tests/_server/ai/test_prompts.py ``` diff --git a/development_docs/pyodide.md b/development_docs/pyodide.md index 333693ccf60..5894af8811e 100644 --- a/development_docs/pyodide.md +++ b/development_docs/pyodide.md @@ -4,17 +4,17 @@ ```bash cd frontend -PYODIDE=true pnpm dev +PYODIDE=true VITE_WASM_MARIMO_PREBUILT_WHEEL=true pnpm dev ``` ## Running the frontend against a local backend ```bash # build once -hatch build +uv build # server and watch for changes uv run pyodide/build_and_serve.py # in another terminal cd frontend -PYODIDE=true VITE_MARIMO_VERSION=$(marimo --version) pnpm dev +PYODIDE=true pnpm dev ``` diff --git a/development_docs/testing.md b/development_docs/testing.md new file mode 100644 index 00000000000..f1ccd6d871b --- /dev/null +++ b/development_docs/testing.md @@ -0,0 +1,42 @@ +# Testing + +## Directory Structure + +- **Snapshots**: Store in `snapshots/` directory next to your test file +- **Fixtures**: Store test input files in `fixtures/` directory next to your test file + +``` +tests/_convert/ipynb/ +├── test_ipynb_converter.py +├── snapshots/ # Expected outputs +└── fixtures/ # Test inputs +``` + +## Snapshot Testing + +```python +from tests.mocks import snapshotter + +snapshot = snapshotter(__file__) +snapshot("output.py.txt", result) # Auto-creates/compares snapshot +``` + +## Kernel Fixtures + +Available in `tests/conftest.py`: + +- `k` - Default kernel (autorun, relaxed) +- `strict_kernel` - Strict execution mode +- `lazy_kernel` - Lazy execution mode +- `run_mode_kernel` - RUN mode (not EDIT) +- `mocked_kernel` - Full MockedKernel wrapper +- `executing_kernel` - Execution context installed +- `any_kernel` - Parametrized: runs test 3x (k, strict, lazy) +- `execution_kernel` - Parametrized: runs test 2x (k, strict) + +## Running Tests + +```bash +uv run --python 3.12 --group test pytest tests/path/to/test.py +uv run --python 3.12 --group test-optional pytest tests/path/to/test.py # with optional deps +``` diff --git a/development_docs/traces.md b/development_docs/traces.md index 471904422e4..cc594cda0e3 100644 --- a/development_docs/traces.md +++ b/development_docs/traces.md @@ -2,22 +2,99 @@ ## Server traces -For debugging purposes, we emit OpenTelemetry traces from the server. We emit traces to `~/.marimo/traces/spans.jsonl`. We don't emit any sensitive information in the traces, and these traces stay local to your machine. The traces get wiped on each sever restart. +For debugging purposes, we emit OpenTelemetry traces from the server. By +default, traces are written to a local JSONL file. When an OTLP endpoint is +configured, traces are exported via OTLP instead, letting marimo participate in +distributed tracing stacks such as Jaeger, Grafana Tempo, or GCP Cloud Trace. -You can analyze the traces using tools like Jaeger or Zipkin, or our marimo notebook: +### Prerequisites + +Tracing requires the `otel` extra (or a development install, which includes +the same packages): ```bash -marimo edit scripts/analyze_traces.py +pip install "marimo[otel]" ``` ### Enable Traces -To enable traces, set the `MARIMO_TRACING` environment variable to `true`: +Set `MARIMO_TRACING=true` to turn tracing on: + +```bash +MARIMO_TRACING=true marimo run notebook.py +``` + +### Local file export (default) + +With no additional configuration, spans are written to +`~/.marimo/traces/spans.jsonl` (the exact path depends on your platform's +XDG state directory). The file is cleared on each server restart and never +leaves your machine. + +You can analyze local traces with Jaeger, Zipkin, or the bundled notebook: + +```bash +marimo edit scripts/analyze_traces.py +``` + +### OTLP export + +To export traces to a remote collector, set the standard OpenTelemetry +environment variables: + +```bash +MARIMO_TRACING=true \ +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \ +OTEL_SERVICE_NAME=marimo \ +marimo run notebook.py +``` + +| Variable | Purpose | Default | +|---|---|---| +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Endpoint of an OTLP collector for all signals | _(unset — file export)_ | +| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Trace-specific OTLP endpoint; takes precedence over `OTEL_EXPORTER_OTLP_ENDPOINT` | _(unset)_ | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP protocol for all signals: `http/protobuf` or `grpc` | `http/protobuf` | +| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | Trace-specific OTLP protocol; takes precedence over `OTEL_EXPORTER_OTLP_PROTOCOL` | _(unset)_ | +| `OTEL_EXPORTER_OTLP_HEADERS` | Comma-separated `key=value` auth or routing headers for OTLP export | _(empty)_ | +| `OTEL_SERVICE_NAME` | `service.name` resource attribute | `marimo` | +| `OTEL_RESOURCE_ATTRIBUTES` | Comma-separated `key=value` pairs added to the resource | _(empty)_ | + +With the default `http/protobuf` protocol, a generic +`OTEL_EXPORTER_OTLP_ENDPOINT` is treated by the OpenTelemetry exporter as the +collector base URL and traces are sent to `/v1/traces`. For example, +`http://localhost:4318` exports traces to `http://localhost:4318/v1/traces`. +If you set `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, include the full traces path, +for example `http://localhost:4318/v1/traces`. + +For gRPC collectors, set the protocol explicitly: ```bash -MARIMO_TRACING=true ./your_server_command +MARIMO_TRACING=true \ +OTEL_EXPORTER_OTLP_PROTOCOL=grpc \ +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \ +marimo run notebook.py ``` +If the selected OTLP exporter package is not installed, or the configured +protocol is unsupported, marimo logs a warning and falls back to the local file +exporter. + +### Distributed trace propagation + +The `OpenTelemetryMiddleware` extracts incoming W3C `traceparent` headers, so +when another service calls marimo (e.g., via the MCP HTTP endpoint), the +resulting spans are linked as children of the caller's trace. No extra +configuration is needed — propagation works automatically whenever tracing is +enabled. + +Propagation also reaches the kernel, which runs in a separate process. The +request headers travel with each control command, and `handle_message` +re-attaches the extracted trace context before dispatching, so kernel spans +(cell execution, dataset previews, function calls, etc.) become children of the +originating request's trace. This lets a single distributed trace span an +upstream app (e.g., a FastAPI gateway instrumented with Logfire), the marimo +server, and the kernel — even when marimo is mounted as an ASGI sub-app. + ## Profiling the kernel You can generate profiling statistics of the kernel in edit mode using the @@ -30,4 +107,10 @@ marimo edit --profile-dir profiles/ notebook.py If the notebook exits gracefully (i.e., is shut down manually), profiling statistics will be written to the profiles/ directory. You can then use standard tools to analyze the dumped statistics. To view flamegraphs, -we recommend snakeviz or tuna (`pip install snakeviz; snakeviz path_to_profile`) +we recommend snakeviz or tuna (`uvx snakeviz path_to_profile`) + +## AI Tracing + +When `MARIMO_TRACING=true` and `pydantic_ai` is installed, marimo automatically calls +`Agent.instrument_all()` at startup so pydantic-ai spans use the same +`TracerProvider`. diff --git a/docker/Dockerfile b/docker/Dockerfile index 7f920ade979..a3765c9a66c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,16 +1,29 @@ -# syntax=docker/dockerfile:1.9 -FROM python:3.12-slim AS base +# syntax=docker/dockerfile:1.12 +FROM python:3.13-slim AS base + +LABEL org.opencontainers.image.source="https://github.com/marimo-team/marimo" +LABEL org.opencontainers.image.description="marimo reactive notebook" + +# Make `uv` and `uvx` available in the PATH for all target images +COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/ # Create a non-root user RUN useradd -m appuser WORKDIR /app -ARG marimo_version=0.8.15 +ARG marimo_version=0.12.8 +LABEL org.opencontainers.image.version="${marimo_version}" + ENV MARIMO_SKIP_UPDATE_CHECK=1 -RUN pip install --no-cache-dir marimo==${marimo_version} && \ +ENV UV_SYSTEM_PYTHON=1 +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +RUN uv pip install --no-cache-dir marimo==${marimo_version} && \ mkdir -p /app/data && \ - chown -R appuser:appuser /app + chown -R appuser:appuser /app && \ + chown -R appuser:appuser /usr/local/lib/python3.13/site-packages/ && \ + chown -R appuser:appuser /usr/local/bin/ COPY --chown=appuser:appuser marimo/_tutorials tutorials RUN rm -rf tutorials/__init__.py @@ -20,16 +33,24 @@ EXPOSE $PORT ENV HOST=0.0.0.0 +STOPSIGNAL SIGTERM + # -slim entry point FROM base AS slim -CMD marimo edit --no-token -p $PORT --host $HOST +USER appuser +CMD ["sh", "-c", "exec marimo edit --no-token -p $PORT --host $HOST"] # -data entry point FROM base AS data -RUN pip install --no-cache-dir altair pandas numpy -CMD marimo edit --no-token -p $PORT --host $HOST +USER appuser +RUN uv pip install --no-cache-dir \ + altair \ + marimo[recommended,lsp] \ + numpy \ + pandas +CMD ["sh", "-c", "exec marimo edit --no-token -p $PORT --host $HOST"] # -sql entry point, extends -data FROM data AS sql -RUN pip install --no-cache-dir marimo[sql] -CMD marimo edit --no-token -p $PORT --host $HOST +RUN uv pip install --no-cache-dir marimo[sql] +CMD ["sh", "-c", "exec marimo edit --no-token -p $PORT --host $HOST"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 00000000000..2c997af99f5 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,23 @@ +# Docker Images + +This directory contains the Dockerfile for building marimo Docker images. + +## Available Images + +- `marimo-slim`: Minimal image with just marimo installed +- `marimo-data`: Includes marimo plus data science packages (pandas, numpy, altair) and marimo[recommended,lsp] +- `marimo-sql`: Extends the data image with SQL support (marimo[recommended,lsp,sql]) + +## Testing locally + +To build all images, from the root + +```bash +# Build your image, and tag it as my_app +docker build -t my_app . -f docker/Dockerfile + +# Start your container, mapping port 8080 +docker run -p 8080:8080 -it my_app + +# Visit http://localhost:8080 +``` diff --git a/docs/404.md b/docs/404.md new file mode 100644 index 00000000000..a270446627c --- /dev/null +++ b/docs/404.md @@ -0,0 +1,21 @@ +--- +title: Page Not Found +description: The page you're looking for doesn't exist. Find what you need in the marimo documentation. +hide: + - navigation + - toc +--- + +# Page Not Found + +The page you're looking for doesn't exist or has been moved. + +**Try one of these instead:** + +- [Getting Started](getting_started/index.md) - Install marimo and learn the basics +- [User Guide](guides/index.md) - In-depth guides on marimo features +- [API Reference](api/index.md) - Complete API documentation +- [Recipes](recipes.md) - Code snippets for common tasks +- [FAQ](faq.md) - Frequently asked questions + +Or use the **search bar** above to find what you're looking for. diff --git a/docs/__init__.py b/docs/__init__.py deleted file mode 100644 index e738d2041eb..00000000000 --- a/docs/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""marimo documentation package.""" - -from . import blocks - -__all__ = ["blocks"] diff --git a/docs/_static/CLAUDE.md b/docs/_static/CLAUDE.md new file mode 100644 index 00000000000..85b078735ed --- /dev/null +++ b/docs/_static/CLAUDE.md @@ -0,0 +1,367 @@ +# Marimo notebook assistant + +I am a specialized AI assistant designed to help create data science notebooks using marimo. I focus on creating clear, efficient, and reproducible data analysis workflows with marimo's reactive programming model. + +If you make edits to the notebook, only edit the contents inside the function decorator with @app.cell. +marimo will automatically handle adding the parameters and return statement of the function. For example, +for each edit, just return: + +``` +@app.cell +def _(): + + return +``` + +## Marimo fundamentals + +Marimo is a reactive notebook that differs from traditional notebooks in key ways: + +- Cells execute automatically when their dependencies change +- Variables cannot be redeclared across cells +- The notebook forms a directed acyclic graph (DAG) +- The last expression in a cell is automatically displayed +- UI elements are reactive and update the notebook automatically + +## Code Requirements + +1. All code must be complete and runnable +2. Follow consistent coding style throughout +3. Include descriptive variable names and helpful comments +4. Import all modules in the first cell, always including `import marimo as mo` +5. Never redeclare variables across cells +6. Ensure no cycles in notebook dependency graph +7. The last expression in a cell is automatically displayed, just like in Jupyter notebooks. +8. Don't include comments in markdown cells +9. Don't include comments in SQL cells +10. Never define anything using `global`. + +## Reactivity + +Marimo's reactivity means: + +- When a variable changes, all cells that use that variable automatically re-execute +- UI elements trigger updates when their values change without explicit callbacks +- UI element values are accessed through `.value` attribute +- You cannot access a UI element's value in the same cell where it's defined +- Cells prefixed with an underscore (e.g. _my_var) are local to the cell and cannot be accessed by other cells + +## Best Practices + + + +- Use polars for data manipulation +- Implement proper data validation +- Handle missing values appropriately +- Use efficient data structures +- A variable in the last expression of a cell is automatically displayed as a table + + + +- For matplotlib: use plt.gca() as the last expression instead of plt.show() +- For plotly: return the figure object directly +- For altair: return the chart object directly. Add tooltips where appropriate. You can pass polars dataframes directly to altair. +- Include proper labels, titles, and color schemes +- Make visualizations interactive where appropriate + + + + +- Access UI element values with .value attribute (e.g., slider.value) +- Create UI elements in one cell and reference them in later cells +- Create intuitive layouts with mo.hstack(), mo.vstack(), and mo.tabs() +- Prefer reactive updates over callbacks (marimo handles reactivity automatically) +- Group related UI elements for better organization + + + +- When writing duckdb, prefer using marimo's SQL cells, which start with df = mo.sql(f"""""") for DuckDB, or df = mo.sql(f"""""", engine=engine) for other SQL engines. +- See the SQL with duckdb example for an example on how to do this +- Don't add comments in cells that use mo.sql() + + +## Troubleshooting + +Common issues and solutions: + +- Circular dependencies: Reorganize code to remove cycles in the dependency graph +- UI element value access: Move access to a separate cell from definition +- Visualization not showing: Ensure the visualization object is the last expression + +After generating a notebook, run `marimo check --fix` to catch and +automatically resolve common formatting issues, and detect common pitfalls. + +## Available UI elements + +- `mo.ui.altair_chart(altair_chart)` +- `mo.ui.button(value=None, kind='primary')` +- `mo.ui.run_button(label=None, tooltip=None, kind='primary')` +- `mo.ui.checkbox(label='', value=False)` +- `mo.ui.date(value=None, label=None, full_width=False)` +- `mo.ui.dropdown(options, value=None, label=None, full_width=False)` +- `mo.ui.file(label='', multiple=False, full_width=False)` +- `mo.ui.number(value=None, label=None, full_width=False)` +- `mo.ui.radio(options, value=None, label=None, full_width=False)` +- `mo.ui.refresh(options: List[str], default_interval: str)` +- `mo.ui.slider(start, stop, value=None, label=None, full_width=False, step=None)` +- `mo.ui.range_slider(start, stop, value=None, label=None, full_width=False, step=None)` +- `mo.ui.table(data, columns=None, on_select=None, sortable=True, filterable=True)` +- `mo.ui.text(value='', label=None, full_width=False)` +- `mo.ui.text_area(value='', label=None, full_width=False)` +- `mo.ui.data_explorer(df)` +- `mo.ui.dataframe(df)` +- `mo.ui.plotly(plotly_figure)` +- `mo.ui.tabs(elements: dict[str, mo.ui.Element])` +- `mo.ui.array(elements: list[mo.ui.Element])` +- `mo.ui.form(element: mo.ui.Element, label='', bordered=True)` + +## Layout and utility functions + +- `mo.md(text)` - display markdown +- `mo.stop(predicate, output=None)` - stop execution conditionally +- `mo.output.append(value)` - append to the output when it is not the last expression +- `mo.output.replace(value)` - replace the output when it is not the last expression +- `mo.Html(html)` - display HTML +- `mo.image(image)` - display an image +- `mo.hstack(elements)` - stack elements horizontally +- `mo.vstack(elements)` - stack elements vertically +- `mo.tabs(elements)` - create a tabbed interface + +## Examples + + +``` +@app.cell +def _(): + mo.md(""" + # Hello world + This is a _markdown_ **cell**. + """) + return +``` + + + +``` +@app.cell +def _(): + import marimo as mo + import altair as alt + import polars as pl + import numpy as np + return + +@app.cell +def _(): + n_points = mo.ui.slider(10, 100, value=50, label="Number of points") + n_points + return + +@app.cell +def _(): + x = np.random.rand(n_points.value) + y = np.random.rand(n_points.value) + + df = pl.DataFrame({"x": x, "y": y}) + + chart = alt.Chart(df).mark_circle(opacity=0.7).encode( + x=alt.X('x', title='X axis'), + y=alt.Y('y', title='Y axis') + ).properties( + title=f"Scatter plot with {n_points.value} points", + width=400, + height=300 + ) + + chart + return + +``` + + + +``` + +@app.cell +def _(): + import marimo as mo + import polars as pl + from vega_datasets import data + return + +@app.cell +def _(): + cars_df = pl.DataFrame(data.cars()) + mo.ui.data_explorer(cars_df) + return + +``` + + + +``` + +@app.cell +def _(): + import marimo as mo + import polars as pl + import altair as alt + return + +@app.cell +def _(): + iris = pl.read_csv("hf://datasets/scikit-learn/iris/Iris.csv") + return + +@app.cell +def _(): + species_selector = mo.ui.dropdown( + options=["All"] + iris["Species"].unique().to_list(), + value="All", + label="Species", + ) + x_feature = mo.ui.dropdown( + options=iris.select(pl.col(pl.Float64, pl.Int64)).columns, + value="SepalLengthCm", + label="X Feature", + ) + y_feature = mo.ui.dropdown( + options=iris.select(pl.col(pl.Float64, pl.Int64)).columns, + value="SepalWidthCm", + label="Y Feature", + ) + mo.hstack([species_selector, x_feature, y_feature]) + return + +@app.cell +def _(): + filtered_data = iris if species_selector.value == "All" else iris.filter(pl.col("Species") == species_selector.value) + + chart = alt.Chart(filtered_data).mark_circle().encode( + x=alt.X(x_feature.value, title=x_feature.value), + y=alt.Y(y_feature.value, title=y_feature.value), + color='Species' + ).properties( + title=f"{y_feature.value} vs {x_feature.value}", + width=500, + height=400 + ) + + chart + return + +``` + + + +``` + +@app.cell +def _(): + mo.stop(not data.value, mo.md("No data to display")) + + if mode.value == "scatter": + mo.output.replace(render_scatter(data.value)) + else: + mo.output.replace(render_bar_chart(data.value)) + return + +``` + + + +``` + +@app.cell +def _(): + import marimo as mo + import altair as alt + import polars as pl + return + +@app.cell +def _(): + # Load dataset + weather = pl.read_csv("") + weather_dates = weather.with_columns( + pl.col("date").str.strptime(pl.Date, format="%Y-%m-%d") + ) + _chart = ( + alt.Chart(weather_dates) + .mark_point() + .encode( + x="date:T", + y="temp_max", + color="location", + ) + ) + return + +@app.cell +def _(): + chart = mo.ui.altair_chart(_chart) +chart + return + +@app.cell +def _(): + # Display the selection + chart.value + return + +``` + + + +``` + +@app.cell +def _(): + import marimo as mo + return + +@app.cell +def _(): + first_button = mo.ui.run_button(label="Option 1") + second_button = mo.ui.run_button(label="Option 2") + [first_button, second_button] + return + +@app.cell +def _(): + if first_button.value: + print("You chose option 1!") + elif second_button.value: + print("You chose option 2!") + else: + print("Click a button!") + return + +``` + + + +``` + +@app.cell +def _(): + import marimo as mo + import polars as pl + return + +@app.cell +def _(): + weather = pl.read_csv('') + return + +@app.cell +def _(): + seattle_weather_df = mo.sql( + f""" + SELECT * FROM weather WHERE location = 'Seattle'; + """ + ) + return + +``` + diff --git a/docs/_static/docs-add-custom-provider.mp4 b/docs/_static/docs-add-custom-provider.mp4 new file mode 100644 index 00000000000..5d4d21bb572 Binary files /dev/null and b/docs/_static/docs-add-custom-provider.mp4 differ diff --git a/docs/_static/docs-add-remote-storage-ui.png b/docs/_static/docs-add-remote-storage-ui.png new file mode 100644 index 00000000000..ade43d7d6d5 Binary files /dev/null and b/docs/_static/docs-add-remote-storage-ui.png differ diff --git a/docs/_static/docs-ai-completion-gh.png b/docs/_static/docs-ai-completion-gh.png new file mode 100644 index 00000000000..054c6c66cd0 Binary files /dev/null and b/docs/_static/docs-ai-completion-gh.png differ diff --git a/docs/_static/docs-ai-completion-preview.mp4 b/docs/_static/docs-ai-completion-preview.mp4 new file mode 100644 index 00000000000..ff2dc20db61 Binary files /dev/null and b/docs/_static/docs-ai-completion-preview.mp4 differ diff --git a/docs/_static/docs-ai-install.png b/docs/_static/docs-ai-install.png new file mode 100644 index 00000000000..7ac08935f88 Binary files /dev/null and b/docs/_static/docs-ai-install.png differ diff --git a/docs/_static/docs-ai-variables.png b/docs/_static/docs-ai-variables.png new file mode 100644 index 00000000000..374680ae6a5 Binary files /dev/null and b/docs/_static/docs-ai-variables.png differ diff --git a/docs/_static/docs-app-config.png b/docs/_static/docs-app-config.png index c19a5973379..b74e93c1ab6 100644 Binary files a/docs/_static/docs-app-config.png and b/docs/_static/docs-app-config.png differ diff --git a/docs/_static/docs-chart-builder-table.mp4 b/docs/_static/docs-chart-builder-table.mp4 new file mode 100644 index 00000000000..a92a379a7c7 Binary files /dev/null and b/docs/_static/docs-chart-builder-table.mp4 differ diff --git a/docs/_static/docs-claude-code-agent.mp4 b/docs/_static/docs-claude-code-agent.mp4 new file mode 100644 index 00000000000..a44667a2c69 Binary files /dev/null and b/docs/_static/docs-claude-code-agent.mp4 differ diff --git a/docs/_static/docs-column-explorer-table.mp4 b/docs/_static/docs-column-explorer-table.mp4 new file mode 100644 index 00000000000..dd2decc9cef Binary files /dev/null and b/docs/_static/docs-column-explorer-table.mp4 differ diff --git a/docs/_static/docs-create-a-synced-notebook.png b/docs/_static/docs-create-a-synced-notebook.png new file mode 100644 index 00000000000..150d077611a Binary files /dev/null and b/docs/_static/docs-create-a-synced-notebook.png differ diff --git a/docs/_static/docs-dataflow-dependencies-panel.jpg b/docs/_static/docs-dataflow-dependencies-panel.jpg new file mode 100644 index 00000000000..e9060cf9c4d Binary files /dev/null and b/docs/_static/docs-dataflow-dependencies-panel.jpg differ diff --git a/docs/_static/docs-dataflow-dependencies-panel.webp b/docs/_static/docs-dataflow-dependencies-panel.webp new file mode 100644 index 00000000000..9515d7a474e Binary files /dev/null and b/docs/_static/docs-dataflow-dependencies-panel.webp differ diff --git a/docs/_static/docs-dataflow-graph.jpg b/docs/_static/docs-dataflow-graph.jpg new file mode 100644 index 00000000000..2c30d933c47 Binary files /dev/null and b/docs/_static/docs-dataflow-graph.jpg differ diff --git a/docs/_static/docs-dataflow-graph.webp b/docs/_static/docs-dataflow-graph.webp new file mode 100644 index 00000000000..659990c8eb0 Binary files /dev/null and b/docs/_static/docs-dataflow-graph.webp differ diff --git a/docs/_static/docs-dataflow-minimap.webm b/docs/_static/docs-dataflow-minimap.webm new file mode 100644 index 00000000000..5e56361c069 Binary files /dev/null and b/docs/_static/docs-dataflow-minimap.webm differ diff --git a/docs/_static/docs-dataflow-variables-explorer.jpg b/docs/_static/docs-dataflow-variables-explorer.jpg new file mode 100644 index 00000000000..e2d964e17aa Binary files /dev/null and b/docs/_static/docs-dataflow-variables-explorer.jpg differ diff --git a/docs/_static/docs-dataflow-variables-explorer.webp b/docs/_static/docs-dataflow-variables-explorer.webp new file mode 100644 index 00000000000..978f1e707b8 Binary files /dev/null and b/docs/_static/docs-dataflow-variables-explorer.webp differ diff --git a/docs/_static/docs-dataframe-default-setting.png b/docs/_static/docs-dataframe-default-setting.png new file mode 100644 index 00000000000..2feb2acd64c Binary files /dev/null and b/docs/_static/docs-dataframe-default-setting.png differ diff --git a/docs/_static/docs-dataframe-table.mp4 b/docs/_static/docs-dataframe-table.mp4 new file mode 100644 index 00000000000..f3f1407c888 Binary files /dev/null and b/docs/_static/docs-dataframe-table.mp4 differ diff --git a/docs/_static/docs-dataframe-table.webm b/docs/_static/docs-dataframe-table.webm new file mode 100644 index 00000000000..5ef1d2112a0 Binary files /dev/null and b/docs/_static/docs-dataframe-table.webm differ diff --git a/docs/_static/docs-dataframe-transform.mp4 b/docs/_static/docs-dataframe-transform.mp4 new file mode 100644 index 00000000000..5ffda7314c1 Binary files /dev/null and b/docs/_static/docs-dataframe-transform.mp4 differ diff --git a/docs/_static/docs-dataframe-transform.webm b/docs/_static/docs-dataframe-transform.webm new file mode 100644 index 00000000000..86f09a88d4b Binary files /dev/null and b/docs/_static/docs-dataframe-transform.webm differ diff --git a/docs/_static/docs-debugging-minimap.mp4 b/docs/_static/docs-debugging-minimap.mp4 new file mode 100644 index 00000000000..5967eed74cf Binary files /dev/null and b/docs/_static/docs-debugging-minimap.mp4 differ diff --git a/docs/_static/docs-debugging-minimap.webm b/docs/_static/docs-debugging-minimap.webm new file mode 100644 index 00000000000..973492f2f16 Binary files /dev/null and b/docs/_static/docs-debugging-minimap.webm differ diff --git a/docs/_static/docs-debugpy-edit-mode.webm b/docs/_static/docs-debugpy-edit-mode.webm new file mode 100644 index 00000000000..70da66c0f13 Binary files /dev/null and b/docs/_static/docs-debugpy-edit-mode.webm differ diff --git a/docs/_static/docs-delete-cell.mp4 b/docs/_static/docs-delete-cell.mp4 new file mode 100644 index 00000000000..c811f2913ea Binary files /dev/null and b/docs/_static/docs-delete-cell.mp4 differ diff --git a/docs/_static/docs-delete-cell.webm b/docs/_static/docs-delete-cell.webm new file mode 100644 index 00000000000..ce4f51a195a Binary files /dev/null and b/docs/_static/docs-delete-cell.webm differ diff --git a/docs/_static/docs-dependency-graph.png b/docs/_static/docs-dependency-graph.png index 160c48bc5a3..cefd532beae 100644 Binary files a/docs/_static/docs-dependency-graph.png and b/docs/_static/docs-dependency-graph.png differ diff --git a/docs/_static/docs-df.mp4 b/docs/_static/docs-df.mp4 index 4fc86d16580..b9c4106aed2 100644 Binary files a/docs/_static/docs-df.mp4 and b/docs/_static/docs-df.mp4 differ diff --git a/docs/_static/docs-df.webm b/docs/_static/docs-df.webm new file mode 100644 index 00000000000..28fc15b398f Binary files /dev/null and b/docs/_static/docs-df.webm differ diff --git a/docs/_static/docs-disable-cell.mp4 b/docs/_static/docs-disable-cell.mp4 new file mode 100644 index 00000000000..fc8b5e4ba71 Binary files /dev/null and b/docs/_static/docs-disable-cell.mp4 differ diff --git a/docs/_static/docs-disable-cell.webm b/docs/_static/docs-disable-cell.webm new file mode 100644 index 00000000000..46ea7c74e4d Binary files /dev/null and b/docs/_static/docs-disable-cell.webm differ diff --git a/docs/_static/docs-enable-cell.mp4 b/docs/_static/docs-enable-cell.mp4 new file mode 100644 index 00000000000..28aa8cdd820 Binary files /dev/null and b/docs/_static/docs-enable-cell.mp4 differ diff --git a/docs/_static/docs-enable-cell.webm b/docs/_static/docs-enable-cell.webm new file mode 100644 index 00000000000..41a36679815 Binary files /dev/null and b/docs/_static/docs-enable-cell.webm differ diff --git a/docs/_static/docs-gallery.png b/docs/_static/docs-gallery.png new file mode 100644 index 00000000000..f5b1a1dde64 Binary files /dev/null and b/docs/_static/docs-gallery.png differ diff --git a/docs/_static/docs-html-autoexport.png b/docs/_static/docs-html-autoexport.png new file mode 100644 index 00000000000..2558df71dc9 Binary files /dev/null and b/docs/_static/docs-html-autoexport.png differ diff --git a/docs/_static/docs-html-export.png b/docs/_static/docs-html-export.png index 55af505343e..386e831288d 100644 Binary files a/docs/_static/docs-html-export.png and b/docs/_static/docs-html-export.png differ diff --git a/docs/_static/docs-intro-app.mp4 b/docs/_static/docs-intro-app.mp4 new file mode 100644 index 00000000000..2d70095dafe Binary files /dev/null and b/docs/_static/docs-intro-app.mp4 differ diff --git a/docs/_static/docs-intro-app.webm b/docs/_static/docs-intro-app.webm new file mode 100644 index 00000000000..ff44c771cdf Binary files /dev/null and b/docs/_static/docs-intro-app.webm differ diff --git a/docs/_static/docs-intro.mp4 b/docs/_static/docs-intro.mp4 new file mode 100644 index 00000000000..bc7a011a6f0 Binary files /dev/null and b/docs/_static/docs-intro.mp4 differ diff --git a/docs/_static/docs-intro.webm b/docs/_static/docs-intro.webm new file mode 100644 index 00000000000..5f28b6798c2 Binary files /dev/null and b/docs/_static/docs-intro.webm differ diff --git a/docs/_static/docs-jupyter-autoexport.png b/docs/_static/docs-jupyter-autoexport.png new file mode 100644 index 00000000000..683266d3952 Binary files /dev/null and b/docs/_static/docs-jupyter-autoexport.png differ diff --git a/docs/_static/docs-jupyterhub.png b/docs/_static/docs-jupyterhub.png new file mode 100644 index 00000000000..949b8150ced Binary files /dev/null and b/docs/_static/docs-jupyterhub.png differ diff --git a/docs/_static/docs-lazy-execution.mp4 b/docs/_static/docs-lazy-execution.mp4 new file mode 100644 index 00000000000..6418777cc72 Binary files /dev/null and b/docs/_static/docs-lazy-execution.mp4 differ diff --git a/docs/_static/docs-markdown-toggle.mp4 b/docs/_static/docs-markdown-toggle.mp4 new file mode 100644 index 00000000000..068f19975da Binary files /dev/null and b/docs/_static/docs-markdown-toggle.mp4 differ diff --git a/docs/_static/docs-markdown-toggle.webm b/docs/_static/docs-markdown-toggle.webm new file mode 100644 index 00000000000..36743bbaa89 Binary files /dev/null and b/docs/_static/docs-markdown-toggle.webm differ diff --git a/docs/_static/docs-mcp-client-settings.png b/docs/_static/docs-mcp-client-settings.png new file mode 100644 index 00000000000..ec0adbd8dc1 Binary files /dev/null and b/docs/_static/docs-mcp-client-settings.png differ diff --git a/docs/_static/docs-mcp-server.mp4 b/docs/_static/docs-mcp-server.mp4 new file mode 100644 index 00000000000..0548e3cc58e Binary files /dev/null and b/docs/_static/docs-mcp-server.mp4 differ diff --git a/docs/_static/docs-md-export.png b/docs/_static/docs-md-export.png new file mode 100644 index 00000000000..6e0ad5e3975 Binary files /dev/null and b/docs/_static/docs-md-export.png differ diff --git a/docs/_static/docs-model-comparison.mp4 b/docs/_static/docs-model-comparison.mp4 new file mode 100644 index 00000000000..3587b47c7cb Binary files /dev/null and b/docs/_static/docs-model-comparison.mp4 differ diff --git a/docs/_static/docs-model-comparison.webm b/docs/_static/docs-model-comparison.webm new file mode 100644 index 00000000000..f039ae27544 Binary files /dev/null and b/docs/_static/docs-model-comparison.webm differ diff --git a/docs/_static/docs-multi-column.png b/docs/_static/docs-multi-column.png new file mode 100644 index 00000000000..cdec3a2f95f Binary files /dev/null and b/docs/_static/docs-multi-column.png differ diff --git a/docs/_static/docs-notebook-errors-context.png b/docs/_static/docs-notebook-errors-context.png new file mode 100644 index 00000000000..7faf7749ee5 Binary files /dev/null and b/docs/_static/docs-notebook-errors-context.png differ diff --git a/docs/_static/docs-notebook-settings-snapshotting.jpg b/docs/_static/docs-notebook-settings-snapshotting.jpg new file mode 100644 index 00000000000..cf35a26c491 Binary files /dev/null and b/docs/_static/docs-notebook-settings-snapshotting.jpg differ diff --git a/docs/_static/docs-notebook-settings-snapshotting.webp b/docs/_static/docs-notebook-settings-snapshotting.webp new file mode 100644 index 00000000000..87c67663ad7 Binary files /dev/null and b/docs/_static/docs-notebook-settings-snapshotting.webp differ diff --git a/docs/_static/docs-panel-drag-drop.webm b/docs/_static/docs-panel-drag-drop.webm new file mode 100644 index 00000000000..d6c3815151b Binary files /dev/null and b/docs/_static/docs-panel-drag-drop.webm differ diff --git a/docs/_static/docs-pdb-breakpoint.webm b/docs/_static/docs-pdb-breakpoint.webm new file mode 100644 index 00000000000..f4a854a0ce0 Binary files /dev/null and b/docs/_static/docs-pdb-breakpoint.webm differ diff --git a/docs/_static/docs-pdb-demo.png b/docs/_static/docs-pdb-demo.png new file mode 100644 index 00000000000..c53a238d584 Binary files /dev/null and b/docs/_static/docs-pdb-demo.png differ diff --git a/docs/_static/docs-pdf-export.png b/docs/_static/docs-pdf-export.png new file mode 100644 index 00000000000..baa9aef6129 Binary files /dev/null and b/docs/_static/docs-pdf-export.png differ diff --git a/docs/_static/docs-postmortem-debugging.webm b/docs/_static/docs-postmortem-debugging.webm new file mode 100644 index 00000000000..4a85e9ff29b Binary files /dev/null and b/docs/_static/docs-postmortem-debugging.webm differ diff --git a/docs/_static/docs-provider-config.png b/docs/_static/docs-provider-config.png new file mode 100644 index 00000000000..0a62c7f0d86 Binary files /dev/null and b/docs/_static/docs-provider-config.png differ diff --git a/docs/_static/docs-reactive-reference-highlighting.jpg b/docs/_static/docs-reactive-reference-highlighting.jpg new file mode 100644 index 00000000000..ff48d086719 Binary files /dev/null and b/docs/_static/docs-reactive-reference-highlighting.jpg differ diff --git a/docs/_static/docs-reactive-reference-highlighting.webp b/docs/_static/docs-reactive-reference-highlighting.webp new file mode 100644 index 00000000000..f41f7d3eaba Binary files /dev/null and b/docs/_static/docs-reactive-reference-highlighting.webp differ diff --git a/docs/_static/docs-remote-storage-panel.png b/docs/_static/docs-remote-storage-panel.png new file mode 100644 index 00000000000..cef932634f2 Binary files /dev/null and b/docs/_static/docs-remote-storage-panel.png differ diff --git a/docs/_static/docs-remote-storage.mp4 b/docs/_static/docs-remote-storage.mp4 new file mode 100644 index 00000000000..c709ce54589 Binary files /dev/null and b/docs/_static/docs-remote-storage.mp4 differ diff --git a/docs/_static/docs-row-viewer-panel.mp4 b/docs/_static/docs-row-viewer-panel.mp4 new file mode 100644 index 00000000000..58e98f80ea6 Binary files /dev/null and b/docs/_static/docs-row-viewer-panel.mp4 differ diff --git a/docs/_static/docs-sidebar-developer-panel.jpg b/docs/_static/docs-sidebar-developer-panel.jpg new file mode 100644 index 00000000000..2459de8e9c4 Binary files /dev/null and b/docs/_static/docs-sidebar-developer-panel.jpg differ diff --git a/docs/_static/docs-sidebar-developer-panel.webp b/docs/_static/docs-sidebar-developer-panel.webp new file mode 100644 index 00000000000..bea5dc95fe9 Binary files /dev/null and b/docs/_static/docs-sidebar-developer-panel.webp differ diff --git a/docs/_static/docs-signature-hint.png b/docs/_static/docs-signature-hint.png new file mode 100644 index 00000000000..6cf66129016 Binary files /dev/null and b/docs/_static/docs-signature-hint.png differ diff --git a/docs/_static/docs-slides-view.mp4 b/docs/_static/docs-slides-view.mp4 new file mode 100644 index 00000000000..2242f256705 Binary files /dev/null and b/docs/_static/docs-slides-view.mp4 differ diff --git a/docs/_static/docs-sql-cell-demo.png b/docs/_static/docs-sql-cell-demo.png new file mode 100644 index 00000000000..4b58f2a08fb Binary files /dev/null and b/docs/_static/docs-sql-cell-demo.png differ diff --git a/docs/_static/docs-sql-cell.png b/docs/_static/docs-sql-cell.png deleted file mode 100644 index cf9d9a9fa2c..00000000000 Binary files a/docs/_static/docs-sql-cell.png and /dev/null differ diff --git a/docs/_static/docs-sql-choose-db.png b/docs/_static/docs-sql-choose-db.png new file mode 100644 index 00000000000..991ebc9c2d0 Binary files /dev/null and b/docs/_static/docs-sql-choose-db.png differ diff --git a/docs/_static/docs-sql-datasource-quick-add.png b/docs/_static/docs-sql-datasource-quick-add.png new file mode 100644 index 00000000000..f508038ad80 Binary files /dev/null and b/docs/_static/docs-sql-datasource-quick-add.png differ diff --git a/docs/_static/docs-sql-datasources-panel.png b/docs/_static/docs-sql-datasources-panel.png new file mode 100644 index 00000000000..abda19fe48d Binary files /dev/null and b/docs/_static/docs-sql-datasources-panel.png differ diff --git a/docs/_static/docs-sql-engine-dropdown.png b/docs/_static/docs-sql-engine-dropdown.png new file mode 100644 index 00000000000..ee12a281616 Binary files /dev/null and b/docs/_static/docs-sql-engine-dropdown.png differ diff --git a/docs/_static/docs-sql-format-icon.webp b/docs/_static/docs-sql-format-icon.webp new file mode 100644 index 00000000000..16f30e41691 Binary files /dev/null and b/docs/_static/docs-sql-format-icon.webp differ diff --git a/docs/_static/docs-sql-linter.webp b/docs/_static/docs-sql-linter.webp new file mode 100644 index 00000000000..bcbe7e66e5f Binary files /dev/null and b/docs/_static/docs-sql-linter.webp differ diff --git a/docs/_static/docs-sql-validate-mode.mp4 b/docs/_static/docs-sql-validate-mode.mp4 new file mode 100644 index 00000000000..1ec6cd0c125 Binary files /dev/null and b/docs/_static/docs-sql-validate-mode.mp4 differ diff --git a/docs/_static/docs-state-counter.mp4 b/docs/_static/docs-state-counter.mp4 new file mode 100644 index 00000000000..8a45abe72ee Binary files /dev/null and b/docs/_static/docs-state-counter.mp4 differ diff --git a/docs/_static/docs-state-counter.webm b/docs/_static/docs-state-counter.webm new file mode 100644 index 00000000000..7f844196845 Binary files /dev/null and b/docs/_static/docs-state-counter.webm differ diff --git a/docs/_static/docs-state-task-list.mp4 b/docs/_static/docs-state-task-list.mp4 new file mode 100644 index 00000000000..0be39587298 Binary files /dev/null and b/docs/_static/docs-state-task-list.mp4 differ diff --git a/docs/_static/docs-state-task-list.webm b/docs/_static/docs-state-task-list.webm new file mode 100644 index 00000000000..3374f438f57 Binary files /dev/null and b/docs/_static/docs-state-task-list.webm differ diff --git a/docs/_static/docs-state-tied.mp4 b/docs/_static/docs-state-tied.mp4 new file mode 100644 index 00000000000..19663b76e3e Binary files /dev/null and b/docs/_static/docs-state-tied.mp4 differ diff --git a/docs/_static/docs-state-tied.webm b/docs/_static/docs-state-tied.webm new file mode 100644 index 00000000000..e6a2b54e9fd Binary files /dev/null and b/docs/_static/docs-state-tied.webm differ diff --git a/docs/_static/docs_cycles_error.png b/docs/_static/docs_cycles_error.png new file mode 100644 index 00000000000..5deece3e3ad Binary files /dev/null and b/docs/_static/docs_cycles_error.png differ diff --git a/docs/_static/docs_import_star_error.png b/docs/_static/docs_import_star_error.png new file mode 100644 index 00000000000..33437a5eb67 Binary files /dev/null and b/docs/_static/docs_import_star_error.png differ diff --git a/docs/_static/docs_redefines_variables_error.png b/docs/_static/docs_redefines_variables_error.png new file mode 100644 index 00000000000..a11283c6567 Binary files /dev/null and b/docs/_static/docs_redefines_variables_error.png differ diff --git a/docs/_static/docs_setup_error.png b/docs/_static/docs_setup_error.png new file mode 100644 index 00000000000..fe79e4a28f7 Binary files /dev/null and b/docs/_static/docs_setup_error.png differ diff --git a/docs/_static/embedding.mp4 b/docs/_static/embedding.mp4 new file mode 100644 index 00000000000..8104f725b1e Binary files /dev/null and b/docs/_static/embedding.mp4 differ diff --git a/docs/_static/embedding.webm b/docs/_static/embedding.webm new file mode 100644 index 00000000000..b611d36b53f Binary files /dev/null and b/docs/_static/embedding.webm differ diff --git a/docs/_static/example-thumbs/accordion.png b/docs/_static/example-thumbs/accordion.png new file mode 100644 index 00000000000..9ed3d4af9e9 Binary files /dev/null and b/docs/_static/example-thumbs/accordion.png differ diff --git a/docs/_static/example-thumbs/admonitions.png b/docs/_static/example-thumbs/admonitions.png new file mode 100644 index 00000000000..763e2496ef4 Binary files /dev/null and b/docs/_static/example-thumbs/admonitions.png differ diff --git a/docs/_static/example-thumbs/altair.png b/docs/_static/example-thumbs/altair.png new file mode 100644 index 00000000000..ff3fa61ece5 Binary files /dev/null and b/docs/_static/example-thumbs/altair.png differ diff --git a/docs/_static/example-thumbs/array.png b/docs/_static/example-thumbs/array.png new file mode 100644 index 00000000000..f7efa264993 Binary files /dev/null and b/docs/_static/example-thumbs/array.png differ diff --git a/docs/_static/example-thumbs/chat.png b/docs/_static/example-thumbs/chat.png new file mode 100644 index 00000000000..112553b021f Binary files /dev/null and b/docs/_static/example-thumbs/chat.png differ diff --git a/docs/_static/example-thumbs/checkbox.png b/docs/_static/example-thumbs/checkbox.png new file mode 100644 index 00000000000..0cf3191e73c Binary files /dev/null and b/docs/_static/example-thumbs/checkbox.png differ diff --git a/docs/_static/example-thumbs/code_editor.png b/docs/_static/example-thumbs/code_editor.png new file mode 100644 index 00000000000..fd7e460869e Binary files /dev/null and b/docs/_static/example-thumbs/code_editor.png differ diff --git a/docs/_static/example-thumbs/dataframe_transformer.png b/docs/_static/example-thumbs/dataframe_transformer.png new file mode 100644 index 00000000000..5e3ff7555c8 Binary files /dev/null and b/docs/_static/example-thumbs/dataframe_transformer.png differ diff --git a/docs/_static/example-thumbs/dataframes.png b/docs/_static/example-thumbs/dataframes.png new file mode 100644 index 00000000000..1d3c60f3666 Binary files /dev/null and b/docs/_static/example-thumbs/dataframes.png differ diff --git a/docs/_static/example-thumbs/date.png b/docs/_static/example-thumbs/date.png new file mode 100644 index 00000000000..b4a32360f46 Binary files /dev/null and b/docs/_static/example-thumbs/date.png differ diff --git a/docs/_static/example-thumbs/details.png b/docs/_static/example-thumbs/details.png new file mode 100644 index 00000000000..ad861cb2d7c Binary files /dev/null and b/docs/_static/example-thumbs/details.png differ diff --git a/docs/_static/example-thumbs/dictionary.png b/docs/_static/example-thumbs/dictionary.png new file mode 100644 index 00000000000..15c4d2716fe Binary files /dev/null and b/docs/_static/example-thumbs/dictionary.png differ diff --git a/docs/_static/example-thumbs/dropdown.png b/docs/_static/example-thumbs/dropdown.png new file mode 100644 index 00000000000..837937d47f0 Binary files /dev/null and b/docs/_static/example-thumbs/dropdown.png differ diff --git a/docs/_static/example-thumbs/dynamic_markdown.png b/docs/_static/example-thumbs/dynamic_markdown.png new file mode 100644 index 00000000000..05844032615 Binary files /dev/null and b/docs/_static/example-thumbs/dynamic_markdown.png differ diff --git a/docs/_static/example-thumbs/editable_dataframes.png b/docs/_static/example-thumbs/editable_dataframes.png new file mode 100644 index 00000000000..aa3e83a99bf Binary files /dev/null and b/docs/_static/example-thumbs/editable_dataframes.png differ diff --git a/docs/_static/example-thumbs/file_upload.png b/docs/_static/example-thumbs/file_upload.png new file mode 100644 index 00000000000..c990cd0a4fd Binary files /dev/null and b/docs/_static/example-thumbs/file_upload.png differ diff --git a/docs/_static/example-thumbs/form.png b/docs/_static/example-thumbs/form.png new file mode 100644 index 00000000000..20782e13e66 Binary files /dev/null and b/docs/_static/example-thumbs/form.png differ diff --git a/docs/_static/example-thumbs/matrix.png b/docs/_static/example-thumbs/matrix.png new file mode 100644 index 00000000000..7bf4d86516f Binary files /dev/null and b/docs/_static/example-thumbs/matrix.png differ diff --git a/docs/_static/example-thumbs/mermaid.png b/docs/_static/example-thumbs/mermaid.png new file mode 100644 index 00000000000..5776d579731 Binary files /dev/null and b/docs/_static/example-thumbs/mermaid.png differ diff --git a/docs/_static/example-thumbs/microphone.png b/docs/_static/example-thumbs/microphone.png new file mode 100644 index 00000000000..ad5847f36af Binary files /dev/null and b/docs/_static/example-thumbs/microphone.png differ diff --git a/docs/_static/example-thumbs/multiselect.png b/docs/_static/example-thumbs/multiselect.png new file mode 100644 index 00000000000..e4f9b4d66d1 Binary files /dev/null and b/docs/_static/example-thumbs/multiselect.png differ diff --git a/docs/_static/example-thumbs/number.png b/docs/_static/example-thumbs/number.png new file mode 100644 index 00000000000..8f229de2865 Binary files /dev/null and b/docs/_static/example-thumbs/number.png differ diff --git a/docs/_static/example-thumbs/plotly.png b/docs/_static/example-thumbs/plotly.png new file mode 100644 index 00000000000..b178588a78c Binary files /dev/null and b/docs/_static/example-thumbs/plotly.png differ diff --git a/docs/_static/example-thumbs/progress_bar.png b/docs/_static/example-thumbs/progress_bar.png new file mode 100644 index 00000000000..550bfaaaf3e Binary files /dev/null and b/docs/_static/example-thumbs/progress_bar.png differ diff --git a/docs/_static/example-thumbs/radio.png b/docs/_static/example-thumbs/radio.png new file mode 100644 index 00000000000..b3e52dbadb9 Binary files /dev/null and b/docs/_static/example-thumbs/radio.png differ diff --git a/docs/_static/example-thumbs/run_button.png b/docs/_static/example-thumbs/run_button.png new file mode 100644 index 00000000000..e9081b4a599 Binary files /dev/null and b/docs/_static/example-thumbs/run_button.png differ diff --git a/docs/_static/example-thumbs/slider.png b/docs/_static/example-thumbs/slider.png new file mode 100644 index 00000000000..6514a6cc095 Binary files /dev/null and b/docs/_static/example-thumbs/slider.png differ diff --git a/docs/_static/example-thumbs/spinner.png b/docs/_static/example-thumbs/spinner.png new file mode 100644 index 00000000000..a4980cfd8a8 Binary files /dev/null and b/docs/_static/example-thumbs/spinner.png differ diff --git a/docs/_static/example-thumbs/stacks.png b/docs/_static/example-thumbs/stacks.png new file mode 100644 index 00000000000..f3b0966cda9 Binary files /dev/null and b/docs/_static/example-thumbs/stacks.png differ diff --git a/docs/_static/example-thumbs/tables.png b/docs/_static/example-thumbs/tables.png new file mode 100644 index 00000000000..84ea11212fd Binary files /dev/null and b/docs/_static/example-thumbs/tables.png differ diff --git a/docs/_static/example-thumbs/tabs.png b/docs/_static/example-thumbs/tabs.png new file mode 100644 index 00000000000..b9138a71d42 Binary files /dev/null and b/docs/_static/example-thumbs/tabs.png differ diff --git a/docs/_static/example-thumbs/text.png b/docs/_static/example-thumbs/text.png new file mode 100644 index 00000000000..672c6aa4159 Binary files /dev/null and b/docs/_static/example-thumbs/text.png differ diff --git a/docs/_static/example-thumbs/text_area.png b/docs/_static/example-thumbs/text_area.png new file mode 100644 index 00000000000..d12315420c1 Binary files /dev/null and b/docs/_static/example-thumbs/text_area.png differ diff --git a/docs/_static/faq-marimo-ui.mp4 b/docs/_static/faq-marimo-ui.mp4 new file mode 100644 index 00000000000..83981c69df0 Binary files /dev/null and b/docs/_static/faq-marimo-ui.mp4 differ diff --git a/docs/_static/faq-marimo-ui.webm b/docs/_static/faq-marimo-ui.webm new file mode 100644 index 00000000000..e6d47112f04 Binary files /dev/null and b/docs/_static/faq-marimo-ui.webm differ diff --git a/docs/_static/intro_condensed.webm b/docs/_static/intro_condensed.webm new file mode 100644 index 00000000000..edee1d73e29 Binary files /dev/null and b/docs/_static/intro_condensed.webm differ diff --git a/docs/_static/intro_tutorial.gif b/docs/_static/intro_tutorial.gif deleted file mode 100644 index cf93544f87a..00000000000 Binary files a/docs/_static/intro_tutorial.gif and /dev/null differ diff --git a/docs/_static/js/analytics.js b/docs/_static/js/analytics.js index 1dc2bc2952a..9c0085f72a0 100644 --- a/docs/_static/js/analytics.js +++ b/docs/_static/js/analytics.js @@ -6,7 +6,7 @@ if (window.location.pathname.endsWith('.html')) { } // @ts-ignore -// biome-ignore +// oxlint-disable-next-line !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys".split(" "),n=0;n { + const script = document.createElement("script"); + script.src = "https://widget.kapa.ai/kapa-widget.bundle.js"; + script.setAttribute( + "data-website-id", + "d5df1a3e-916b-44ad-a725-e80e490570b2", + ); + script.setAttribute("data-modal-title", "Ask marimo"); + script.setAttribute("data-project-name", "marimo"); + script.setAttribute("data-project-color", "rgb(3, 41, 33)"); + + // Alternative logo + // "https://marimo.io/favicon-32x32.png", + script.setAttribute("data-project-logo", "https://marimo.io/logo.png"); + script.setAttribute( + "data-modal-disclaimer", + "This is a custom LLM for marimo with access to all [documentation](https://docs.marimo.io) and the [API reference](https://docs.marimo.io/api/).", + ); + + // Uncertain answer + script.setAttribute( + "data-uncertain-answer-callout", + "I could not find an answer in my knowledge base. But a human team member can give you more detailed answers directly in our [Discord](https://marimo.io/discord?ref=kapa-widget).", + ); + + // CTA + script.setAttribute( + "data-answer-cta-button-enabled", + "true", + ); + script.setAttribute( + "data-answer-cta-button-link", + "https://marimo.io/discord?ref=kapa-widget", + ); + script.setAttribute( + "data-answer-cta-button-text", + "Ask on Discord", + ); + + // TODO: Add example questions + // Currently this makes the styling weird + // script.setAttribute( + // "data-modal-example-questions", + // "How is marimo different from Jupyter?,How can I run my marimo notebook as a script?", + // ); + script.setAttribute("data-user-analytics-fingerprint-enabled", "true"); + + // Font size parameters + script.setAttribute("data-font-size-xs", "0.6rem"); + script.setAttribute("data-font-size-sm", "0.7rem"); + script.setAttribute("data-font-size-md", "0.8rem"); + script.setAttribute("data-font-size-lg", "0.9rem"); + script.setAttribute("data-font-size-xl", "1rem"); + + // Style parameters + script.setAttribute( + "data-modal-title-font-family", + "var(--md-text-font-family)", + ); + + // Button size + script.setAttribute("data-button-text", "Ask"); + script.setAttribute("data-button-height", "4rem"); + script.setAttribute("data-button-width", "4rem"); + script.setAttribute("data-button-image-height", "24"); + script.setAttribute("data-button-image-width", "24"); + + // Modal size + script.setAttribute("data-modal-image-height", "24"); + script.setAttribute("data-modal-image-width", "24"); + script.setAttribute("data-modal-title-font-size", "1rem"); + script.setAttribute("data-modal-header-padding", "12px"); + script.setAttribute("data-modal-body-padding-top", "8px"); + script.setAttribute("data-modal-body-padding-right", "12px"); + script.setAttribute("data-modal-body-padding-left", "12px"); + script.setAttribute("data-modal-body-padding-bottom", "12px"); + + // Remove hover animations + script.setAttribute("data-button-hover-animation-enabled", "false"); + script.setAttribute("data-button-animation-enabled", "false"); + + script.async = true; + document.head.appendChild(script); +}); diff --git a/docs/_static/js/math.js b/docs/_static/js/math.js new file mode 100644 index 00000000000..822f08a7dca --- /dev/null +++ b/docs/_static/js/math.js @@ -0,0 +1,53 @@ +(() => { + const renderMath = (element) => { + const tex = element.textContent || element.innerHTML; + if (tex.startsWith("\\(") && tex.endsWith("\\)")) { + katex.render(tex.slice(2, -2), element, { displayMode: false }); + } else if (tex.startsWith("\\[") && tex.endsWith("\\]")) { + katex.render(tex.slice(2, -2), element, { displayMode: true }); + } + }; + + const renderAllMath = () => { + const maths = document.querySelectorAll( + ".arithmatex:not([data-processed])", + ); + maths.forEach((element) => { + try { + renderMath(element); + element.setAttribute("data-processed", "true"); + } catch (error) { + console.warn("Failed to render math:", error); + } + }); + }; + + // Watch for new content + const observer = new MutationObserver((mutations) => { + const shouldRender = mutations.some((mutation) => { + return mutation.addedNodes.length > 0; + }); + if (shouldRender) { + renderAllMath(); + } + }); + + const init = () => { + if (typeof katex === "undefined") { + console.warn("KaTeX not loaded"); + return; + } + + renderAllMath(); + observer.observe(document.body, { + childList: true, + subtree: true, + }); + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/docs/_static/js/page_actions.js b/docs/_static/js/page_actions.js new file mode 100644 index 00000000000..56b39022a19 --- /dev/null +++ b/docs/_static/js/page_actions.js @@ -0,0 +1,380 @@ +/** + * Page Actions Dropdown + * + * Adds an "Actions" dropdown to each docs page with options to: + * - Copy page content as markdown + * - Open in Claude / ChatGPT + * - Connect to Cursor / VS Code (via MCP deep links) + */ +(function () { + const MCP_CONFIG = { name: "marimo", url: "https://mcp.marimo.app/mcp" }; + const CURSOR_DEEP_LINK = + "cursor://anysphere.cursor-deeplink/mcp/install?name=" + + encodeURIComponent(MCP_CONFIG.name) + + "&config=" + + encodeURIComponent(btoa(JSON.stringify(MCP_CONFIG))); + const VSCODE_DEEP_LINK = + "vscode:mcp/install?" + + encodeURIComponent(JSON.stringify(MCP_CONFIG)); + + // --- Icons (inline SVGs) --- + + var ARROW = ''; + + var CHEVRON = ''; + + var CHECK = ''; + + var ICONS = { + copy: '', + chatgpt: '', + claude: '', + cursor: '', + vscode: '', + }; + + // --- Markdown fetching --- + + async function getMarkdownContent() { + try { + var pathname = window.location.pathname; + var mdUrl = + pathname === "/" ? "/index.md" : pathname.replace(/\/$/, "") + ".md"; + var response = await fetch(mdUrl); + if (response.ok) { + var contentType = response.headers.get("content-type") || ""; + if (contentType.includes("text/markdown")) { + return await response.text(); + } + } + } catch (_) { + // Fetch failed (local dev, CORS, etc.) — fall through to fallback + } + // Fallback: extract rendered text, excluding the dropdown itself + var article = document.querySelector("article.md-content__inner"); + if (!article) return document.title; + var clone = article.cloneNode(true); + clone.querySelectorAll(".pa-dropdown").forEach(function (el) { + el.remove(); + }); + return clone.innerText; + } + + // --- Copy with fallback --- + + function fallbackCopyText(text) { + var textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + var copied = false; + try { + copied = document.execCommand("copy"); + } catch (_) { + copied = false; + } + document.body.removeChild(textarea); + return copied; + } + + async function copyText(text) { + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return true; + } + } catch (_) { + // Fall through to execCommand fallback + } + return fallbackCopyText(text); + } + + // --- Action handlers --- + + async function handleCopy(item) { + var markdown = await getMarkdownContent(); + var ok = await copyText(markdown); + if (ok) { + showCopied(item); + } else { + showCopyError(item); + } + } + + function handleOpenInClaude() { + var pageUrl = window.location.href; + var q = "Read from " + pageUrl + " so I can ask questions about it."; + window.open( + "https://claude.ai/new?q=" + encodeURIComponent(q), + "_blank", + "noopener,noreferrer" + ); + } + + function handleOpenInChatGPT() { + var pageUrl = window.location.href; + var q = "Read from " + pageUrl + " so I can ask questions about it."; + window.open( + "https://chatgpt.com/?q=" + encodeURIComponent(q), + "_blank", + "noopener,noreferrer" + ); + } + + function handleConnectToCursor() { + window.open(CURSOR_DEEP_LINK); + } + + function handleConnectToVSCode() { + window.open(VSCODE_DEEP_LINK); + } + + // --- Feedback animations --- + + function showCopied(item) { + var check = item.querySelector(".pa-check"); + var label = item.querySelector(".pa-item-label"); + var desc = item.querySelector(".pa-item-desc"); + var origLabel = label ? label.innerHTML : ""; + var origDesc = desc ? desc.textContent : ""; + if (check) check.style.opacity = "1"; + if (label) label.textContent = "Copied!"; + if (desc) desc.textContent = "Copy as Markdown"; + setTimeout(function () { + if (check) check.style.opacity = "0"; + if (label) label.innerHTML = origLabel; + if (desc) desc.textContent = origDesc; + }, 1500); + } + + function showCopyError(item) { + var label = item.querySelector(".pa-item-label"); + var desc = item.querySelector(".pa-item-desc"); + var origLabel = label ? label.innerHTML : ""; + var origDesc = desc ? desc.textContent : ""; + if (label) label.textContent = "Copy failed"; + if (desc) desc.textContent = "Try again or use Ctrl+C"; + setTimeout(function () { + if (label) label.innerHTML = origLabel; + if (desc) desc.textContent = origDesc; + }, 2000); + } + + // --- Singleton outside-click handler --- + + var activeDropdown = null; + + document.addEventListener("click", function (e) { + if (activeDropdown && !activeDropdown.contains(e.target)) { + var menu = activeDropdown.querySelector(".pa-menu"); + var toggle = activeDropdown.querySelector(".pa-toggle"); + if (menu && !menu.hidden) { + menu.hidden = true; + toggle.setAttribute("aria-expanded", "false"); + activeDropdown.classList.remove("pa-open"); + activeDropdown = null; + } + } + }); + + // --- Dropdown creation --- + + function createDropdown() { + var container = document.createElement("div"); + container.className = "pa-dropdown"; + + // Toggle button + var menuId = "pa-menu-" + Math.random().toString(36).slice(2, 9); + var toggle = document.createElement("button"); + toggle.className = "pa-toggle"; + toggle.setAttribute("aria-haspopup", "menu"); + toggle.setAttribute("aria-expanded", "false"); + toggle.setAttribute("aria-controls", menuId); + toggle.setAttribute("aria-label", "Page actions"); + toggle.title = "Page actions"; + toggle.innerHTML = + '' + ICONS.copy + "" + + 'Copy page' + + CHEVRON; + container.appendChild(toggle); + + // Menu + var menu = document.createElement("div"); + menu.className = "pa-menu"; + menu.id = menuId; + menu.setAttribute("role", "menu"); + menu.hidden = true; + container.appendChild(menu); + + var actions = [ + { + id: "copy", + label: "Copy page", + desc: "Copy as Markdown", + icon: ICONS.copy, + external: false, + }, + { + id: "chatgpt", + label: "Open in ChatGPT", + desc: "Ask about this page", + icon: ICONS.chatgpt, + external: true, + }, + { + id: "claude", + label: "Open in Claude", + desc: "Ask about this page", + icon: ICONS.claude, + external: true, + }, + { + id: "cursor", + label: "Connect to Cursor", + desc: "Add marimo MCP server", + icon: ICONS.cursor, + external: true, + }, + { + id: "vscode", + label: "Connect to VS Code", + desc: "Add marimo MCP server", + icon: ICONS.vscode, + external: true, + }, + ]; + + var items = []; + actions.forEach(function (action) { + var btn = document.createElement("button"); + btn.className = "pa-item"; + btn.setAttribute("role", "menuitem"); + btn.setAttribute("tabindex", "-1"); + btn.setAttribute("data-action", action.id); + btn.innerHTML = + '' + action.icon + "" + + '' + + '' + action.label + + (action.external ? ARROW : "") + + "" + + '' + action.desc + "" + + "" + + CHECK; + menu.appendChild(btn); + items.push(btn); + }); + + // --- Event handling --- + + function openMenu() { + menu.hidden = false; + toggle.setAttribute("aria-expanded", "true"); + container.classList.add("pa-open"); + activeDropdown = container; + if (items.length) items[0].focus(); + } + + function closeMenu(restoreFocus) { + menu.hidden = true; + toggle.setAttribute("aria-expanded", "false"); + container.classList.remove("pa-open"); + activeDropdown = null; + if (restoreFocus) toggle.focus(); + } + + function isOpen() { + return !menu.hidden; + } + + toggle.addEventListener("click", function (e) { + e.stopPropagation(); + if (isOpen()) { + closeMenu(); + } else { + openMenu(); + } + }); + + // Action dispatch + menu.addEventListener("click", function (e) { + var item = e.target.closest("[data-action]"); + if (!item) return; + var action = item.getAttribute("data-action"); + switch (action) { + case "copy": + handleCopy(item); + return; // don't close — show check + case "claude": + handleOpenInClaude(); + break; + case "chatgpt": + handleOpenInChatGPT(); + break; + case "cursor": + handleConnectToCursor(); + break; + case "vscode": + handleConnectToVSCode(); + break; + } + closeMenu(); + }); + + // Keyboard navigation + container.addEventListener("keydown", function (e) { + if (e.key === "Escape") { + closeMenu(true); + return; + } + if (e.key === "Tab" && isOpen()) { + closeMenu(false); + return; + } + if (!isOpen()) return; + + var currentIndex = items.indexOf(document.activeElement); + if (e.key === "ArrowDown") { + e.preventDefault(); + var next = (currentIndex + 1) % items.length; + items[next].focus(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + var prev = (currentIndex - 1 + items.length) % items.length; + items[prev].focus(); + } + }); + + return container; + } + + // --- Injection --- + + function injectDropdown() { + var article = document.querySelector("article.md-content__inner"); + if (!article || article.querySelector(".pa-dropdown")) return; + article.insertBefore(createDropdown(), article.firstChild); + } + + // --- Lifecycle --- + + function init() { + injectDropdown(); + + // Re-inject after instant navigation replaces content + var observer = new MutationObserver(function () { + if (!document.querySelector(".pa-dropdown")) { + injectDropdown(); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/docs/_static/marimo-pair-molab.png b/docs/_static/marimo-pair-molab.png new file mode 100644 index 00000000000..2b2420d13fc Binary files /dev/null and b/docs/_static/marimo-pair-molab.png differ diff --git a/docs/_static/molab-attach-gpu.mp4 b/docs/_static/molab-attach-gpu.mp4 new file mode 100644 index 00000000000..7bcba7f7c18 Binary files /dev/null and b/docs/_static/molab-attach-gpu.mp4 differ diff --git a/docs/_static/molab-copy-embed-iframe.png b/docs/_static/molab-copy-embed-iframe.png new file mode 100644 index 00000000000..0b3e88569bb Binary files /dev/null and b/docs/_static/molab-copy-embed-iframe.png differ diff --git a/docs/_static/molab-sync-notebook.png b/docs/_static/molab-sync-notebook.png new file mode 100644 index 00000000000..a9cdf52f6ed Binary files /dev/null and b/docs/_static/molab-sync-notebook.png differ diff --git a/docs/_static/molab-wasm-preview.png b/docs/_static/molab-wasm-preview.png new file mode 100644 index 00000000000..425f798a9fc Binary files /dev/null and b/docs/_static/molab-wasm-preview.png differ diff --git a/docs/_static/numfocus_affiliated_project.png b/docs/_static/numfocus_affiliated_project.png new file mode 100644 index 00000000000..40d20008764 Binary files /dev/null and b/docs/_static/numfocus_affiliated_project.png differ diff --git a/docs/_static/outputs.mp4 b/docs/_static/outputs.mp4 new file mode 100644 index 00000000000..dd407663ff1 Binary files /dev/null and b/docs/_static/outputs.mp4 differ diff --git a/docs/_static/outputs.webm b/docs/_static/outputs.webm new file mode 100644 index 00000000000..14035c39b34 Binary files /dev/null and b/docs/_static/outputs.webm differ diff --git a/docs/_static/reactive.mp4 b/docs/_static/reactive.mp4 new file mode 100644 index 00000000000..b80a466d337 Binary files /dev/null and b/docs/_static/reactive.mp4 differ diff --git a/docs/_static/reactive.webm b/docs/_static/reactive.webm new file mode 100644 index 00000000000..dd6288753e5 Binary files /dev/null and b/docs/_static/reactive.webm differ diff --git a/docs/_static/readme-generate-with-ai.gif b/docs/_static/readme-generate-with-ai.gif new file mode 100644 index 00000000000..6113a2158a5 Binary files /dev/null and b/docs/_static/readme-generate-with-ai.gif differ diff --git a/docs/_static/readme-generate-with-ai.mp4 b/docs/_static/readme-generate-with-ai.mp4 new file mode 100644 index 00000000000..f60cc2a99ac Binary files /dev/null and b/docs/_static/readme-generate-with-ai.mp4 differ diff --git a/docs/_static/readme-ui-form.mp4 b/docs/_static/readme-ui-form.mp4 new file mode 100644 index 00000000000..69b3b2bfaa3 Binary files /dev/null and b/docs/_static/readme-ui-form.mp4 differ diff --git a/docs/_static/readme-ui-form.webm b/docs/_static/readme-ui-form.webm new file mode 100644 index 00000000000..d5b6ed2dd10 Binary files /dev/null and b/docs/_static/readme-ui-form.webm differ diff --git a/docs/_static/readme-ui.mp4 b/docs/_static/readme-ui.mp4 new file mode 100644 index 00000000000..7af95003d2b Binary files /dev/null and b/docs/_static/readme-ui.mp4 differ diff --git a/docs/_static/readme-ui.webm b/docs/_static/readme-ui.webm new file mode 100644 index 00000000000..53c228506d8 Binary files /dev/null and b/docs/_static/readme-ui.webm differ diff --git a/docs/_static/readme.mp4 b/docs/_static/readme.mp4 new file mode 100644 index 00000000000..e3d540a2a16 Binary files /dev/null and b/docs/_static/readme.mp4 differ diff --git a/docs/_static/readme.webm b/docs/_static/readme.webm new file mode 100644 index 00000000000..58ed04faf1f Binary files /dev/null and b/docs/_static/readme.webm differ diff --git a/docs/_static/share-wasm-link.mp4 b/docs/_static/share-wasm-link.mp4 new file mode 100644 index 00000000000..234dead8be2 Binary files /dev/null and b/docs/_static/share-wasm-link.mp4 differ diff --git a/docs/_static/share-wasm-link.webm b/docs/_static/share-wasm-link.webm new file mode 100644 index 00000000000..1268bb974e5 Binary files /dev/null and b/docs/_static/share-wasm-link.webm differ diff --git a/docs/_static/windsurf-api.png b/docs/_static/windsurf-api.png new file mode 100644 index 00000000000..e89666d4399 Binary files /dev/null and b/docs/_static/windsurf-api.png differ diff --git a/docs/_static/windsurf-settings.png b/docs/_static/windsurf-settings.png new file mode 100644 index 00000000000..1f1bbf98cf9 Binary files /dev/null and b/docs/_static/windsurf-settings.png differ diff --git a/docs/api/app.md b/docs/api/app.md index 1f263402619..ad4683c12a0 100644 --- a/docs/api/app.md +++ b/docs/api/app.md @@ -4,7 +4,10 @@ options: members: - embed + - run ## AppMeta ::: marimo.app_meta + +::: marimo.AppMeta diff --git a/docs/api/caching.md b/docs/api/caching.md index f8f0bcda49d..2501033eae2 100644 --- a/docs/api/caching.md +++ b/docs/api/caching.md @@ -1,37 +1,416 @@ # Caching -marimo comes with utilities to cache intermediate computations. These utilities -come in two types: caching the return values of expensive functions in memory, -and caching the values of variables to disk. +marimo comes with utilities to cache intermediate computations. These +utilities can be applied as decorators to functions to cache their +returned values; you can choose between saving caches in memory or to disk. -## Caching expensive functions +## Basic usage -Use [`mo.cache`][marimo.cache] to cache the return values of functions in -memory, based on the function arguments, closed-over values, and the notebook -code defining the function. +marimo provides two decorators for caching the return values of expensive +functions: -The resulting cache is similar to `functools.cache`, but with the benefit that -[`mo.cache`][marimo.cache] won't return stale values (because it keys on -closed-over values) and isn't invalidated when the cell defining the decorated -function is simply re-run (because it keys on notebook code). This means that -like marimo notebooks, [`mo.cache`][marimo.cache] has no hidden state -associated with the cached function, which makes you more productive while developing iteratively. +1. [`mo.cache`][marimo.cache], which saves cached values to memory; +2. [`mo.persistent_cache`][marimo.persistent_cache], which saves cached values to disk. -For a cache with bounded size, use [`mo.lru_cache`][marimo.lru_cache]. +/// tab | `mo.cache` -::: marimo.cache -::: marimo.lru_cache +```python +import marimo as mo + +@mo.cache +def compute_embedding(data: str, embedding_dimension: int, model: str) -> np.ndarray: + ... +``` + +/// + +/// tab | `mo.persistent_cache` + +```python +import marimo as mo + +@mo.persistent_cache +def compute_embedding(data: str, embedding_dimension: int, model: str) -> np.ndarray: + ... +``` + +/// + +/// tab | `mo.cache` (async) + +```python +import marimo as mo + +@mo.cache +async def fetch_data(url: str, params: dict) -> dict: + response = await http_client.get(url, params=params) + return response.json() +``` + +/// -## Caching variables to disk +/// tab | `mo.persistent_cache` (async) -Use [`mo.persistent_cache`][marimo.persistent_cache] to cache variables computed in an expensive block of -code to disk. The next time this block of code is run, if marimo detects a -cache hit, the code will be skipped and your variables will be loaded into -memory, letting you pick up where you left off. +```python +import marimo as mo -!!! tip "Cache location" - By default, caches are stored in `__marimo__/cache/`, in the directory of the +@mo.persistent_cache +async def compute_embedding(data: str, embedding_dimension: int, model: str) -> np.ndarray: + response = await llm_client.get_embeddings(data, model) + return response.embeddings +``` + +/// + +Roughly speaking, the first time a cached function is called with a particular +sequence of arguments, the function will run and its return value will be +cached. The next time it is called with the same sequence of arguments (on +cache hit), the function body will be skipped and the return value will be +retrieved from cache instead. + +The in-memory cache ([`mo.cache`][marimo.cache]) is faster and doesn't consume disk space, but +it is lost on notebook restart. The disk cache ([`mo.persistent_cache`][marimo.persistent_cache]) is +slower and consumes space on disk, but it persists across notebook runs, +letting you pick up where you left off. + +(For an in-memory cache of bounded size, use [`mo.lru_cache`][marimo.lru_cache].) + +!!! note "Async functions are fully supported" + All cache decorators (`mo.cache`, `mo.lru_cache`, `mo.persistent_cache`) work + seamlessly with both synchronous and asynchronous functions. When multiple + concurrent calls are made to a cached async function with the same arguments, + only one execution occurs—the rest await the result. This prevents race conditions + and duplicate work. + +!!! tip "Where persistent caches are stored" + By default, persistent caches are stored in `__marimo__/cache/`, in the directory of the current notebook. For projects versioned with `git`, consider adding `**/__marimo__/cache/` to your `.gitignore`. +!!! tip "Caches are preserved even when a cell is re-run" + If a cell defining a cached function is re-run, the cache will be preserved unless + its source code (or the source code of the cell's ancestors) has changed. + +### Persistent cache context manager + +You can also use marimo's [`mo.persistent_cache`][marimo.persistent_cache] as a context manager: + +```python +with mo.persistent_cache("my_cache_name"): + X = my_expensive_computation(data, model) +``` + +The next time this block of code is run, if marimo detects a cache hit, the +code will be skipped and your variables will be loaded into memory. The cache +key for the context manager is computed in the same way as it is computed for +decorated functions. + +## Automatic cell caching + +`mo.cache` and `mo.persistent_cache` are opt-in per function or code block. marimo +also has a notebook-wide mechanism that automatically attempts to cache +**every** executed cell, not just the ones you have explicitly decorated. + +Enable it with: + +```toml title="pyproject.toml" +[tool.marimo.runtime] +cache_cells = true +``` + +!!! tip "Configuring for your project" + Set the above snippet in your notebook's `pyproject.toml` as shown above, + or directly in your notebooks' PEP 723 metadata. + +With `cache_cells` enabled, marimo attempts to save and restore every cell's +state on notebook restart. On a cache hit, marimo skips re-running the cell and +hydrates its variables from a stored stub instead. If a stub cannot be hydrated +(for example, its value is not hashable or serializable), marimo invalidates +the cache for that cell and its producing ancestors, then re-runs them live. +You do not need to clear anything manually. + +!!! warning "Experimental" + UI elements defined by a cached cell are not currently restored from + cache on a cache hit, which forces a live re-run of that cell. Smarter + caching (skipping cells where caching itself is slow) and + surfacing more information about cache hits and misses in the UI are + planned but not yet implemented. + +Cached cell execution is also what powers [cached WASM +exports](../guides/exporting/webassembly_html.md#exporting-with-cached-execution), +which let you publish notebooks whose expensive or browser-incompatible cells +were computed elsewhere beforehand. + +!!! tip "Further reading" + For more on the design of marimo's caching, see our SciPy paper + (currently under peer review): + [github.com/scipy-conference/scipy_proceedings/pull/1262](https://github.com/scipy-conference/scipy_proceedings/pull/1262) + +## Cache key + +Both `mo.cache` and `mo.persistent_cache` use the same mechanism for creating a +cache key, differing only in where the cache is stored. The cache key is based +on function arguments and closed-over variables. + +### Function arguments +**Arguments must either be primitive, marimo UI elements, array-like, or pickleable:** + +1. Primitive types (strings, bytes, numbers, None) are hashed. +2. marimo UI elements are hashed based on their value. +3. [Array-like objects](https://numpy.org/doc/stable/reference/arrays.interface.html#object.__array_interface__) are introspected, with their values being hashed. +4. All other objects are pickled. + + +### Closed-over variables +Syntactically closing over variables provides another way to parametrize +functions. In this example, the variable `x` is "closed over": + +```python +x = 0 +``` + +```python +def my_function(): + return x + 1 +``` + +Closed-over variables are processed in the following way: + +* marimo first attempts to hash or pickle the closed-over variables, just as it +does for arguments. +* If a closed-over variables cannot be hashed or pickled, then marimo uses the +source code that defines the value as part of the cache key; in particular, marimo +hashes the cell that defines the variable as well as the source code of that +cell's ancestors. This assumes that the variable's value is a deterministic +function of the source code that defines it, although certain side-effects +(specifically, if a cell raised an exception or loaded from another cache) +are taken into account. + +Because marimo's cache key construction can fall back to source code for +closed-over variables, closing over variables lets you cache functions even in +the presence of non-hashable and non-pickleable arguments. + +## Limitations + +marimo's cache has some limitations: + +- Side effects are not cached. This means that on a cache hit, side effects like printing, file I/O, or network requests will not occur. +- The source code of imported modules is not used when computing the cache key. + - By setting `pin_modules` to `True`, you can ensure that the cache is invalidated when module versions change (`e.g.`, update when the module's `__version__` attribute changes). + - This limitation does not apply if the external module is a marimo notebook. +- The return values of persistently cached functions must be serializable with pickle. + + + + + + + + + +!!! warning "Don't mutate variables" + marimo works best when you don't mutate variables across cells. The same + is true for caching, since the cache key may not always be able to take + mutations into account. + + +### Decorators defined in other Python modules + +Decorators defined in other Python modules that do not use `functools.wraps` +cannot be correctly cached. This can lead to confusing bugs like the example +below: + + +```python +# my_lib.py +def my_decorator(func): + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + return wrapper +``` + +```python +# Cell 1 +from my_lib import my_decorator + +@mo.cache +@my_decorator +def expensive_function(): + # ... some computation + return "result1" + +@mo.cache +@my_decorator +def another_expensive_function(): + # ... different computation + return "result2" + +# This assertion may unexpectedly pass due to cache collision! +assert expensive_function() == another_expensive_function(), "But why?" +``` + +The fix is to make sure the decorator uses `functools.wraps`: + +```python +# my_lib.py (fixed) +from functools import wraps + +def my_decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + return wrapper +``` + +In this instance, the cache will work as expected because the decorated function has the same signature and metadata as the original function. +## Comparison with `functools.cache` + +Here is a table comparing marimo's cache with `functools.cache`: + +| Feature | marimo cache | `functools.cache` | +|-------- | ------------------|-----------------| +| Cache return values in memory? | ✅ | ✅ | +| Cache return values to disk?| ✅ | ❌ | +| Preserved on cell re-runs? | ✅ | ❌ | +| Tracks closed-over variables | ✅ | ❌ | +| Allows unhashable arguments? | ✅ | ❌ | +| Allows Array-like arguments? | ✅ | ❌ | +| Supports async functions? | ✅ | ❌ | +| Suitable for lightweight functions (microseconds)? | ❌ | ✅ | + + +!!! tip "When to use `functools.cache`" + Prefer `functools.cache` for extremely lightweight functions + (that execute in less than a millisecond). Using memoization to calculate + the Fibonacci sequence is a classic example of using `functools.cache` + effectively. On a basic macbook in pure python, `fib(35)` takes 1 second to + compute; with `mo.cache` it takes 0.000229 seconds; with `functools.cache`, it + takes 0.000025 seconds (x9 faster!!). Although relatively small, the additional overhead of + `mo.cache` (and more so `mo.persistent_cache`) is larger than + `functools.cache`. If your function takes more than a few milliseconds to + compute, the difference is negligible. + + +## Tips + +### Isolate cached code blocks to their own cells + +Isolating cached functions in separate cells improves cache reliability. When +dependencies and cached functions are in the same cell, any change to the cell +invalidates the cache, even if the cached function itself hasn't changed. +Separating them ensures the cache is only invalidated when the function +actually changes. + +**Don't do this:** +```python +# Cell 1 +llm_client = ... +@mo.cache +def prompt_llm(query, **kwargs): + message = {"role": "user", "content": query} + return llm_client.chat.completions.create(messages=[message], **kwargs) +``` + +**Do this instead:** + +```python +# Cell 1 +llm_client = ... +``` + +```python +# Cell 2 +@mo.cache +def prompt_llm(query, **kwargs): + message = {"role": "user", "content": query} + return llm_client.chat.completions.create(messages=[message], **kwargs) +``` + +### Close over unhashable or un-pickleable arguments + +The [cache key](#cache-key) is constructed in part by hashing or pickle-ing function arguments. When you call a cached function with arguments that cannot be processed in this way, an exception will be raised. To parametrize cached functions with unhashable or un-pickleable arguments, syntactically close over them instead. + +**You can't do this:** +```python +# Cell 1 +@mo.cache +def query_database(query, engine): + return engine.execute(query) +``` + +```python +# This won't work because my_database_engine is not hashable +query_database("SELECT * FROM my_table", my_database_engine) +``` + +Instead, you can close over `my_database_engine`: + +**Do this:** + +```python +# Cell 1 +my_database_engine = ... +``` + +```python +# Cell 2 +@mo.cache +def query_database(query): + return my_database_engine.execute(query) +``` + +### Close-over low-memory-footprint variables + +Non-primitive closed-over variables are serialized for cache key generation. +When possible, compute derived values (like length) outside the cache block and +only use the small values inside. + +**Don't do this:** +```python +with mo.persistent_cache("bad example"): + length = len(my_very_large_dataset) + ... # uses length +``` + +**Do this instead:** +```python +length = len(my_very_large_dataset) # my_very_large_dataset is not needed for cache invalidation +``` + +```python +with mo.persistent_cache("good example"): + ... # uses length +``` + + +### Use `mo.watch.file` when working with files + +**Don't do this:** +```python +my_file = open("my_file.txt") +with mo.persistent_cache("my_file"): + data = my_file.read() + # Do something with data +my_file.close() +``` + +**Do this instead:** +```python +# Cell 1 +my_file = mo.watch.file("my_file.txt") +``` + +```python +# Cell 2 +with mo.persistent_cache("my_file"): + data = my_file.read() + # Do something with data +``` + +## API + +::: marimo.cache +::: marimo.lru_cache ::: marimo.persistent_cache + + diff --git a/docs/api/cli_args.md b/docs/api/cli_args.md index 0e9c1a5aa22..9f8ac02fec8 100644 --- a/docs/api/cli_args.md +++ b/docs/api/cli_args.md @@ -1,13 +1,34 @@ # Command Line Arguments -Use `mo.cli_args` to access command-line arguments passed to the notebook. This -allows you to pass arguments to the notebook that are not controllable by the -user. The arguments will be parsed from the command line when running the -notebook as an application with `marimo run` or `marimo edit`; they will also -be parsed from the command line when running as a script. +When running as a script with `python notebook.py`, command-line arguments +are available to your program in `sys.argv`, just like any other Python +program. This means you can use +[`argparse`](https://docs.python.org/3/library/argparse.html), +[`simple-parsing`](https://github.com/lebrice/SimpleParsing), and other tools +for specifying and parsing arguments. -Some examples passing command-line arguments to the notebook when running -as a script: +You can also use tools like `argparse` when running as a notebook (`marimo +edit` or `marimo run`) or exporting to HTML, IPYNB or another format (`marimo +export`). In these cases, `sys.argv` is set to the notebook filename followed by any args +following the `--` separator. + +For example, for + +```bash +marimo edit notebook.py -- --lr 1e-4 --epochs 15 +``` + +sets `sys.argv` to `["notebook.py", "--lr", "1e-4", "--epochs", "15"]`. + +For usage examples, see the guide on [running as a script](../guides/scripts.md). + +## Utility function for parsing arguments + +marimo provides a utility function called `mo.cli_args()` for parsing arguments +from strings into primitive data types (`int`, `bool`, `float`, `str`). +However, unlike `argparse` and `simple-parsing`, this function does not let you +declare your program's arguments, nor does it generate help text. **For these +reasons, we recommend using `argparse` or `simple-parsing` instead.** ```bash python notebook.py -- --arg1 value1 --arg2 value2 @@ -20,9 +41,6 @@ python notebook.py -- --arg1 10.5 --arg2 hello --arg2 world # mo.cli_args() == {'arg1': 10.5, 'arg2': ['hello', 'world']} ``` -In each example, `python` can be replaced as `marimo run` (for running as -an app) or `marimo edit` (for running as a notebook). - ::: marimo.cli_args !!! note "Query Parameters" diff --git a/docs/api/control_flow.md b/docs/api/control_flow.md index cfec9fdb649..5110639e23f 100644 --- a/docs/api/control_flow.md +++ b/docs/api/control_flow.md @@ -1,9 +1,19 @@ # Control flow -Use `mo.stop` to halt execution of a cell, and optionally output an object. -This function is useful for validating user input. +## Controlling when cells run + +* Use [`mo.stop`][marimo.stop] to halt execution of a cell when a condition is met. +* Combine [`mo.stop`][marimo.stop] with [`mo.ui.run_button`][marimo.ui.run_button] to gate execution on button click. +* Use [`mo.ui.refresh`][marimo.ui.refresh] to make cells run periodically. + +!!! tip "Lazy execution" + + In addition to these utilities, you can [configure the runtime to be lazy](../guides/expensive_notebooks.md#configure-how-marimo-runs-cells), marking cells as stale instead of automatically running them. ::: marimo.stop -Use [`mo.ui.refresh`][marimo.ui.refresh] to trigger other cells to run periodically, on a configurable -interval (or on click). +## Threading + +::: marimo.Thread + +::: marimo.current_thread diff --git a/docs/api/diagrams.md b/docs/api/diagrams.md index 259115c4c26..e1f2124610d 100644 --- a/docs/api/diagrams.md +++ b/docs/api/diagrams.md @@ -14,4 +14,55 @@ def __(): ## Mermaid diagrams +Customize Mermaid styling with `theme` and `theme_variables`: + +Theme only: + +```python +mo.mermaid( + diagram, + theme="neutral", +) +``` + +```python +mo.mermaid( + """ + graph TD + A[Observed] --> B[Latent] + B --> C[Posterior] + """, + theme="base", + theme_variables={ + "primaryColor": "#E8EEF5", + "primaryTextColor": "#1F2937", + "primaryBorderColor": "#64748B", + "lineColor": "#475569", + "tertiaryColor": "#F8FAFC", + }, +) +``` + +`theme` supports `"base"`, `"dark"`, `"default"`, `"forest"`, +`"neutral"`, and `"null"`. + +By default, `mo.mermaid(diagram)` follows the app light/dark theme. + +For supported `theme_variables` keys and defaults, see Mermaid's theming docs: + +- [Theme configuration](https://mermaid.js.org/config/theming.html) +- [Theme variables reference](https://mermaid.js.org/config/theming.html#theme-variables) + +Per Mermaid docs, custom `theme_variables` are reliably applied with +`theme="base"`. + +If you pass `theme_variables` with `theme=None`, marimo automatically uses +`theme="base"`. + +If you pass `theme_variables` with any explicit non-`base` theme, +`mo.mermaid(...)` raises a `ValueError`. + +If custom colors are not appearing, make sure you are not combining +`theme_variables` with a non-`base` theme. + ::: marimo.mermaid diff --git a/docs/api/index.md b/docs/api/index.md index 4d8f6b3c5c9..094f6a69190 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1,3 +1,7 @@ +--- +description: "Complete API reference for the marimo Python library: interactive inputs, markdown, layouts, media, control flow, and more." +--- + # API Reference Use the marimo library in marimo notebooks (`import marimo as mo`) to @@ -26,4 +30,5 @@ Use the marimo library in marimo notebooks (`import marimo as mo`) to | [state](state.md) | Synchronize multiple UI elements with `mo.state` | | [app](app.md) | Embed notebooks in other notebooks | | [cell](cell.md) | Run cells defined in another notebook | +| [watch](watch.md) | Reactively respond to file changes on disk | | [miscellaneous](miscellaneous.md) | Miscellaneous utilities | diff --git a/docs/api/inputs/anywidget.md b/docs/api/inputs/anywidget.md index 4397e9134e6..b6543a67df3 100644 --- a/docs/api/inputs/anywidget.md +++ b/docs/api/inputs/anywidget.md @@ -21,6 +21,7 @@ Here is an example using `drawdata`: # pip install drawdata from drawdata import ScatterWidget +# Don't forget to wrap the widget with marimo.ui.anywidget widget = mo.ui.anywidget(ScatterWidget()) # In another cell, you can access the widget's value @@ -32,10 +33,12 @@ widget.data_as_polars ``` For additional examples, see -[our repo](https://github.com/marimo-team/marimo/tree/main/examples/third_party/anywidget). +[our repo](https://github.com/marimo-team/marimo/tree/main/examples/third_party/anywidget) or our [widgets gallery](https://marimo.io/gallery/widgets). ## Custom widget +Anywidget lets you write custom widgets by adding JavaScript to your Python code. Below is an example of a counter widget. + ```python import anywidget import traitlets @@ -73,6 +76,7 @@ class CounterWidget(anywidget.AnyWidget): """ # Stateful property that can be accessed by JavaScript & Python + count = traitlets.Int(0).tag(sync=True) widget = mo.ui.anywidget(CounterWidget()) @@ -84,6 +88,14 @@ widget.value # You can also access the widget's specific properties widget.count ``` +### More examples + +If you're eager to build your own widgets and want to dive deeper you may enjoy these resources: + +- Before making your own widget, it would be best to check and see if the widget already exists. The [widgets section on the marimo gallery](https://marimo.io/gallery/widgets) as well as the [gallery on anywidget.dev](https://anywidget.dev/en/community/) give you a good overview of what's out there. +- The [wigglystuff repository](https://github.com/koaning/wigglystuff) has many smaller widgets that could serve as an excellent starting point. +- We've noticed that coding agents are getting better at generating these anywidgets on the fly. If you're keen to explore that, install our [official skills](../../guides/generate_with_ai/customize_your_agent.md#official-marimo-skills), which include a skill for generating anywidgets. +- You may also enjoy [this livestream on the marimo YouTube channel](https://www.youtube.com/watch?v=3V1r5sKnyz8) on building anywidgets. --- diff --git a/docs/api/inputs/button.md b/docs/api/inputs/button.md index 15fae0b2a6f..a4c60359bb2 100644 --- a/docs/api/inputs/button.md +++ b/docs/api/inputs/button.md @@ -4,15 +4,9 @@ If you're looking for a button to trigger computation on click, consider using [`mo.ui.run_button`][marimo.ui.run_button]. -/// marimo-embed - -```python -@app.cell -def __(): - mo.ui.button(label="Click me") - return -``` +/// marimo-embed-file + filepath: examples/ui/button.py /// -::: marimo.ui.button +::: marimo.ui.button \ No newline at end of file diff --git a/docs/api/inputs/chat.md b/docs/api/inputs/chat.md index 40073bcf913..dac291155e6 100644 --- a/docs/api/inputs/chat.md +++ b/docs/api/inputs/chat.md @@ -49,6 +49,23 @@ message string) attributes; `config` is a [`ChatModelConfig`][marimo.ai.ChatModelConfig] object with various configuration parameters, which you are free to ignore. +## Using Pydantic AI + +marimo has first class support for [pydantic-ai](https://ai.pydantic.dev). Use the [`Agent`](https://ai.pydantic.dev/agents) class to build your chatbot and the Chat UI will display reasoning steps, tool calls and more. + +```python +from pydantic_ai import Agent +import marimo as mo + +assistant = Agent( + "openai:gpt-5", + system_prompt="You are a helpful assistant.", +) + +chat = mo.ui.chat(mo.ai.llm.pydantic_ai(assistant)) +chat +``` + ## Using a Built-in AI Model You can use marimo's built-in AI models, such as OpenAI's GPT: @@ -75,7 +92,18 @@ chat.value ``` This returns a list of [`ChatMessage`][marimo.ai.ChatMessage] objects, each -containing `role`, `content`, and optional `attachments` attributes. +containing `id`, `role`, `parts` and `metadata` attributes. The `content` and `attachments` attributes are supported for basic models. + +???+ note + + For pydantic-ai, the messages are mapped to [Vercel UI messages](https://github.com/pydantic/pydantic-ai/blob/9aa6dd40efafd93c04c19c2ef5596a454906ca53/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/request_types.py). To convert to Pydantic messages, use the + adapter function. + + ```python + from pydantic_ai.ui.vercel_ai import VercelAIAdapter + + messages = VercelAIAdapter.load_messages(chat.value) + ``` ::: marimo.ai.ChatMessage @@ -132,11 +160,110 @@ mo.ui.chat( ) ``` +## Streaming Responses + +Chatbots can stream responses in real-time, creating a more interactive experience +similar to ChatGPT where you see the response appear word-by-word as it's generated. + +Responses from built-in models (OpenAI, Anthropic, Google, Groq, Bedrock) are streamed by default. + +### How Streaming Works + +marimo uses **delta-based streaming**, which follows the industry-standard pattern used by OpenAI, Anthropic, and other AI providers. Your generator function should yield **individual chunks** (deltas) of new content, which marimo automatically accumulates and displays progressively. + +### With Custom Models + +For custom models, you can use either regular (sync) or async generator functions that yield delta chunks: + +**Sync generator (simpler):** + +```python +import marimo as mo +import time + +def streaming_model(messages, config): + """Stream responses word by word.""" + response = "This response will appear word by word!" + words = response.split() + + for word in words: + yield word + " " # Yield delta chunks + time.sleep(0.1) # Simulate processing delay + +chat = mo.ui.chat(streaming_model) +chat +``` + +**Async generator (for async operations):** + +```python +import marimo as mo +import asyncio + +async def async_streaming_model(messages, config): + """Stream responses word by word asynchronously.""" + response = "This response will appear word by word!" + words = response.split() + + for word in words: + yield word + " " # Yield delta chunks + await asyncio.sleep(0.1) # Async processing delay + +chat = mo.ui.chat(async_streaming_model) +chat +``` + +Each `yield` sends a new chunk (delta) to marimo, which accumulates and displays +the progressively building response in real-time. + +!!! tip "Delta vs Accumulated" + **Yield deltas, not accumulated text.** Each yield should be **new content only**: + + ✅ **Correct (delta mode):** + ```python + yield "Hello" + yield " " + yield "world" + # Result: "Hello world" + ``` + + ❌ **Incorrect (accumulated mode, deprecated):** + ```python + yield "Hello" + yield "Hello " + yield "Hello world" + # Inefficient: sends duplicate content + ``` + + Delta mode is more efficient (reduces bandwidth by ~99% for long responses) and aligns with standard streaming APIs. + +!!! tip "See streaming examples" + For complete working examples, check out: + + - [`openai_example.py`](https://github.com/marimo-team/marimo/blob/main/examples/ai/chat/openai_example.py) - OpenAI chatbot with streaming (default) + - [`streaming_custom.py`](https://github.com/marimo-team/marimo/blob/main/examples/ai/chat/streaming_custom.py) - Custom streaming chatbot + ## Built-in Models marimo provides several built-in AI models that you can use with the chat UI element. +### Pydantic AI + +```python +import marimo as mo +from pydantic_ai import Agent + +assistant = Agent( + "openai:gpt-5", + system_prompt="You are a helpful assistant.", +) + +mo.ui.chat(mo.ai.llm.pydantic_ai(assistant)) +``` + +::: marimo.ai.llm.pydantic_ai + ### OpenAI ```python diff --git a/docs/api/inputs/code_editor.md b/docs/api/inputs/code_editor.md index 117b1b109b2..9b50351270b 100644 --- a/docs/api/inputs/code_editor.md +++ b/docs/api/inputs/code_editor.md @@ -1,14 +1,7 @@ # Code Editor -/// marimo-embed - -```python -@app.cell -def __(): - mo.ui.code_editor(label="Code Editor", language="python") - return -``` - +/// marimo-embed-file + filepath: examples/ui/code_editor.py /// ::: marimo.ui.code_editor diff --git a/docs/api/inputs/data_editor.md b/docs/api/inputs/data_editor.md new file mode 100644 index 00000000000..cb6457bb74b --- /dev/null +++ b/docs/api/inputs/data_editor.md @@ -0,0 +1,8 @@ +# Data editor + +/// marimo-embed-file + size: xlarge + filepath: examples/ui/data_editor.py +/// + +::: marimo.ui.data_editor diff --git a/docs/api/inputs/data_explorer.md b/docs/api/inputs/data_explorer.md index d9f79624d59..d74d4e8f9b9 100644 --- a/docs/api/inputs/data_explorer.md +++ b/docs/api/inputs/data_explorer.md @@ -15,13 +15,33 @@ The data explorer UI element outputs a visual editor explore your data via plott @app.cell def __(): import pandas as pd - import pyodide - csv = pyodide.http.open_url("https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv") - df = pd.read_csv(csv) + df = pd.read_csv("https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv") mo.ui.data_explorer(df) return ``` /// +To set an initial configuration, you can pass keyword arguments to `mo.ui.data_explorer`. For example, to start with `sepal_length` on the x-axis, `sepal_width` on the y-axis, and `species` as the color encoding: + +/// marimo-embed + size: large + app_width: full + +```python +@app.cell +def __(): + import pandas as pd + df = pd.read_csv("https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv") + mo.ui.data_explorer( + df, + x="sepal_length", + y="sepal_width", + color="species", + ) + return +``` + +/// + ::: marimo.ui.data_explorer diff --git a/docs/api/inputs/dataframe.md b/docs/api/inputs/dataframe.md index 0da34d04401..a1deb9fe83c 100644 --- a/docs/api/inputs/dataframe.md +++ b/docs/api/inputs/dataframe.md @@ -2,19 +2,29 @@ The dataframe UI element outputs a visual editor to apply "transforms" to a dataframe, such as filtering rows, applying group-bys and aggregations, and more. The transformed dataframe is shown below the transform editor. The UI output also includes the generated Python used to generate the resulting dataframe, which you can copy paste into a cell. You can programmatically access the resulting dataframe by accessing the element's `.value` attribute. -!!! note "Pandas or Polars Required" +!!! note "Dataframe library required" - In order to use the dataframe UI element, you must have the `pandas` or `polars` package installed. - You can install it with `pip install pandas` or `pip install polars`. + To use the dataframe UI element, you must have a supported dataframe library installed (e.g., Polars, Pandas, PyArrow, Ibis, DuckDB). Supported transforms are: - Filter Rows +- Select Columns - Rename Column - Column Conversion - Sort Column - Group By - Aggregate +- Sample Rows +- Shuffle Rows +- Explode Columns +- Expand Dict +- Unique +- Pivot + +!!! info "Request a Transform" + + Don't see a transform you need? You can [request a new dataframe transform](https://github.com/marimo-team/marimo/issues/new?title=New%20dataframe%20transform:&labels=enhancement&template=feature_request.yaml) on our GitHub repository. /// marimo-embed size: large @@ -25,9 +35,7 @@ Supported transforms are: @app.cell def __(): import pandas as pd - import pyodide - csv = pyodide.http.open_url("https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv") - df = pd.read_csv(csv) + df = pd.read_csv("https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv") mo.ui.dataframe(df) return ``` diff --git a/docs/api/inputs/file.md b/docs/api/inputs/file.md index d2e568c258c..57a340a2e17 100644 --- a/docs/api/inputs/file.md +++ b/docs/api/inputs/file.md @@ -5,7 +5,22 @@ ```python @app.cell def __(): - mo.vstack([mo.ui.file(kind="button"), mo.ui.file(kind="area")]) + file_button = mo.ui.file(kind="button") + file_area = mo.ui.file(kind="area") + return file_area, file_button + +@app.cell +def _(file_area, file_button, mo): + mo.vstack([file_button, file_area]) + return + +@app.cell +def _(file_area, file_button, mo): + # Access uploaded files + mo.vstack([ + mo.md(f"Button upload: {file_button.name() if file_button.value else 'No file'}"), + mo.md(f"Area upload: {file_area.name() if file_area.value else 'No file'}") + ]) return ``` diff --git a/docs/api/inputs/index.md b/docs/api/inputs/index.md index d7fe22a96ba..e8ccd019467 100644 --- a/docs/api/inputs/index.md +++ b/docs/api/inputs/index.md @@ -1,3 +1,7 @@ +--- +description: "marimo interactive UI inputs: sliders, dropdowns, tables, checkboxes, text fields, chat, and more. Build powerful notebook interfaces with marimo.ui." +--- + # Inputs marimo comes packaged with interactive UI elements that you can use to build @@ -21,6 +25,7 @@ powerful notebooks and apps. These elements are available in `marimo.ui`. | [`marimo.ui.file`][marimo.ui.file] | File uploads | | [`marimo.ui.file_browser`][marimo.ui.file_browser] | Browse files | | [`marimo.ui.form`][marimo.ui.form] | Create forms | +| [`marimo.ui.matrix`][marimo.ui.matrix] | Interactive matrix/vector editor | | [`marimo.ui.microphone`][marimo.ui.microphone] | Record audio | | [`marimo.ui.multiselect`][marimo.ui.multiselect] | Multiple selection | | [`marimo.ui.number`][marimo.ui.number] | Number inputs | @@ -45,6 +50,6 @@ automatically run with the element's latest value. | Integration | Description | |-------------|-------------| | [`marimo.ui.altair_chart`][marimo.ui.altair_chart] | Interactive Altair charts | +| [`marimo.ui.matplotlib`][marimo.ui.matplotlib] | Reactive Matplotlib plots | | [`marimo.ui.plotly`][marimo.ui.plotly] | Interactive Plotly charts | -| [`marimo.mpl.interactive`][marimo.mpl.interactive] | Interactive Matplotlib plots | | [`marimo.ui.anywidget`][marimo.ui.anywidget] | Custom widgets | diff --git a/docs/api/inputs/matrix.md b/docs/api/inputs/matrix.md new file mode 100644 index 00000000000..61a1a1a5a60 --- /dev/null +++ b/docs/api/inputs/matrix.md @@ -0,0 +1,53 @@ +# Matrix + +/// marimo-embed + +```python +@app.cell +def __(): + matrix = mo.ui.matrix( + [[1, 0, 0], [0, 1, 0], [0, 0, 1]], + min_value=-5, + max_value=5, + step=0.1, + precision=1, + label="$I$", + ) + matrix + return + +@app.cell +def __(): + matrix.value + return +``` + +/// + +`mo.ui.matrix` also supports 1D (vector) input: + +/// marimo-embed + +```python +@app.cell +def __(): + vector = mo.ui.matrix( + [1, 0, 0, 0, 0], + min_value=-5, + max_value=5, + step=0.1, + precision=1, + label="$\\vec{v}$", + ) + vector + return + +@app.cell +def __(): + vector.value + return +``` + +/// + +::: marimo.ui.matrix diff --git a/docs/api/inputs/run_button.md b/docs/api/inputs/run_button.md index c2e9b924a16..9c82af24b65 100644 --- a/docs/api/inputs/run_button.md +++ b/docs/api/inputs/run_button.md @@ -1,29 +1,7 @@ # Run Button -/// marimo-embed - size: medium - -```python -@app.cell -def __(): - b = mo.ui.run_button() - b - return - -@app.cell -def __(): - s = mo.ui.slider(1, 10) - s - return - -@app.cell -def __(): - mo.stop(not b.value, \"Click `run` to submit the slider's value\") - - s.value - return -``` - +/// marimo-embed-file + filepath: examples/ui/run_button.py /// ::: marimo.ui.run_button diff --git a/docs/api/inputs/tabs.md b/docs/api/inputs/tabs.md index 63a90bf8f3b..caa85b09861 100644 --- a/docs/api/inputs/tabs.md +++ b/docs/api/inputs/tabs.md @@ -1,37 +1,9 @@ # Tabs -/// marimo-embed +/// marimo-embed-file size: large - -```python -@app.cell -def __(): - import matplotlib.pyplot as plt - import numpy as np - - # Generate some random data - categories = ["A", "B", "C", "D", "E"] - values = np.random.rand(5) - - bar = plt.bar(categories, values) - plt.title("Random Bar Chart") - plt.xlabel("Categories") - plt.ylabel("Values") - None - return - -@app.cell -def __(): - mo.ui.tabs( - { - "📈 Sales": bar, - "📊 Subscriptions": bar, - "💻 Settings": mo.ui.text(placeholder="Key"), - } - ) - return -``` - + mode: edit + filepath: examples/ui/tabs.py /// ::: marimo.ui.tabs diff --git a/docs/api/inputs/text_area.md b/docs/api/inputs/text_area.md index e02c8ee0fe5..529b8c4499b 100644 --- a/docs/api/inputs/text_area.md +++ b/docs/api/inputs/text_area.md @@ -1,20 +1,7 @@ # Text Area -/// marimo-embed - -```python -@app.cell -def __(): - text_area = mo.ui.text_area(placeholder="Search...", label="Description") - return - -@app.cell -def __(): - mo.hstack([text_area, mo.md(f"Has value: {text_area.value}")]) - return/// - -``` - +/// marimo-embed-file + filepath: examples/ui/text_area.py /// ::: marimo.ui.text_area diff --git a/docs/api/layouts/accordion.md b/docs/api/layouts/accordion.md index 9095fc7f100..acf30188ac0 100644 --- a/docs/api/layouts/accordion.md +++ b/docs/api/layouts/accordion.md @@ -1,23 +1,9 @@ # Accordion -/// marimo-embed +/// marimo-embed-file size: medium - -```python -@app.cell -def __(): - mo.accordion( - { - "Door 1": mo.md("Nothing!"), - "Door 2": mo.md("Nothing!"), - "Door 3": mo.md( - "![goat](https://images.unsplash.com/photo-1524024973431-2ad916746881)" - ), - } - ) - return -``` - + mode: edit + filepath: examples/outputs/accordion.py /// ::: marimo.accordion diff --git a/docs/api/layouts/carousel.md b/docs/api/layouts/carousel.md index 44b59aff6f5..9ce39143fd1 100644 --- a/docs/api/layouts/carousel.md +++ b/docs/api/layouts/carousel.md @@ -10,7 +10,7 @@ def __(): mo.md("# Introduction"), "By the marimo team", mo.md("## What is marimo?"), - mo.md("![marimo moss ball](https://marimo.io/logo.png)"), + mo.md("![marimo moss ball](https://marimo.app/logotype-wide.svg)"), mo.md("## Questions?"), ]) return diff --git a/docs/api/layouts/index.md b/docs/api/layouts/index.md index 421153ffdb4..b56c69e731f 100644 --- a/docs/api/layouts/index.md +++ b/docs/api/layouts/index.md @@ -18,11 +18,14 @@ them but just render their children in a certain way. | [`marimo.lazy`][marimo.lazy] | Lazy load content | | [`marimo.left`][marimo.left] | Left-align content | | [`marimo.nav_menu`][marimo.nav_menu] | Create navigation menus | +| [`marimo.outline`][marimo.outline] | Display table of contents outline | | [`marimo.plain`][marimo.plain] | Display content without styling | | [`marimo.right`][marimo.right] | Right-align content | | [`marimo.routes`][marimo.routes] | Create page routing | +| [`marimo.stat`][marimo.stat] | Display statistics | | [`marimo.sidebar`][marimo.sidebar] | Create sidebars | | [`marimo.tree`][marimo.tree] | Create tree structures | +| [`marimo.json`][marimo.json] | Create JSON structures | | [`marimo.vstack`][marimo.vstack] | Stack elements vertically | ## Stateful diff --git a/docs/api/layouts/json.md b/docs/api/layouts/json.md new file mode 100644 index 00000000000..eebb66077c5 --- /dev/null +++ b/docs/api/layouts/json.md @@ -0,0 +1,17 @@ +# Json + +/// marimo-embed + +```python +@app.cell +def __(): + mo.json( + '["entry", "another entry", {"key": [0, 1, 2]}]', + label="A JSON in tree..", + ) + return +``` + +/// + +::: marimo.json diff --git a/docs/api/layouts/outline.md b/docs/api/layouts/outline.md new file mode 100644 index 00000000000..a2fc14f73c4 --- /dev/null +++ b/docs/api/layouts/outline.md @@ -0,0 +1,24 @@ +# Outline + +/// marimo-embed + +```python +@app.cell +def __(): + mo.md("# Header 1") + return + +@app.cell +def __(): + mo.md("## Header 2") + return + +@app.cell +def __(): + mo.outline(label="Table of Contents") + return +``` + +/// + +::: marimo.outline \ No newline at end of file diff --git a/docs/api/layouts/stat.md b/docs/api/layouts/stat.md new file mode 100644 index 00000000000..4344bf5aea9 --- /dev/null +++ b/docs/api/layouts/stat.md @@ -0,0 +1,71 @@ +# Stat + +/// marimo-embed + +```python +@app.cell +def _(): + active_users = mo.stat( + value="1.2M", + label="Active Users", + caption="12k from last month", + direction="increase" + ) + + revenue = mo.stat( + value="$4.5M", + label="Revenue", + caption="8k from last quarter", + direction="increase" + ) + + conversion = mo.stat( + value="3.8", + label="Conversion Rate", + caption="0.5 from last week", + direction="decrease", + ) + + mo.hstack([active_users, revenue, conversion], justify="center", gap="2rem") + return + +@app.cell +def _(): + import altair as alt + import polars as pl + + alt.renderers.set_embed_options(actions=False) + + df = pl.DataFrame( + { + "revenue": [30, 20, 70, 45], + "dates": ["01/01/2024", "01/03/2024", "01/06/2024", "01/09/2024"], + } + ) + + chart = ( + alt.Chart(df) + .mark_line(interpolate="monotone") + .encode( + x=alt.X("dates", axis=None), + y=alt.Y("revenue", axis=None), + tooltip=["dates", "revenue"], + ) + .properties(height=40, width=60, background="transparent") + .configure_view(strokeWidth=0) + ) + + mo.stat( + value=df["revenue"][-1], + label="Revenue", + caption="QoQ Growth", + direction="increase", + bordered=True, + slot=chart, + ) + return +``` + +/// + +::: marimo.stat diff --git a/docs/api/markdown.md b/docs/api/markdown.md index 6a5ece5b2bb..8fe41bbbe51 100644 --- a/docs/api/markdown.md +++ b/docs/api/markdown.md @@ -6,6 +6,15 @@ elements. ::: marimo.md +## Loading LaTeX macros + +You can load LaTeX macros using `mo.latex(filename=...)`. + +::: marimo.latex + +!!! warning "Side effects" + The `mo.latex()` function has side effects (registering the LaTeX macros) and should be used in the same cell as `import marimo`. Otherwise, the LaTeX macros may not be loaded before the cells that use them. + ## Icons We support rendering icons from [Iconify](https://icon-sets.iconify.design/). @@ -30,3 +39,17 @@ mo.ui.button( label='
Hover over me
' ) ``` + +## Rendering images + +You can render images from a local `public/` folder: + +```python +mo.md( + ''' + + ''' +) +``` + +See [Static files](../guides/outputs.md#static-files) for information about serving images and other static assets. diff --git a/docs/api/media/download.md b/docs/api/media/download.md index 1cc707c997d..47d7b03393a 100644 --- a/docs/api/media/download.md +++ b/docs/api/media/download.md @@ -1,19 +1,7 @@ # Download Media -/// marimo-embed - -```python -@app.cell -def __(): - download_txt = mo.download( - data="Hello, world!".encode("utf-8"), - filename="hello.txt", - mimetype="text/plain", - ) - download_txt - return -``` - +/// marimo-embed-file + filepath: examples/ui/download.py /// ::: marimo.download diff --git a/docs/api/media/image_compare.md b/docs/api/media/image_compare.md new file mode 100644 index 00000000000..690b95f224b --- /dev/null +++ b/docs/api/media/image_compare.md @@ -0,0 +1,32 @@ +# Image Compare + +/// marimo-embed + size: medium + +```python +@app.cell +def __(): + from PIL import Image, ImageDraw + + # A colorful "before" image, compared against its grayscale "after". + before_image = Image.new("RGB", (600, 400), "white") + _draw = ImageDraw.Draw(before_image) + for _x in range(0, 600, 40): + _draw.rectangle( + [_x, 0, _x + 20, 400], fill=(_x % 256, (_x * 2) % 256, 128) + ) + _draw.ellipse([200, 100, 400, 300], fill=(255, 140, 0)) + after_image = before_image.convert("L").convert("RGB") + + mo.image_compare( + before_image=before_image, + after_image=after_image, + value=30, + direction="horizontal", + ) + return +``` + +/// + +::: marimo.image_compare diff --git a/docs/api/media/index.md b/docs/api/media/index.md index cfb6a5b6461..3bb3ffc45bf 100644 --- a/docs/api/media/index.md +++ b/docs/api/media/index.md @@ -5,6 +5,7 @@ Use these functions to embed media in your outputs. | Function | Description | |----------|-------------| | [`marimo.image`](image.md) | Display images | +| [`marimo.image_compare`](image_compare.md) | Compare two images side by side | | [`marimo.audio`](audio.md) | Play audio files | | [`marimo.video`](video.md) | Play videos | | [`marimo.pdf`](pdf.md) | Display PDFs | diff --git a/docs/api/miscellaneous.md b/docs/api/miscellaneous.md index 6b7a56613a5..adffb45c066 100644 --- a/docs/api/miscellaneous.md +++ b/docs/api/miscellaneous.md @@ -5,6 +5,29 @@ ::: marimo.defs ::: marimo.refs + ::: marimo.notebook_dir -::: marimo.Thread +::: marimo.notebook_location + +## Inspect + +Use `mo.inspect()` to explore Python objects with a rich, interactive display of their attributes, methods, and documentation. + +### Example + +```python +import marimo as mo + +# Inspect a class +mo.inspect(list, methods=True) + +# Inspect an instance +my_dict = {"key": "value"} +mo.inspect(my_dict) + +# Show all attributes including private and dunder +mo.inspect(my_dict, all=True) +``` + +::: marimo.inspect diff --git a/docs/api/outputs.md b/docs/api/outputs.md index 63b47dad35f..cab89a0e341 100644 --- a/docs/api/outputs.md +++ b/docs/api/outputs.md @@ -11,6 +11,10 @@ programmatically, using `mo.output.replace()` and `mo.output.append()`. ::: marimo.output.replace + +!!! tip "**Watch `mo.output.replace` in action**" + See a demo of how `mo.output.replace` works in this [short YouTube video](https://youtube.com/shorts/tCMeQb-PqNU?si=7PeFzQJzNvXsLoXN). + ::: marimo.output.append ::: marimo.output.clear @@ -42,9 +46,16 @@ as an app. If you do want them to appear in apps, marimo provides utility functions for capturing console outputs and redirecting them to cell outputs. /// +::: marimo.output.clear_console + ::: marimo.redirect_stdout ::: marimo.redirect_stderr ::: marimo.capture_stdout ::: marimo.capture_stderr + +## Object inspection + +Use [`mo.inspect`][marimo.inspect] to create a rich display of Python objects +for exploring their attributes, methods, and documentation. diff --git a/docs/api/plotting.md b/docs/api/plotting.md index 9fa377bd132..b38dcc79d4e 100644 --- a/docs/api/plotting.md +++ b/docs/api/plotting.md @@ -6,6 +6,10 @@ as you normally would. For more information about plotting, see the [plotting guide](../guides/working_with_data/plotting.md). +## Reactive plots with matplotlib + +::: marimo.ui.matplotlib + ## Reactive charts with Altair /// marimo-embed @@ -14,19 +18,16 @@ For more information about plotting, see the [plotting guide](../guides/working_ ```python @app.cell async def __(): + import altair as alt import pandas as pd - import pyodide - import micropip import json - await micropip.install('altair') - import altair as alt return @app.cell def __(): - cars = pd.DataFrame(json.loads( - pyodide.http.open_url('https://vega.github.io/vega-datasets/data/cars.json').read() - )) + cars = pd.read_json( + 'https://vega.github.io/vega-datasets/data/cars.json' + ) chart = mo.ui.altair_chart(alt.Chart(cars).mark_point().encode( x='Horsepower', @@ -73,17 +74,26 @@ chart # You can now access chart.value to get the selected data Altair has a concept of [data](https://altair-viz.github.io/user_guide/data_transformers.html) transformers, which can be used to improve performance. -Such examples are: +Some examples are: -- pandas Dataframe has to be sanitized and serialized to JSON. -- The rows of a Dataframe might need to be sampled or limited to a maximum number. -- The Dataframe might be written to a `.csv` or `.json` file for performance reasons. +- pandas Dataframe has to be sanitized and serialized to JSON; +- the rows of a Dataframe might need to be sampled or limited to a maximum number; +- the Dataframe might be written to a `.csv` or `.json` file for performance reasons. -By default, Altair uses the `default` data transformer, which is the slowest in marimo. It is limited to 5000 rows (although we increase this to `20_000` rows as marimo can handle this). This includes the data inside the HTML that is being sent over the network, which can also be limited by marimo's maximum message size. +By default, Altair uses the `default` data transformer, which is the slowest in +marimo. It is limited to 5000 rows (although we increase this to `20_000` rows +as marimo can handle this). This includes the data inside the HTML that is +being sent over the network, which can also be limited by marimo's maximum +message size. -It is recommended to use the `marimo_csv` data transformer, which is the most performant and can handle the largest datasets: it converts the data to a CSV file which is smaller and can be sent over the network. This can handle up to +400,000 rows with no issues. +It is recommended to use the `marimo_csv` data transformer, which is the most +performant and can handle the largest datasets: it converts the data to a CSV +file which is smaller and can be sent over the network. This can handle up to ++400,000 rows with no issues. -When using `mo.ui.altair_chart`, we automatically set the data transformer to `marimo_csv` for you. If you are using Altair directly, you can set the data transformer using the following code: +When using `mo.ui.altair_chart`, we automatically set the data transformer to +`marimo_csv` for you. If you are using Altair directly, you can set the data +transformer using the following code: ```python import altair as alt @@ -92,10 +102,12 @@ alt.data_transformers.enable('marimo_csv') ## Reactive plots with Plotly -!!! warning "mo.ui.plotly only supports scatter plots, treemaps charts, and sunbursts charts." +!!! warning "Supported charts" + marimo can render any Plotly plot, but [`mo.ui.plotly`][marimo.ui.plotly] only - supports reactive selections for scatter plots, treemaps charts, and sunbursts charts. If you require other kinds of - selection, consider using [`mo.ui.altair_chart`][marimo.ui.altair_chart]. + supports reactive selections for scatter/scattergl plots, pure line charts, bar charts, + box plots, violin plots, strip charts, histograms, funnel charts, funnelarea charts, waterfall charts, heatmaps, treemaps, and sunburst charts. If you require other kinds of + selection, please [file an issue](https://github.com/marimo-team/marimo/issues). ::: marimo.ui.plotly @@ -106,13 +118,13 @@ alt.data_transformers.enable('marimo_csv') show_root_heading: true show_source: true -## Leafmap support +## Leafmap marimo supports rendering [Leafmap](https://leafmap.org/) maps using the `folium` and `plotly` backends. ## Other plotting libraries -You can use all the popular plotting libraries with marimo. Such as: +You can use all the popular plotting libraries with marimo, such as: - [Matplotlib](https://matplotlib.org/) - [Plotly](https://plotly.com/) diff --git a/docs/api/query_params.md b/docs/api/query_params.md index d9e80a5e812..c7c66ae5b21 100644 --- a/docs/api/query_params.md +++ b/docs/api/query_params.md @@ -1,5 +1,7 @@ # Query Parameters +Query parameters are key-value pairs appended to the end of a URL to pass data to the server or customize a request. + Use `mo.query_params` to access query parameters passed to the notebook. You can also use `mo.query_params` to set query parameters in order to keep track of state in the URL. This is useful for bookmarking or sharing a particular @@ -7,6 +9,42 @@ state of the notebook while running as an application with `marimo run`. ::: marimo.query_params +## Pydantic Models for Query Parameters + +One of the use cases for URL query parameters is to set the initial state of UI elements. + +Passing query parameters into a Pydantic model helps document and validate the parameters. + + +```python +import marimo as mo +from pydantic import BaseModel, Field + +class MyModel(BaseModel): + r: int = Field(28, ge=0, le=255, description="Red Channel") + g: int = Field(115, ge=0, le=255, description="Green Channel") + b: int = Field(97, ge=0, le=255, description="Blue Channel") + message: str = Field("
", description="Some text") + +model = MyModel(**mo.query_params().to_dict()) + +# UI with initial state from query params +r_slider = mo.ui.slider(start=0, stop=255, step=1, label="R", value=model.r) +g_slider = mo.ui.slider(start=0, stop=255, step=1, label="G", value=model.g) +b_slider = mo.ui.slider(start=0, stop=255, step=1, label="B", value=model.b) +``` + +In the next cell: (see [live](https://marimo.app/l/03egkc?g=255)) +```python +bg_color = f"rgb({r_slider.value},{g_slider.value},{b_slider.value})" +mo.vstack([ + r_slider, g_slider, b_slider, + mo.Html(model.message + bg_color).style(background_color=bg_color, text_align="center") +]) +``` + +When using [marimo apps mounted to FastAPI](../guides/deploying/programmatically.md), the Pydantic model can be passed into the API documentation for the main app. + !!! note "CLI arguments" You can also access command-line arguments passed to the notebook using diff --git a/docs/api/state.md b/docs/api/state.md index f24050e1f45..99780369164 100644 --- a/docs/api/state.md +++ b/docs/api/state.md @@ -1,10 +1,13 @@ # State -!!! warning "Advanced topic!" +!!! warning "Stop! Read the interactivity guide first!" + **Read the guide on [creating interactive + elements](../guides/interactivity.md)** before reading this one! - This API doc covers reactive state (`mo.state`), an advanced topic. +!!! warning "Advanced topic!" + This guide covers reactive state (`mo.state`), an advanced topic. - **You likely don't need reactive state**. UI elements already have built-in + **You likely don't need `mo.state`**. UI elements already have built-in state, their associated value, which you can access with their `value` attribute. For example, `mo.ui.slider()` has a value that is its current position on an interval, while `mo.ui.button()` has a value that can be configured to @@ -12,15 +15,13 @@ `False`. Additionally, interacting with UI elements bound to global variables [automatically executes cells](../guides/interactivity.md) that reference those variables, letting you react to changes by just reading their - `value` attributes. This functional paradigm is the preferred way of - reacting to UI interactions in marimo. So if you - think you need to use `mo.state`, make sure to first read the [guide on - interactivity](../guides/interactivity.md). Chances are, the reactive execution - built into UI elements will suffice. (For example, [you don't need reactive - state to handle a button click](../recipes.md#working-with-buttons).) - + `value` attributes. **This functional paradigm is the preferred way of + reacting to UI interactions in marimo.** **Chances are, the reactive + execution built into UI elements will suffice.** (For example, [you don't need + reactive state to handle a button click](../recipes.md#working-with-buttons).) That said, here are some signs you might need `mo.state`: + - you need to maintain historical state related to a UI element that can't be computed from its built-in `value` (_e.g._, all values the user has ever input into a form) @@ -28,9 +29,7 @@ interacting with either one controls the other) - you need to introduce cycles across cells - If one of these cases applies to you, then read on. `mo.state` lets you - make all kinds of interesting applications, but like mutable state in general, - it can complicate notebook development and has the potential to - introduce hard-to-find bugs. + **In over 99% of cases, you don't need and shouldn't use `mo.state`.** This + feature can introduce hard-to-find bugs. ::: marimo.state diff --git a/docs/api/status.md b/docs/api/status.md index 0186632bdf9..1f0714d7fa8 100644 --- a/docs/api/status.md +++ b/docs/api/status.md @@ -9,59 +9,18 @@ databases, or APIs. You can display a progress bar while iterating over a collection, similar to `tqdm`. -/// marimo-embed +/// marimo-embed-file size: medium - -```python -@app.cell -def __(): - rerun = mo.ui.button("Rerun") - rerun - return - -@app.cell -async def __(): - import asyncio - rerun - for _ in mo.status.progress_bar( - range(10), - title="Loading", - subtitle="Please wait", - show_eta=True, - show_rate=True - ): - await asyncio.sleep(0.5) - return -``` - + filepath: examples/outputs/progress_bar.py /// ::: marimo.status.progress_bar ## Spinner -/// marimo-embed +/// marimo-embed-file size: medium - -```python -@app.cell -def __(): - rerun = mo.ui.button("Rerun") - rerun - return - -@app.cell -async def __(): - import asyncio - rerun - with mo.status.spinner(title="Loading...") as _spinner: - await asyncio.sleep(1) - _spinner.update("Almost done") - await asyncio.sleep(1) - _spinner.update("Done") - return -``` - + filepath: examples/outputs/spinner.py /// ::: marimo.status.spinner diff --git a/docs/api/watch.md b/docs/api/watch.md new file mode 100644 index 00000000000..f47560c6e54 --- /dev/null +++ b/docs/api/watch.md @@ -0,0 +1,7 @@ +# Watch + + +::: marimo.watch.file + + +::: marimo.watch.directory diff --git a/docs/apps/embedding_numbers.py b/docs/apps/embedding_numbers.py index cb5e402a88a..85e7b414940 100644 --- a/docs/apps/embedding_numbers.py +++ b/docs/apps/embedding_numbers.py @@ -1,30 +1,30 @@ import marimo -__generated_with = "0.7.19" +__generated_with = "0.13.14" app = marimo.App() @app.cell -def __(mo): +def _(mo): mo.md( """ - # Embedding MNIST + # Embedding MNIST - This app shows how to use the function `pymde.preserve_neighbors` - to produce embeddings that highlight the local structure of your - data, using MNIST as a case study. In these embeddings similar - digits are near each other, and dissimilar digits are not near each other. + This app shows how to use the function `pymde.preserve_neighbors` + to produce embeddings that highlight the local structure of your + data, using MNIST as a case study. In these embeddings similar + digits are near each other, and dissimilar digits are not near each other. - ## Data - The data we'll embed are 70,000 28x28 grayscale images of handwritten - digits: - """ + ## Data + The data we'll embed are 70,000 28x28 grayscale images of handwritten + digits: + """ ) return @app.cell -def __(button, show_random_images): +def _(button, show_random_images): button show_random_images(5) @@ -32,14 +32,14 @@ def __(button, show_random_images): @app.cell -def __(mo): +def _(mo): button = mo.ui.button(label="Click this button") mo.md(f"{button} _to view another random sample of images._").center() - return button, + return (button,) @app.cell -def __(mo): +def _(mo): params = ( mo.md( """ @@ -61,11 +61,11 @@ def __(mo): ) params - return params, + return (params,) @app.cell -def __(params): +def _(params): if params.value is not None: embedding_dimension, constraint_type = ( params.value["embedding_dimension"], @@ -77,7 +77,7 @@ def __(params): @app.cell -def __(constraint_type, pymde): +def _(constraint_type, pymde): if constraint_type is not None: _constraints = { "Centered": pymde.Centered(), @@ -85,18 +85,11 @@ def __(constraint_type, pymde): } constraint = _constraints[constraint_type] - return constraint, + return (constraint,) @app.cell -def __( - compute_embedding, - constraint, - embedding_dimension, - mnist, - plt, - pymde, -): +def _(compute_embedding, constraint, embedding_dimension, mnist, plt, pymde): def show_embedding(): _, embedding = compute_embedding(embedding_dimension, constraint) pymde.plot(embedding, color_by=mnist.attributes["digits"]) @@ -105,11 +98,11 @@ def show_embedding(): show_embedding() if embedding_dimension is not None else None - return show_embedding, + return @app.cell -def __(mnist, pymde, torch): +def _(mnist, pymde, torch): embedding_cache = {} @@ -129,17 +122,17 @@ def compute_embedding(embedding_dim, constraint): value = (mde, X) embedding_cache[key] = value return value - return compute_embedding, embedding_cache + return (compute_embedding,) @app.cell -def __(pymde): +def _(pymde): mnist = pymde.datasets.MNIST() - return mnist, + return (mnist,) @app.cell -def __(mnist, plt, torch): +def _(mnist, plt, torch): def show_random_images(n_images): indices = torch.randperm(mnist.data.shape[0])[:n_images] images = mnist.data[indices].reshape((-1, 28, 28)) @@ -151,11 +144,11 @@ def show_random_images(n_images): ax.set_xticks([]) plt.tight_layout() return fig - return show_random_images, + return (show_random_images,) @app.cell -def __(): +def _(): import matplotlib.pyplot as plt import pymde import torch diff --git a/docs/apps/intro.py b/docs/apps/intro.py index 0bb9ae6b5fe..ab63bd1c004 100644 --- a/docs/apps/intro.py +++ b/docs/apps/intro.py @@ -1,40 +1,40 @@ import marimo -__generated_with = "0.0.6" +__generated_with = "0.13.14" app = marimo.App() @app.cell -def __(mo): - mo.md("# Welcome to marimo! 🌊🍃") +def _(mo): + mo.md("""# Welcome to marimo! 🌊🍃""") return @app.cell -def __(mo): +def _(mo): slider = mo.ui.slider(1, 22) - return slider, + return (slider,) @app.cell -def __(mo, slider): +def _(mo, slider): mo.md( f""" - marimo is a Python library for creating reactive and interactive - notebooks and apps. + marimo is a Python library for creating reactive and interactive + notebooks and apps. - Unlike traditional notebooks, marimo notebooks **run - automatically** when you modify them or - interact with UI elements, like this slider: {slider}. + Unlike traditional notebooks, marimo notebooks **run + automatically** when you modify them or + interact with UI elements, like this slider: {slider}. - {"##" + "🍃" * slider.value} - """ + {"##" + "🍃" * slider.value} + """ ) return @app.cell -def __(mo): +def _(mo): mo.accordion( { "A notebook or an app?": ( @@ -54,9 +54,9 @@ def __(mo): @app.cell -def __(): +def _(): import marimo as mo - return mo, + return (mo,) if __name__ == "__main__": diff --git a/docs/blocks.py b/docs/blocks.py deleted file mode 100644 index d733eabdcf2..00000000000 --- a/docs/blocks.py +++ /dev/null @@ -1,106 +0,0 @@ -import textwrap -import xml.etree.ElementTree as etree -from typing import Any, Dict, List, Union, cast -import urllib.parse - -from pymdownx.blocks import BlocksExtension # type: ignore -from pymdownx.blocks.block import Block, type_string_in # type: ignore - - -class MarimoEmbedBlock(Block): - NAME: str = "marimo-embed" - OPTIONS: Dict[str, List[Union[str, Any]]] = { - "size": [ - "medium", - type_string_in(["small", "medium", "large", "xlarge", "xxlarge"]), - ], - "app_width": ["wide", type_string_in(["wide", "full", "compact"])], - "mode": ["read", type_string_in(["read", "edit"])], - } - - def on_create(self, parent: etree.Element) -> etree.Element: - # Create container div - container = etree.SubElement(parent, "div") - container.set("class", "marimo-embed-container") - return container - - def on_add(self, block: etree.Element) -> etree.Element: - return block - - def on_end(self, block: etree.Element) -> None: - # Extract the Python code - code = block.text.strip() if block.text else "" - - # Remove ```python if present - if code.startswith("```python"): - code = code[9:] # first 9 characters are ```python - code = code[:-3] # last 3 characters are ``` - - # Dedent the code - code = textwrap.dedent(code) - - # Create iframe element - size: str = cast(str, self.options["size"]) - app_width: str = cast(str, self.options["app_width"]) - mode: str = cast(str, self.options["mode"]) - url = create_marimo_iframe(code=code, app_width=app_width, mode=mode) - - # Clear existing content - block.text = None - for child in block: - block.remove(child) - - # Add iframe - iframe = etree.SubElement(block, "iframe") - iframe.set("class", f"demo {size}") - iframe.set("src", url) - iframe.set( - "allow", - "camera; geolocation; microphone; fullscreen; autoplay; encrypted-media; picture-in-picture; clipboard-read; clipboard-write", - ) - iframe.set("width", "100%") - iframe.set("height", "400px") - iframe.set("frameborder", "0") - iframe.set("style", "display: block; margin: 0 auto;") - - def on_markdown(self) -> str: - return "raw" - - -def uri_encode_component(code: str) -> str: - return urllib.parse.quote(code, safe="~()*!.'") - - -def create_marimo_iframe( - *, - code: str, - mode: str = "read", - app_width: str = "wide", -) -> str: - header = "\n".join( - [ - "import marimo", - f'app = marimo.App(width="{app_width}")', - "", - ] - ) + "\n".join( - [ - "", - "@app.cell", - "def __():", - " import marimo as mo", - " return", - ] - ) - body = header + code - encoded_code = uri_encode_component(body) - return f"https://marimo.app/?code={encoded_code}&embed=true&mode={mode}" - - -class MarimoBlocksExtension(BlocksExtension): - def extendMarkdownBlocks(self, md: Any, block_mgr: Any) -> None: - block_mgr.register(MarimoEmbedBlock, self.getConfigs()) - - -def makeExtension(*args: Any, **kwargs: Any) -> MarimoBlocksExtension: - return MarimoBlocksExtension(*args, **kwargs) diff --git a/docs/blocks/__init__.py b/docs/blocks/__init__.py new file mode 100644 index 00000000000..7d3930d19c6 --- /dev/null +++ b/docs/blocks/__init__.py @@ -0,0 +1,179 @@ +import textwrap +import xml.etree.ElementTree as etree +from typing import Any, Dict, List, Union, cast + +import lzstring + +from pymdownx.blocks import BlocksExtension # type: ignore +from pymdownx.blocks.block import ( + Block, + type_boolean, + type_string, + type_string_in, +) # type: ignore + + +class BaseMarimoBlock(Block): + """Base class for marimo embed blocks""" + + OPTIONS: Dict[str, List[Union[str, Any]]] = { + "size": [ + "medium", + type_string_in(["small", "medium", "large", "xlarge", "xxlarge"]), + ], + "mode": ["edit", type_string_in(["read", "edit"])], + "show-chrome": [False, type_boolean], + } + + def on_create(self, parent: etree.Element) -> etree.Element: + container = etree.SubElement(parent, "div") + container.set("class", "marimo-embed-container") + return container + + def on_add(self, block: etree.Element) -> etree.Element: + return block + + def _create_iframe(self, block: etree.Element, url: str) -> None: + # Clear existing content + block.text = None + for child in block: + block.remove(child) + + # Add iframe + size: str = cast(str, self.options["size"]) + iframe = etree.SubElement(block, "iframe") + iframe.set("class", f"demo {size}") + iframe.set("src", url) + iframe.set( + "allow", + "camera; geolocation; microphone; fullscreen; autoplay; encrypted-media; picture-in-picture; clipboard-read; clipboard-write", + ) + iframe.set("width", "100%") + iframe.set("height", "400px") + iframe.set("frameborder", "0") + iframe.set("style", "display: block; margin: 0 auto;") + + def on_markdown(self) -> str: + return "raw" + + +class MarimoEmbedBlock(BaseMarimoBlock): + NAME: str = "marimo-embed" + OPTIONS: Dict[str, List[Union[str, Any]]] = { + **BaseMarimoBlock.OPTIONS, + "app_width": ["medium", type_string_in(["medium", "full", "compact"])], + } + + def on_end(self, block: etree.Element) -> None: + code = block.text.strip() if block.text else "" + if code.startswith("```python"): + code = code[9:] + code = code[:-3] + code = textwrap.dedent(code) + + app_width: str = cast(str, self.options["app_width"]) + mode: str = cast(str, self.options["mode"]) + show_chrome: bool = cast(bool, self.options["show-chrome"]) + url = create_marimo_wasm_url( + code=create_marimo_wasm_code(code=code, app_width=app_width), + mode=mode, + show_chrome=show_chrome, + ) + self._create_iframe(block, url) + + +class MarimoEmbedFileBlock(BaseMarimoBlock): + NAME: str = "marimo-embed-file" + OPTIONS: Dict[str, List[Union[str, Any]]] = { + **BaseMarimoBlock.OPTIONS, + "filepath": ["", type_string], + "show_source": ["true", type_string_in(["true", "false"])], + } + + def on_end(self, block: etree.Element) -> None: + filepath = cast(str, self.options["filepath"]) + if not filepath: + raise ValueError("File path must be provided") + + # Read from project root + try: + with open(filepath, "r", encoding="utf-8") as f: + code = f.read() + except FileNotFoundError: + raise ValueError(f"File not found: {filepath}") + + mode: str = cast(str, self.options["mode"]) + show_chrome: bool = cast(bool, self.options["show-chrome"]) + url = create_marimo_wasm_url( + code=code, mode=mode, show_chrome=show_chrome + ) + self._create_iframe(block, url) + + # Add source code section if enabled + show_source: str = cast(str, self.options.get("show_source", "true")) + if show_source == "true": + details = etree.SubElement(block, "details") + summary = etree.SubElement(details, "summary") + summary.text = f"Source code for `{filepath}`" + + # TODO: figure out syntax highlighting + # md_text = f"\n\n```python\n{code}\n```\n\n" + # result = self.md.htmlStash.store(self.md.convert(md_text)) + # container.text = result + + copy_paste_container = etree.SubElement(details, "p") + copy_paste_container.text = "Tip: paste this code into an empty cell, and the marimo editor will create cells for you" + + code_container = etree.SubElement(details, "pre") + code_container.set("class", "marimo-source-code") + code_block = etree.SubElement(code_container, "code") + code_block.set("class", "language-python") + code_block.text = code + + +_lz = lzstring.LZString() + + +def create_marimo_wasm_code( + *, + code: str, + app_width: str = "medium", +) -> str: + header = "\n".join( + [ + "import marimo", + f'app = marimo.App(width="{app_width}")', + "", + ] + ) + mo_cell = "\n".join( + [ + "", + "@app.cell", + "def __():", + " import marimo as mo", + " return", + ] + ) + + mo_at_bottom = "with app.setup:" in code + if mo_at_bottom: + return header + code + mo_cell + return header + mo_cell + code + + +def create_marimo_wasm_url( + code: str, mode: str = "edit", show_chrome: bool = False +) -> str: + compressed = _lz.compressToEncodedURIComponent(code) + return f"https://marimo.app/?embed=true&mode={mode}&show-chrome={'true' if show_chrome else 'false'}#code/{compressed}" + + +class MarimoBlocksExtension(BlocksExtension): + def extendMarkdownBlocks(self, md: Any, block_mgr: Any) -> None: + block_mgr.register(MarimoEmbedBlock, self.getConfigs()) + block_mgr.register(MarimoEmbedFileBlock, self.getConfigs()) + + +def makeExtension(*args: Any, **kwargs: Any) -> MarimoBlocksExtension: + return MarimoBlocksExtension(*args, **kwargs) diff --git a/docs/community.md b/docs/community.md index fc5182d3423..e6fe330eee2 100644 --- a/docs/community.md +++ b/docs/community.md @@ -9,6 +9,7 @@ We're building a community. Come hang out with us! - ✏️ [Start a GitHub Discussion](https://github.com/marimo-team/marimo/discussions) - 🦋 [Follow us on Bluesky](https://bsky.app/profile/marimo.io) - 🐦 [Follow us on Twitter](https://twitter.com/marimo_io) +- 🎥 [Subscribe on YouTube](https://www.youtube.com/@marimo-team) - 🕴️ [Follow us on LinkedIn](https://www.linkedin.com/company/marimo-io) ## Shields diff --git a/docs/examples.md b/docs/examples.md deleted file mode 100644 index 6b90e3c07f4..00000000000 --- a/docs/examples.md +++ /dev/null @@ -1,12 +0,0 @@ -# Examples - -We have a large [library of -examples](https://github.com/marimo-team/marimo/tree/main/examples) in our -repo. Each example [encapsulates its own Python dependencies in a package -sandbox](guides/editor_features/package_management.md), making it easy to run. -We've deployed some of these examples at our [public -gallery](https://marimo.io/@public). - -We spotlight projects from the community each week on [our Twitter](https://x.com/marimo_io); check out our -[spotlights repo](https://github.com/marimo-team/spotlights) for a running -archive, with links to notebooks. diff --git a/docs/examples/index.md b/docs/examples/index.md new file mode 100644 index 00000000000..0a477a4321b --- /dev/null +++ b/docs/examples/index.md @@ -0,0 +1,331 @@ +# Examples + +This page includes dozens of bite-sized how-to examples to help you get started +with marimo. Be sure to also read the [quickstart](../getting_started/index.md) and +the [user guide](../guides/index.md), especially the guide on [how marimo runs +cells](../guides/reactivity.md). + +!!! Tip "Get inspired at our gallery!" + + For inspirational examples, including embedding-driven + data labelers, Stanford-scientist authored tutorials, and more, + check out our [public gallery](https://marimo.io/gallery). + + +## Running cells + +
+ +- ⚡️ [**Basic execution**](running_cells/basics.md) + +- 🐞 [**Getting around multiple definition errors**](running_cells/multiple_definitions.md) + +- 🛑 [**Stop cells from running**](running_cells/stop.md) + +- 🖱️ [**Run cells on button click**](running_cells/run_button.md) + +- 🕓 [**Refresh on a timer**](running_cells/refresh.md) + +- ⏳ [**Run async functions**](running_cells/async_await.md) + +- 💾 [**Caching computations in memory**](running_cells/memory_cache.md) + +- 💾 [**Cache computations to persistent storage**](running_cells/persistent_cache.md) + +- 🐞 [**Using the debugger**](running_cells/debugging.md) + +- 🐍 [**Run notebooks as scripts**](../guides/scripts.md) + +
+ +## Visual Outputs + +
+ +- 📤 [**Cell outputs**](outputs/basic_output.md) + +- ✍️ [**Basic markdown**](outputs/basic_markdown.md) + +- 💬 [**Console outputs**](outputs/console_outputs.md) + +- 📋 [**Capturing console output**](outputs/capture_console_outputs.md) + +- 📈 [**Showing plots**](outputs/plots.md) + +- 🎥 [**Showing videos and other media**](../api/media/index.md) + +- 🎛️ [**Conditionally showing outputs**](outputs/conditional_output.md) + +- 🧩 [**Showing multiple outputs in one cell**](outputs/multiple_outputs.md) + +
+ +### Writing markdown + +
+ +- ⚡️ [**Python values in markdown**](markdown/dynamic_markdown.md) + + --- + + + +- 🪄 [**Mermaid diagrams**](markdown/mermaid.md) + + --- + + + +- 🚨 [**Admonitions**](markdown/admonitions.md) + + --- + + + +- 📂 [**Collapsible details**](markdown/details.md) + + --- + + + +- 😀 [**Emoji**](markdown/emoji.md) + +
+ +## Working with data + +### Dataframes + +marimo is designed for working with dataframes. Here are a few examples; see +the [dataframes guide](../guides/working_with_data/dataframes.md) for details. + +
+ +- 🧮 [**Interactive dataframe viewer**](outputs/dataframes.md) + + --- + + + +- 🔍 [**Select dataframe rows**](../api/inputs/table.md) + + --- + + + +- ✏️ [**Editable dataframe**](../api/inputs/data_editor.md) + + --- + + + +- 🛠️ [**Interactive dataframe transformer**](../api/inputs/dataframe.md) + + --- + + + +
+ +### SQL + +Here are some basic examples, see the [SQL +guide](../guides/working_with_data/sql.md) for more details. + +
+ +- 🦆 [**Query dataframes with DuckDB SQL**](../guides/working_with_data/sql.md#example) + +- 🛢️ [**SQLite, Postgres, and other engines**](../guides/working_with_data/sql.md#connecting-to-a-custom-database) + +
+ +### Plots + +See the [plotting guide](../guides/working_with_data/plotting.md) for a full +overview. + +
+ +- 📊 [**Selecting data with Altair**](../api/plotting.md#reactive-charts-with-altair) + + --- + + + +- 📉 [**Selecting data with Plotly**](../guides/working_with_data/plotting.md#plotly) + + --- + + + +- 🔭 [**Showing matplotlib plots**](outputs/plots.md) + +
+ +### Progress bars and status elements + +
+ +- 📶 [**Progress bar**](outputs/progress_bar.md) + + --- + + + +- 🌀 [**Loading spinner**](outputs/spinner.md) + + --- + + + +
+ + +### Layouts + +
+ +- 📐 [**Horizontal and vertical stacking**](outputs/stacks.md) + + --- + + + +- 📁 [**Accordion toggle**](../api/layouts/accordion.md) + + --- + + + +- 🗂️ [**Tabs**](../api/inputs/tabs.md) + + --- + + + +
+ +## Input elements + +### Basic input elements + +marimo has a large library of interactive UI elements, which you can use +without callbacks — just make sure to assign elements to global variables. See +the [API reference](../api/inputs/index.md) for a full list, and the [interactivity +guide](../guides/interactivity.md) for rules governing how UI elements work. + +
+ +- 🎚️ [**Slider**](../api/inputs/slider.md) + + --- + + + +- 🧾 [**Dropdown**](../api/inputs/dropdown.md) + + --- + + + +- 👆 [**Multi-select**](../api/inputs/multiselect.md) + + --- + + + +- 🔘 [**Radio buttons**](../api/inputs/radio.md) + + --- + + + +- ☑️ [**Checkbox**](../api/inputs/checkbox.md) + + --- + + + +- 📅 [**Date**](../api/inputs/dates.md) + + --- + + + +- 📁 [**File**](../api/inputs/file.md) + + --- + + + +- 🔤 [**Text input**](../api/inputs/text.md) + + --- + + + +- 📝 [**Text area**](../api/inputs/text_area.md) + + --- + + + +- 🧑‍💻 [**Code editor**](../api/inputs/code_editor.md) + + --- + + + +- 🔍 [**Table**](../api/inputs/table.md) + + --- + + + +- 🎙️ [**Microphone**](../api/inputs/microphone.md) + + --- + + + +- 💬 [**Chat**](../api/inputs/chat.md) + + --- + + + +- 🔢 [**Matrix**](../api/inputs/matrix.md) + + --- + + + +
+ +### Composite input elements + +Composite input elements let you create a single UI element from multiple +other UI elements. + +
+ +- 🧾 [**Form**](../api/inputs/form.md) + + --- + + + +- 🎒 [**Array**](../api/inputs/array.md) + + --- + + + +- 📖 [**Dictionary**](../api/inputs/dictionary.md) + + --- + + + +
diff --git a/docs/examples/markdown/admonitions.md b/docs/examples/markdown/admonitions.md new file mode 100644 index 00000000000..fbb9aeebc61 --- /dev/null +++ b/docs/examples/markdown/admonitions.md @@ -0,0 +1,9 @@ +# Admonitions + +marimo supports admonition blocks using the [PyMdown Extensions Blocks](https://facelessuser.github.io/pymdown-extensions/extensions/blocks/) syntax. See the [Admonition plugin docs](https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/admonition/) for the full format and options. + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/markdown/admonitions.py +/// diff --git a/docs/examples/markdown/details.md b/docs/examples/markdown/details.md new file mode 100644 index 00000000000..dd9c67cbdc8 --- /dev/null +++ b/docs/examples/markdown/details.md @@ -0,0 +1,8 @@ +# Details + + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/markdown/details.py +/// diff --git a/docs/examples/markdown/dynamic_markdown.md b/docs/examples/markdown/dynamic_markdown.md new file mode 100644 index 00000000000..496d40cad04 --- /dev/null +++ b/docs/examples/markdown/dynamic_markdown.md @@ -0,0 +1,7 @@ +# Dynamic markdown + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/markdown/dynamic_markdown.py +/// diff --git a/docs/examples/markdown/emoji.md b/docs/examples/markdown/emoji.md new file mode 100644 index 00000000000..300446c54d1 --- /dev/null +++ b/docs/examples/markdown/emoji.md @@ -0,0 +1,8 @@ +# Emoji + + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/markdown/emoji.py +/// diff --git a/docs/examples/markdown/mermaid.md b/docs/examples/markdown/mermaid.md new file mode 100644 index 00000000000..19070057f51 --- /dev/null +++ b/docs/examples/markdown/mermaid.md @@ -0,0 +1,8 @@ +# Mermaid + + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/markdown/mermaid.py +/// diff --git a/docs/examples/outputs/basic_markdown.md b/docs/examples/outputs/basic_markdown.md new file mode 100644 index 00000000000..67b8c3f2e27 --- /dev/null +++ b/docs/examples/outputs/basic_markdown.md @@ -0,0 +1,10 @@ +# Basic markdown + +marimo has [built-in support for markdown](../../guides/outputs.md#markdown). +The simplest way to write markdown is to use markdown cells. + +/// marimo-embed-file + size: xxlarge + mode: edit + filepath: examples/outputs/basic_markdown.py +/// diff --git a/docs/examples/outputs/basic_output.md b/docs/examples/outputs/basic_output.md new file mode 100644 index 00000000000..c9c613c8285 --- /dev/null +++ b/docs/examples/outputs/basic_output.md @@ -0,0 +1,7 @@ +# Cell output + +/// marimo-embed-file + size: xxlarge + mode: edit + filepath: examples/outputs/cell_output.py +/// diff --git a/docs/examples/outputs/capture_console_outputs.md b/docs/examples/outputs/capture_console_outputs.md new file mode 100644 index 00000000000..2727a29d1f4 --- /dev/null +++ b/docs/examples/outputs/capture_console_outputs.md @@ -0,0 +1,10 @@ +# Capture console outputs + +You can capture console outputs with marimo utility functions. Learn +more in the [API reference](https://docs.marimo.io/api/outputs/#console-outputs). + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/outputs/capture_console_outputs.py +/// diff --git a/docs/examples/outputs/conditional_output.md b/docs/examples/outputs/conditional_output.md new file mode 100644 index 00000000000..1b5df771ed1 --- /dev/null +++ b/docs/examples/outputs/conditional_output.md @@ -0,0 +1,8 @@ +# Conditionally showing outputs + + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/outputs/conditional_output.py +/// diff --git a/docs/examples/outputs/console_outputs.md b/docs/examples/outputs/console_outputs.md new file mode 100644 index 00000000000..f723ab6d419 --- /dev/null +++ b/docs/examples/outputs/console_outputs.md @@ -0,0 +1,7 @@ +# Console outputs + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/outputs/console_outputs.py +/// diff --git a/docs/examples/outputs/dataframes.md b/docs/examples/outputs/dataframes.md new file mode 100644 index 00000000000..5103ad69e44 --- /dev/null +++ b/docs/examples/outputs/dataframes.md @@ -0,0 +1,9 @@ +# Dataframes + +See the [dataframes guide](../../guides/working_with_data/dataframes.md) for more info. + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/outputs/dataframes.py +/// diff --git a/docs/examples/outputs/multiple_outputs.md b/docs/examples/outputs/multiple_outputs.md new file mode 100644 index 00000000000..cfa96fd0ac4 --- /dev/null +++ b/docs/examples/outputs/multiple_outputs.md @@ -0,0 +1,9 @@ +# Showing multiple outputs + +See the [API reference on outputs](../../api/outputs.md) for more info. + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/outputs/showing_multiple_outputs.py +/// diff --git a/docs/examples/outputs/plots.md b/docs/examples/outputs/plots.md new file mode 100644 index 00000000000..0ed95308a1f --- /dev/null +++ b/docs/examples/outputs/plots.md @@ -0,0 +1,9 @@ +# Showing plots + +See the [plotting guide](../../guides/working_with_data/plotting.md) for an in-depth overview. + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/outputs/plots.py +/// diff --git a/docs/examples/outputs/progress_bar.md b/docs/examples/outputs/progress_bar.md new file mode 100644 index 00000000000..1ddbcb28dcb --- /dev/null +++ b/docs/examples/outputs/progress_bar.md @@ -0,0 +1,7 @@ +# Progress bar + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/outputs/progress_bar.py +/// diff --git a/docs/examples/outputs/spinner.md b/docs/examples/outputs/spinner.md new file mode 100644 index 00000000000..35df8d40813 --- /dev/null +++ b/docs/examples/outputs/spinner.md @@ -0,0 +1,7 @@ +# Spinner + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/outputs/spinner.py +/// diff --git a/docs/examples/outputs/stacks.md b/docs/examples/outputs/stacks.md new file mode 100644 index 00000000000..f239e8e7617 --- /dev/null +++ b/docs/examples/outputs/stacks.md @@ -0,0 +1,7 @@ +# Horizontal and vertical stacking + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/outputs/stacks.py +/// diff --git a/docs/examples/running_cells/async_await.md b/docs/examples/running_cells/async_await.md new file mode 100644 index 00000000000..ed5793497e7 --- /dev/null +++ b/docs/examples/running_cells/async_await.md @@ -0,0 +1,9 @@ +# Run on button click + +marimo lets you use `await` to execute `async` functions. + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/running_cells/async_await.py +/// diff --git a/docs/examples/running_cells/basics.md b/docs/examples/running_cells/basics.md new file mode 100644 index 00000000000..e890ef43316 --- /dev/null +++ b/docs/examples/running_cells/basics.md @@ -0,0 +1,8 @@ +# Basic execution + +/// marimo-embed-file + size: xlarge + mode: edit + show-chrome: true + filepath: examples/running_cells/basics.py +/// diff --git a/docs/examples/running_cells/debugging.md b/docs/examples/running_cells/debugging.md new file mode 100644 index 00000000000..be76ab67b3a --- /dev/null +++ b/docs/examples/running_cells/debugging.md @@ -0,0 +1,9 @@ +# Debugging + +marimo lets you use PDB to debug cells. Insert a breakpoint by typing `breakpoint()`: + +```python +x = 0 +breakpoint() +1 / x +``` diff --git a/docs/examples/running_cells/memory_cache.md b/docs/examples/running_cells/memory_cache.md new file mode 100644 index 00000000000..8f1c5dc6ffc --- /dev/null +++ b/docs/examples/running_cells/memory_cache.md @@ -0,0 +1,10 @@ +# In-memory caching + +Use [`mo.cache`][marimo.cache] to cache the output of expensive functions in +memory. + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/running_cells/in_memory_cache.py +/// diff --git a/docs/examples/running_cells/multiple_definitions.md b/docs/examples/running_cells/multiple_definitions.md new file mode 100644 index 00000000000..e46006fc394 --- /dev/null +++ b/docs/examples/running_cells/multiple_definitions.md @@ -0,0 +1,18 @@ +# Getting around multiple definition errors + +marimo does not allow the same variable to be defined in multiple cells. This +restriction guarantees that the code on the page matches the outputs you see, +eliminates hidden state and hidden bugs, and lets marimo run your notebooks as +web apps and scripts, but it also has a learning curve. Here are some tips to +help you adapt. + +!!! Tip "**Learn more**" + + Check out our guide on [multiple definition errors](../../guides/understanding_errors/multiple_definitions.md) + to learn more, or watch our [YouTube video](https://www.youtube.com/watch?v=5TzAADGRfxU). + +/// marimo-embed-file + size: xxlarge + mode: edit + filepath: examples/running_cells/multiple_definitions.py +/// diff --git a/docs/examples/running_cells/persistent_cache.md b/docs/examples/running_cells/persistent_cache.md new file mode 100644 index 00000000000..ecb933ded17 --- /dev/null +++ b/docs/examples/running_cells/persistent_cache.md @@ -0,0 +1,10 @@ +# Persistent caching + +Use [`mo.persistent_cache`][marimo.persistent_cache] to cache the output of +expensive computations to persistent storage (like disk). + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/running_cells/persistent_cache.py +/// diff --git a/docs/examples/running_cells/refresh.md b/docs/examples/running_cells/refresh.md new file mode 100644 index 00000000000..fe934153170 --- /dev/null +++ b/docs/examples/running_cells/refresh.md @@ -0,0 +1,9 @@ +# Refresh cells on a timer + +Use [`mo.ui.refresh`][marimo.ui.refresh] to run a cell on a timer + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/ui/refresh.py +/// diff --git a/docs/examples/running_cells/run_button.md b/docs/examples/running_cells/run_button.md new file mode 100644 index 00000000000..73a0239e305 --- /dev/null +++ b/docs/examples/running_cells/run_button.md @@ -0,0 +1,9 @@ +# Run on button click + +Use [mo.ui.run_button][marimo.ui.run_button] to trigger cell execution on button click. + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/ui/run_button.py +/// diff --git a/docs/examples/running_cells/stop.md b/docs/examples/running_cells/stop.md new file mode 100644 index 00000000000..dabfbd88425 --- /dev/null +++ b/docs/examples/running_cells/stop.md @@ -0,0 +1,9 @@ +# Stopping execution + +Use [`mo.stop`][marimo.stop] to conditionally stop execution of a cell. + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/control_flow/stop_execution.py +/// diff --git a/docs/faq.md b/docs/faq.md index 1c13aa7e0ae..0c5ce66fd77 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -1,45 +1,9 @@ --- +description: "Frequently asked questions about marimo: how it differs from Jupyter, why reactive execution, deployment, and troubleshooting." hide: - navigation --- -# FAQ - -- [Choosing marimo](#choosing-marimo) - - [How is marimo different from Jupyter?](#faq-jupyter) - - [What problems does marimo solve?](#faq-problems) - - [How is marimo.ui different from Jupyter widgets?](#faq-widgets) -- [Using marimo](#using-marimo) - - [Is marimo a notebook or a library?](#faq-notebook-or-library) - - [What's the difference between a marimo notebook and a marimo app?](#faq-notebook-app) - - [How does marimo know what cells to run?](#faq-reactivity) - - [Does marimo slow my code down](#faq-overhead) - - [How do I prevent automatic execution from running expensive cells?](#faq-expensive) - - [How do I disable automatic execution?](#faq-lazy) - - [How do I use sliders and other interactive elements?](#faq-interactivity) - - [How do I add a submit button to UI elements?](#faq-form) - - [How do I write markdown?](#faq-markdown) - - [How do I display plots?](#faq-plots) - - [How do I prevent matplotlib plots from being cut off?](#faq-mpl-cutoff) - - [How do I display interactive matplotlib plots?](#faq-interactive-plots) - - [How do I display objects in rows and columns?](#faq-rows-columns) - - [How do I show cell code in the app view?](#faq-show-code) - - [How do I create an output with a dynamic number of UI elements?](#faq-dynamic-ui-elements) - - [Why aren't my `on_change` handlers being called?](#faq-on-change-called) - - [Why are my `on_change` handlers in an array all referencing the last element?](#faq-on-change-last) - - [Why aren't my brackets in SQL working?](#faq-sql-brackets) - - [How do I restart a notebook?](#faq-restart) - - [How do I reload modules?](#faq-reload) - - [How does marimo treat type annotations?](#faq-annotations) - - [How do I use dotenv?](#faq-dotenv) - - [What packages can I use?](#faq-packages) - - [How do I use marimo on a remote server?](#faq-remote) - - [How do I make marimo accessible on all network interfaces?](#faq-interfaces) - - [How do I use marimo behind JupyterHub?](#faq-jupyter-hub) - - [How do I use marimo with JupyterBook?](#faq-jupyter-book) - - [How do I deploy apps?](#faq-app-deploy) - - [Is marimo free?](#faq-marimo-free) - ## Choosing marimo @@ -87,7 +51,7 @@ relationships between cells, and automatically re-running cells as needed. In addition, marimo notebooks can serialize package requirements inline; marimo runs these "sandboxed" notebooks in temporary virtual environments, -making them [reproducible down to the packages]guides/editor_features/package_management.md). +making them [reproducible down to the packages](guides/editor_features/package_management.md). **Maintainability.** marimo notebooks are stored as pure Python programs (`.py` files). This lets you @@ -104,9 +68,9 @@ value. This is difficult to get working in Jupyter notebooks. marimo notebooks can be executed as Python scripts from the command-line (since they're stored as `.py` files). In contrast, this requires extra steps to do for Jupyter, such as copying and pasting the code out or using external -frameworks. In the future, we'll also let you import symbols (functions, -classes) defined in a marimo notebook into other Python programs/notebooks, -something you can't easily do with Jupyter. +frameworks. We also let you import symbols (functions, classes) defined in a +marimo notebook into other Python programs/notebooks, something you can't +easily do with Jupyter. **Shareability.** Every marimo notebook can double as an interactive web app, complete with UI @@ -127,7 +91,10 @@ synchronized with the Python kernel: no callbacks, no observers, no manually re-running cells.

- +

## Using marimo @@ -143,7 +110,7 @@ marimo is both a notebook and a library. - Use the _marimo library_ (`import marimo as mo`) in marimo notebooks. Write markdown with `mo.md(...)`, create stateful interactive elements with `mo.ui` (`mo.ui.slider(...)`), and - more. See the docs for an [API reference](https://docs.marimo.io/api/). + more. See the docs for an [API reference](./api/index.md). @@ -328,7 +295,7 @@ marimo tutorial layout -### How do I show cell code in the app view?(#faq-show-code) +### How do I show cell code in the app view? Use [`mo.show_code`][marimo.show_code]. @@ -349,6 +316,71 @@ use [`mo.ui.array`][marimo.ui.array] or For usage examples, see the [recipes for grouping UI elements together](recipes.md#grouping-ui-elements-together). + +### How do I let users interrupt a progress bar iteration? + +To create an interruptible progress bar, run the progress bar in its own thread, +and create a button that on change signals to the thread that it should exit. + +Example: + +```python +import marimo + +__generated_with = "0.20.1" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + import time + from threading import Event + return Event, mo, time + + +@app.cell +def _(Event): + cancelled = Event() + return (cancelled,) + + +@app.cell +def _(cancelled, mo): + cancel = mo.ui.button( + label="Interrupt the progress bar", on_change=lambda _: cancelled.set() + ) + cancel + return + + +@app.cell +def _(cancelled, mo, time): + def progress(total): + with mo.status.progress_bar(total=10) as pbar: + for _ in range(10): + if cancelled.is_set(): + pbar.update( + increment=0, subtitle="The user cancelled the iteration" + ) + break + # Sleep... or anything else that releases GIL + time.sleep(0.5) + pbar.update() + + return (progress,) + + +@app.cell +def _(mo, progress): + mo.Thread(target=progress, args=(10,)).start() + return + + +if __name__ == "__main__": + app.run() +``` + ### How do I restart a notebook? @@ -462,24 +494,49 @@ marimo. Instead, use `dotenv.load_dotenv(dotenv.find_dotenv(usecwd=True))`. You can use any Python package. marimo cells run arbitrary Python code. + + +### How do I use marimo with Claude Code or other AI agents? + +Use [marimo pair](https://marimo.io/pair), an agent skill that +gives agent CLIs like Claude Code, Codex, and OpenCode full access to a running +notebook: your agent can read variables, test logic in a scratchpad, run +cells, and add or remove them. The marimo editor also has [built-in AI +features](guides/editor_features/ai_completion.md), including a chat panel and +code completion. + ### How do I use marimo on a remote server? +> We recorded a video tutorial on how to use marimo on a remote server. Check it out [here](https://youtu.be/pam9Hw8rbaA). + Use SSH port-forwarding to run marimo on a remote server and connect to it from a browser on your local machine. Make sure -to pass the `--headless` flag when starting marimo on remote: +to pass the `--headless` flag when starting marimo on remote; on the remote +machine, we also recommend using a port other than marimo's default port, such +as 8080: + +_On the remote machine, run:_ ```bash -marimo edit --headless +marimo edit --headless --port 8080 ``` -You may also want to set a custom host and port: +or, if you want to set a custom host: ```bash marimo edit --headless --host 0.0.0.0 --port 8080 ``` +_On local, run:_ + +``` +ssh -N -L 3718:127.0.0.1:8080 REMOTE_USER@REMOTE_HOST +``` + +Then open `localhost:3718` in your browser. + ### How do I make marimo accessible on all network interfaces? @@ -494,8 +551,8 @@ marimo edit --host 0.0.0.0 ### How do I use marimo behind JupyterHub? -JupyterHub can be configured to launch marimo using the [`jupyter-marimo-proxy` -package](https://github.com/jyio/jupyter-marimo-proxy). +JupyterHub can be configured to launch marimo using the +[`marimo-jupyter-extension`](https://github.com/marimo-team/marimo-jupyter-extension). @@ -508,7 +565,22 @@ To include a marimo notebook in a JupyterBook, you can either export your notebook to an `ipynb` file, or export to `HTML`: 1. export to ipynb: `marimo export ipynb my_notebook.py -o my_notebook.ipynb --include-outputs` -2. export to HTML: `marimo export ipynb my_notebook.py -o my_notebook.html` +2. export to HTML: `marimo export html my_notebook.py -o my_notebook.html` + + + +### How do I preview notebooks on GitHub? + +Here are a few ways to preview notebooks on GitHub: + +1. Export notebooks to ipynb. +2. Install the marimo glance browser extension for +[Chrome](https://chromewebstore.google.com/detail/marimo-glance/emnkplkdlpojjembfbkdagibhmippjfg) or +[Firefox](https://addons.mozilla.org/en-US/firefox/addon/marimo-glance/), which +lets you replace a notebook's raw Python source on GitHub or GitLab with an +interactive notebook in one click. The marimo glance extension only works for notebooks compatible with [WebAssembly](guides/wasm.md). +3. Use [molab's built-in GitHub previewer](guides/molab.md#mirror-notebooks-from-github). + diff --git a/docs/getting_started/index.md b/docs/getting_started/index.md index 19e7bacf67d..7bb108232ca 100644 --- a/docs/getting_started/index.md +++ b/docs/getting_started/index.md @@ -1,3 +1,7 @@ +--- +description: "Get started with marimo: install the reactive Python notebook, learn the basics, and explore key concepts." +--- + # Getting Started These tutorials will help you get started with marimo diff --git a/docs/getting_started/installation.md b/docs/getting_started/installation.md index 9cff848d302..8573d322492 100644 --- a/docs/getting_started/installation.md +++ b/docs/getting_started/installation.md @@ -1,3 +1,7 @@ +--- +description: "Install marimo with pip, uv, or conda. Set up a virtual environment and get started with the reactive Python notebook." +--- + # Installation Before installing marimo, we recommend creating and activating a Python @@ -20,19 +24,35 @@ Before installing marimo, we recommend creating and activating a Python _Make sure the environment is activated before installing marimo and when using marimo._ Install other packages you may need, such as numpy, pandas, matplotlib, and altair, in this environment. When you're done, deactivate the environment - with `deactivate` in the terimnal. + with `deactivate` in the terminal. Learn more from the [official Python tutorial](https://docs.python.org/3/tutorial/venv.html#creating-virtual-environments). /// admonition | Using uv? type: tip -[uv](https://github.com/astral-sh/uv) is a next-generation Python package +[uv](https://docs.astral.sh/uv/) is a next-generation Python package installer and manager that is 10-100x faster than pip, and also makes it easy -to install Python and manage projects. With `uv`, creating a virtual -environment is as easy as `uv venv`. +to install Python and manage projects. Create a [uv +project](https://docs.astral.sh/uv/guides/projects/) with `uv init`; this +creates and manages a virtual environment for you behind-the-scenes. For +detailed information on using marimo with `uv`, see our [uv +guide](../guides/package_management/using_uv.md). +/// + +/// admonition | Use our editor extensions + type: tip + +Try our [VS Code +extension](https://marketplace.visualstudio.com/items?itemName=marimo-team.vscode-marimo), +which works in VS Code, Cursor, and other VS Code forks, or our [PyCharm +plugin](https://plugins.jetbrains.com/plugin/32416-marimo). /// + + +## Install with minimal dependencies + To install marimo, run the following in a terminal: /// tab | install with pip @@ -41,12 +61,24 @@ To install marimo, run the following in a terminal: pip install marimo ``` +To check if the install worked, run + +```bash +marimo tutorial intro +``` + /// /// tab | install with uv ```bash -uv pip install marimo +uv add marimo +``` + +To check if the install worked, run + +```bash +uv run marimo tutorial intro ``` /// @@ -57,14 +89,14 @@ uv pip install marimo conda install -c conda-forge marimo ``` -/// - To check if the install worked, run ```bash marimo tutorial intro ``` +/// + A tutorial notebook should open in your browser. /// admonition | Installation issues? @@ -72,3 +104,50 @@ A tutorial notebook should open in your browser. Having installation issues? Reach out to us [at GitHub](https://github.com/marimo-team/marimo/issues) or [on Discord](https://marimo.io/discord?ref=docs). /// + +## Install with recommended dependencies + +marimo is lightweight, with few dependencies, to maximize compatibility with +your own environments. + +To unlock additional features in the marimo editor, including SQL cells, +AI completion, server-side plotting of dataframe columns, and more, we +suggest installing `marimo[recommended]`: + +/// tab | install with pip + +```bash +pip install "marimo[recommended]" +``` + +/// + +/// tab | install with uv + +```bash +uv add "marimo[recommended]" +``` + +/// + +/// tab | install with conda + +```bash +conda install -c conda-forge marimo "duckdb>=1.0.0" "altair>=5.4.0" pyarrow "polars>=1.9.0" "sqlglot[c]>=23.4" "openai>=1.55.3" "ruff" "nbformat>=5.7.0" "vegafusion>=2.0.0" "vl-convert-python>=1.0.0" +``` + +/// + +Installing marimo in this way installs the following additional dependencies and unlocks the following features: + +| Dependency | Feature | +|----------------------------|---------------------------------| +| duckdb>=1.0.0 | SQL cells | +| altair>=5.4.0 | Plotting in datasource viewer | +| polars[pyarrow]>=1.9.0 | SQL output back in Python | +| sqlglot[c]>=23.4 | SQL cells parsing | +| openai>=1.55.3 | AI features | +| ruff | Formatting | +| nbformat>=5.7.0 | Export as IPYNB | +| vegafusion>=2.0.0 | Performant charting | +| vl-convert-python>=1.0.0 | Required by vegafusion | diff --git a/docs/getting_started/key_concepts.md b/docs/getting_started/key_concepts.md index 70bee7418b7..25b74c52539 100644 --- a/docs/getting_started/key_concepts.md +++ b/docs/getting_started/key_concepts.md @@ -1,3 +1,7 @@ +--- +description: "Learn marimo's key concepts: reactive notebooks, interactive elements, Python scripts, web apps, and how they work together." +--- + # Key concepts This page covers marimo's key concepts: @@ -12,7 +16,7 @@ elements in a reproducible **notebook environment**. marimo notebooks are **reactive**: they automatically react to your code changes and UI interactions and keep your notebook up-to-date, not unlike a -spreadsheet. This makes your notebooks reproducibile, [eliminating hidden +spreadsheet. This makes your notebooks reproducible, [eliminating hidden state](../faq.md#faq-problems); it's also what enables marimo notebooks to double as apps and Python scripts. @@ -52,7 +56,10 @@ variables defined by that cell._ This is reactive execution.
- +
@@ -94,7 +101,7 @@ For more on reactive execution, open the dataflow tutorial marimo tutorial dataflow ``` -or read the [reactivity guide](../guides/reactivity.md). +or read the [reactivity guide](../guides/reactivity.md). To visualize and understand how data flows through your notebook, check out our [dataflow tools](../guides/editor_features/dataflow.md). ### Visualizing outputs @@ -106,7 +113,10 @@ other marimo elements to build rich composite outputs:
- +
@@ -116,7 +126,7 @@ The marimo library also comes with elements for laying out outputs, including [`mo.hstack`][marimo.hstack], [`mo.vstack`][marimo.vstack], [`mo.accordion`][marimo.accordion], [`mo.ui.tabs`][marimo.ui.tabs], [`mo.sidebar`][marimo.sidebar], [`mo.nav_menu`][marimo.nav_menu], [`mo.ui.table`][marimo.ui.table], -and [many more](https://docs.marimo.io/api/layouts/index.html). +and [many more](../api/layouts/index.md). For more on outputs, try these tutorials: @@ -137,7 +147,8 @@ that can wrap other UI elements.
- +
diff --git a/docs/getting_started/quickstart.md b/docs/getting_started/quickstart.md index c7a5be24358..fdb0b5071a4 100644 --- a/docs/getting_started/quickstart.md +++ b/docs/getting_started/quickstart.md @@ -1,3 +1,7 @@ +--- +description: "Quickstart guide for marimo: create notebooks, run tutorials, execute as apps and scripts, and convert Jupyter notebooks." +--- + # Quickstart Installing marimo gets you the `marimo` command-line interface (CLI), the entry @@ -11,6 +15,10 @@ point to all things marimo. marimo tutorial --help ``` +!!! tip "See marimo in action on YouTube" + + The [marimo concepts playlist](https://www.youtube.com/watch?v=3N6lInzq5MI&list=PLNJXGo8e1XT9jP7gPbRdm1XwloZVFvLEq) on our [YouTube channel](https://www.youtube.com/@marimo-team) gives an overview of many features. + ## Edit notebooks Create and edit notebooks with `marimo edit`. @@ -31,21 +39,41 @@ marimo edit your_notebook.py (If `your_notebook.py` doesn't exist, marimo will create a blank notebook named `your_notebook.py`.) +## Pair with AI + +Pair with AI agents like Claude Code, Codex, or OpenCode using [marimo +pair](https://marimo.io/pair), which transforms marimo from a notebook into a +collaborative canvas for computation that yields a reproducible software artifact. + ## Deploy as apps -Use `marimo run` to serve your notebook as an app, with Python code hidden and -uneditable. +Use `marimo run` to [serve your notebook as an app](../guides/apps.md), with +Python code hidden and uneditable. ```bash marimo run your_notebook.py ``` -## Convert from Jupyter to marimo +## Run as scripts + +Run your notebook as a script with + +```python +python your_notebook.py +``` + +You can also [pass CLI args](../guides/scripts.md) to your notebook. + +## Convert Jupyter notebooks and Python scripts to marimo -Automatically convert Jupyter notebooks to marimo notebooks with `marimo convert`: +Automatically convert Jupyter notebooks and Python scripts to marimo notebooks with `marimo convert`: ```bash +# From Jupyter notebook marimo convert your_notebook.ipynb -o your_notebook.py + +# From Python script or jupytext py:percent format +marimo convert your_script.py -o your_notebook.py ``` Then open the notebook with `marimo edit your_notebook.py` @@ -56,7 +84,7 @@ Then open the notebook with `marimo edit your_notebook.py` startup), you can disable autorun on startup via [user configuration](../guides/configuration/runtime_configuration.md). 1. Type `marimo config show` to get the location of your config file. - 2. If no config file exists, create it at `~/.marimo.toml` or `$XDG_CONFIG_HOME/marimo/marimo.toml`. + 2. If no config file exists, create it at `$XDG_CONFIG_HOME/marimo/marimo.toml`. 3. Update your config to include the following: ```toml title="marimo.toml" @@ -72,8 +100,8 @@ Use marimo export ``` -to convert marimo notebooks to other file formats, including HTML, IPYNB, -and markdown. +to [export marimo notebooks](../guides/exporting/index.md) to other file formats, +including HTML, IPYNB, and markdown. ## Install optional dependencies for more features @@ -97,7 +125,7 @@ pip install "marimo[recommended]" /// tab | install with uv ```bash -uv pip install "marimo[recommended]" +uv add "marimo[recommended]" ``` /// @@ -125,19 +153,30 @@ _Get started with Copilot_: _Note_: Copilot is not yet available in our conda distribution; please install marimo from `PyPI` if you need Copilot. -marimo also comes with support for [other copilots](../guides/editor_features/ai_completion.md#codeium-copilot), -and a built-in [AI assistant](../guides/editor_features/ai_completion.md#generate-code-with-our-ai-assistant) that helps you write code. +marimo also comes with support for [other copilots](../guides/editor_features/ai_completion.md#custom-copilots), +and a built-in [AI assistant](../guides/editor_features/ai_completion.md) that helps you write code. + +## Share links to cloud notebooks + +Use [molab](https://molab.marimo.io/notebooks), a cloud-based marimo notebook +service similar to Google Colab, to create and share notebook links +([docs](../guides/molab.md)). + +## Editor extensions + +**VS Code/Cursor.** You can edit and run marimo notebooks in VS Code or Cursor using our +extension; this provides a user interface that's similar to VS Code +Jupyter, but with marimo's reactive execution, interactive elements, +built-in package management, Git-friendly file format, and more. + +Install the extension by searching "marimo" in the extensions sidebar +(`Cmd/Ctrl-Shift-P`, type "install extension", then search "marimo") +or from the [VS Code marketplace site](https://marketplace.visualstudio.com/items?itemName=marimo-team.vscode-marimo). -## Try our VS Code extension + -The best way to use marimo is through the CLI. However, if you prefer VS Code -over terminal, try our [VS Code -extension](https://marketplace.visualstudio.com/items?itemName=marimo-team.vscode-marimo). -Use this extension to edit and run notebooks directly from VS Code, and to list -all marimo notebooks in your current directory. +**PyCharm.** Our [PyCharm +plugin](https://plugins.jetbrains.com/plugin/32416-marimo), available on the +JetBrains Marketplace, brings the full marimo editor inside PyCharm, letting you open, edit, and +run notebooks without leaving your IDE. -
-
-VS Code extension for marimo -
-
diff --git a/docs/guides/apps.md b/docs/guides/apps.md index b3e46a05862..bd004e66483 100644 --- a/docs/guides/apps.md +++ b/docs/guides/apps.md @@ -1,3 +1,7 @@ +--- +description: "Deploy marimo notebooks as interactive web apps. Customize layouts with drag-and-drop, add authentication, and share with users." +--- + # Run as an app The marimo CLI lets you run any notebook as an app: `marimo run` lays out @@ -17,12 +21,31 @@ marimo run notebook.py View the [CLI documentation](../cli.md#marimo-run) for more details. +### Gallery + +You can run multiple notebooks (or a directory of notebooks) as a gallery: + +```bash +marimo run folder/ +marimo run notebook_a.py notebook_b.py folder/ +``` + +This shows a page with one card per notebook. Cards can use notebook OpenGraph metadata for the title, description, and thumbnail image. Configure [OpenGraph previews](publishing/opengraph.md) and optionally generate images with [Thumbnails](publishing/thumbnails.md). + +If you run a single folder with watch mode (`marimo run folder/ --watch`), the gallery index is refreshed on subsequent workspace requests so file additions and deletions show up after refreshing the gallery page. See [Using your own editor](editor_features/watching.md) for watch behavior and security considerations. + ## Layout While editing a notebook with `marimo edit`, you can preview the notebook as an app by clicking the preview button in the bottom-right of the editor. (You can also use the command palette.) +!!! note "`layouts` folder" + marimo saves metadata about your constructed layout in a `layouts` folder; + make sure to include this folder when sharing or deploying your notebook + so that others can reconstruct your layout. Include this folder in version + control. + ### Vertical layout The default layout is the vertical layout: cell outputs are concatenated @@ -53,19 +76,76 @@ Enable the grid editor in the app preview, via a dropdown:
Grid layout lets you drag and drop outputs to construct your app
-marimo saves metadata about your constructed layout in a `layouts` folder; -make sure to include this folder when sharing your notebook so that others -can reconstruct your layout. - ### Slides layout If you prefer a slideshow-like experience, you can use the slides layout. Enable the slides layout in the app preview, via the same dropdown as above. -Unlike the grid layout, the slides are much less customizable: + + +#### Features + +- A slide minimap on the left where you can drag and drop slides to rearrange them. +- A config sidebar on the right where you can configure the type of each slide. +- Edit code and run cells by clicking the Code toggle or pressing `C`. +- Add speaker notes at the bottom of each slide and launch speaker view by pressing `S`. +- Powered by [reveal.js](https://revealjs.com/), so you can use most of its features like keyboard shortcuts, navigation, etc. + +#### Styling slides + +The slides layout is rendered with [reveal.js](https://revealjs.com/), so you +can brand a deck with a custom CSS file (see [Theming](configuration/theming.md)) +targeted at reveal.js's own classes. + +Target `.reveal-viewport` for deck-wide styles, like a background or a logo +that appears on every slide: + +```css +.reveal-viewport { + background-color: #faf8f4; +} + +/* Logo pinned to the top-right of every slide */ +.reveal-viewport::after { + content: ""; + position: absolute; + top: 1.25rem; + right: 1.5rem; + width: 7.5rem; + height: 2.5rem; + background-image: url("./logo.png"); + background-repeat: no-repeat; + background-position: right center; + background-size: contain; + z-index: 40; +} +``` + +To target a specific slide, use either its position or a [named cell](configuration/theming.md#targeting-cells): + +```css +/* By position (stable as long as the cell order doesn't change) */ +.reveal .slides > section:first-of-type { /* ... */ } +.reveal .slides > section:nth-of-type(3) { /* ... */ } + +/* By named cell (best when you only care about one specific cell) */ +[data-cell-name="title"] { /* ... */ } +``` + +For a full-bleed background on a single slide, style `.reveal-viewport` with +`:has()` rather than the `
` directly — reveal.js letterboxes slide +content, so painting only the `
` can leave margins around it: + +```css +.reveal-viewport:has(.slides > section.present [data-cell-name="title"]) { + background: linear-gradient(145deg, #1c1917 0%, #292524 45%, #1f2937 100%); +} +``` + +#### Notes - The order of the slides is determined by the order of the cells in the notebook. -- The slides do not support drag-and-drop rearrangement or resizing. -- All outputs are shown and all code is hidden. +- For PDF export, use `marimo export pdf notebook.py --as=slides --raster-server=live` for slide-style output with better capture compatibility. If you need more control over the layout, please file an issue on [GitHub](https://github.com/marimo-team/marimo/issues), so we can properly prioritize this feature. diff --git a/docs/guides/best_practices.md b/docs/guides/best_practices.md index 5bf132dee1a..bf03fd0ee8f 100644 --- a/docs/guides/best_practices.md +++ b/docs/guides/best_practices.md @@ -66,6 +66,9 @@ instead of mutating existing ones. ```python extended_list = l + [new_item()] ``` +**Don't use state and `on_change` handlers.** Don't use `on_change` handlers +to react to UI interactions. Instead, use marimo's built-in [reactive execution +for interactive elements](../guides/interactivity.md). **Write idempotent cells.** Write cells whose outputs and behavior are the same diff --git a/docs/guides/coming_from/jupyter.md b/docs/guides/coming_from/jupyter.md index 112d5300658..0dd0702feb8 100644 --- a/docs/guides/coming_from/jupyter.md +++ b/docs/guides/coming_from/jupyter.md @@ -3,14 +3,14 @@ If you're coming from Jupyter, here are a few tips to help you adapt to marimo notebooks. -## Adapting to marimo's execution model +## How marimo runs cells -The biggest difference between marimo and Jupyter is the execution model. +The biggest difference between marimo and Jupyter is the [execution model](../reactivity.md). A **Jupyter** notebook is a **REPL**: you execute blocks of code one at a time, and Jupyter has no understanding of how different blocks are related to each other. As a result a Jupyter notebook can easily -accumulate "hidden state" (and hidden bugs) --- you might accidentally execute +accumulate **"hidden state"** (and hidden bugs) --- you might accidentally execute cells out of order, or you might run (or delete) a cell but forget to re-run cells that depended on its variables. Because of this, Jupyter notebooks suffer from a [reproducibility crisis](../../faq.md#faq-problems), with over @@ -76,7 +76,7 @@ def __(): For more tips on adapting to marimo's execution model, see our guide on [working with expensive notebooks](../expensive_notebooks.md). -## Adapting to marimo's restriction on redefining variables +## Redefining variables marimo "compiles" your notebook cells into a directed graph on cells, linked by variable declarations and references, reusing this graph to @@ -125,19 +125,37 @@ augmented_df = df augmented_df["another_column"] = [3, 4] ``` -## Adapting to marimo's file format +To learn how to write Pandas/Polars code in a functional style, +which is more amenable to marimo's execution model, check out +[this YouTube video](https://youtu.be/J0PJpdU7c4g). + +## marimo's file format marimo stores notebooks as Python, not JSON. This lets you version notebooks with git, [execute them as scripts](../scripts.md), and import named cells into other Python files. However, it does mean that your notebook outputs (e.g., plots) are not stored in the file. -If you'd like to keep a visual record of your notebook work, [enable -the "Auto-download as HTML" setting](../configuration/index.md), which will -periodically snapshot your notebook as HTML to a `__marimo__` folder in the -notebook directory. +### Store notebook outputs + +To keep a visual record of your notebook work, [enable +the "Auto-download as HTML/IPYNB" setting](../configuration/index.md), which will +periodically snapshot your notebook as HTML or ipynb to a `__marimo__` folder +in the notebook directory. You can also export to ipynb from marimo's CLI, +using the `marimo export` command. + +### Preview notebook outputs on GitHub -### Converting Jupyter notebooks to marimo notebooks +Check in exported ipynb files to version control to view notebook outputs on GitHub. + +You can also install the marimo glance +[Chrome](https://chromewebstore.google.com/detail/marimo-glance/emnkplkdlpojjembfbkdagibhmippjfg) +or [Firefox](https://addons.mozilla.org/en-US/firefox/addon/marimo-glance/) +extensions to render live interactive previews of notebooks on GitHub, which +can work well for inexpensive and self-contained notebooks. (The extension renders notebooks +[with WebAssembly](../wasm.md), so not all notebooks will work.) + +### Convert Jupyter notebooks to marimo notebooks Convert Jupyter notebooks to marimo notebooks at the command-line: @@ -145,7 +163,36 @@ Convert Jupyter notebooks to marimo notebooks at the command-line: marimo convert your_notebook.ipynb -o your_notebook.py ``` -## Adapting to the absence of magic commands +### Convert Python scripts to marimo notebooks + +marimo can also convert regular Python scripts to marimo notebooks: + +```bash +marimo convert your_script.py -o your_notebook.py +``` + +This supports: +- **py:percent format**: If your script uses `# %%` cell markers, marimo will convert it to a multi-cell notebook (requires jupytext) +- **Regular Python scripts**: Scripts without cell markers are converted to a single-cell notebook + +For py:percent conversion with uv: + +```bash +uvx --with=jupytext marimo convert your_script.py -o your_notebook.py +``` + +### Export marimo notebooks to Jupyter notebooks + +Export to an `ipynb` file with + +```bash +marimo export ipynb notebook.py -o notebook.ipynb +``` + +Note that some marimo library functions, including UI elements, +won't work in Jupyter notebooks. + +## Magic commands Because marimo notebooks are just Python (improving maintainability), marimo doesn't support IPython magic commands or `!`-prefixed console commands. Here @@ -162,6 +209,29 @@ import subprocess subprocess.run(["ls", "-l"]) ``` +### Common magic commands replacements + +| Magic Command | Replacement | +| ------------- | ---------------------------------------------------------------------------------------------- | +| %cd | `os.chdir()`, see also [`mo.notebook_dir()`][marimo.notebook_dir] | +| %clear | Right-click or toggle the cell actions | +| %debug | Python's built-in debugger: `breakpoint()` | +| %env | `os.environ` | +| %load | N/A - use Python imports | +| %load_ext | N/A | +| %autoreload | marimo's [module autoreloader](../editor_features/module_autoreloading.md) | +| %matplotlib | marimo auto-displays plots | +| %pwd | `os.getcwd()` | +| %pip | Use marimo's [built-in package management](../editor_features/package_management.md) | +| %who_ls | `dir()`, `globals()`, [`mo.refs()`][marimo.refs], [`mo.defs()`][marimo.defs] | +| %system | `subprocess.run()` | +| %%time | `time.perf_counter()` or Python's timeit module | +| %%timeit | Python's timeit module | +| %%writefile | `with open("file.txt", "w") as f: f.write()` | +| %%capture | [`mo.capture_stdout()`][marimo.capture_stdout], [`mo.capture_stderr()`][marimo.capture_stderr] | +| %%html | [`mo.Html()`][marimo.Html] or [`mo.md()`][marimo.md] | +| %%latex | [`mo.md(r'$$...$$')`][marimo.md] | + ### Installing packages with marimo's package manager Use marimo's package management sidebar panel to install packages to your current diff --git a/docs/guides/coming_from/jupytext.md b/docs/guides/coming_from/jupytext.md index f9255b029bf..aa0402f26a5 100644 --- a/docs/guides/coming_from/jupytext.md +++ b/docs/guides/coming_from/jupytext.md @@ -7,11 +7,14 @@ are stored as `.py` files by default. However, Jupytext works with IPython notebooks, whereas marimo works with marimo notebooks, which are not based on IPython/Jupyter. Here's a comparison to help you transition smoothly. +!!! tip "marimo also has a markdown fileformat" + Learn more by running `marimo tutorial markdown-format` at the command-line. + ## Notebook Format | Jupytext | marimo | |----------|--------| -| Jupytext uses comments or special markers to define cell types in notebooks. | Notebooks are pure Python (`.py`) files by default, using standard Python syntax, such as decorators and functions, to define cells. | +| Jupytext uses comments or special markers to define cell types in notebooks. | Notebooks are pure Python (`.py`) files by default, using standard Python syntax, such as decorators and functions, to define cells. In markdown form (`.md`), marimo has no special syntax, meaning your notebook will render well in locations like Github. | ## Converting Jupyter notebooks @@ -22,12 +25,17 @@ on IPython/Jupyter. Here's a comparison to help you transition smoothly. | `jupytext --to py notebook.ipynb` | `marimo convert notebook.ipynb > notebook.py` | !!! tip "From py:percent notebooks to marimo notebooks" - If you have a Python file encoded in the [py:percent](https://jupytext.readthedocs.io/en/latest/#text-notebooks) - format, you can convert it to a marimo notebook in two steps: + marimo can directly convert Python files in the [py:percent](https://jupytext.readthedocs.io/en/latest/#text-notebooks) + format (requires jupytext): + + ``` + marimo convert percent_notebook.py -o marimo_notebook.py + ``` + + If using uv: ``` - jupytext --to notebook.ipynb percent_notebook.py - marimo convert notebook.ipynb > marimo_notebook.py + uvx --with=jupytext marimo convert percent_notebook.py -o marimo_notebook.py ``` ### To `.ipynb` diff --git a/docs/guides/coming_from/papermill.md b/docs/guides/coming_from/papermill.md index 5e204e5ec96..7649e8239b7 100644 --- a/docs/guides/coming_from/papermill.md +++ b/docs/guides/coming_from/papermill.md @@ -88,7 +88,46 @@ programmatically. This API also allows for parametrizing the inputs to the cell; to learn more, make sure to checkout [the example][marimo.Cell.run] in our API reference. -2. **Using subprocess**: +2. **Programmatic execution with definition overrides**: + + You can run a marimo app programmatically and override cell definitions: + + ```python + import marimo + from my_notebook import app + + # Run the app with overridden definitions + # This completely replaces the definitions in cells that define these variables + outputs, defs = app.run(defs={"batch_size": 64, "learning_rate": 0.001, "model_type": "transformer"}) + ``` + + **Important limitations:** + - When you provide definitions to `app.run()`, you are **completely overriding** + the definitions of cells that define those variables + - The cells that originally defined those variables will not execute their logic + - You must provide **all** the definitions that a cell would normally produce, + not just individual parameters + - This is different from CLI arguments which are parsed within the cell's execution + + For example, if you have a cell that defines: + ```python + @app.cell + def config(): + batch_size = 32 + learning_rate = 0.01 + return batch_size, learning_rate + ``` + + To override this cell, you must provide both variables: + ```python + # Correct: Override the entire cell's definitions + outputs, defs = app.run(defs={"batch_size": 64, "learning_rate": 0.001}) + + # Incorrect: This would leave learning_rate undefined + # outputs, defs = app.run(defs={"batch_size": 64}) + ``` + +3. **Using subprocess**: ```python import subprocess diff --git a/docs/guides/coming_from/streamlit.md b/docs/guides/coming_from/streamlit.md index 17381adcd13..217a1139cb9 100644 --- a/docs/guides/coming_from/streamlit.md +++ b/docs/guides/coming_from/streamlit.md @@ -215,11 +215,13 @@ def expensive_computation(args): marimo: ```python -@functools.cache +@mo.cache def expensive_computation(args): # ... ``` +marimo provides [`mo.cache`](../../api/caching.md/#marimo.cache) and [`mo.lru_cache`](../../api/caching.md/#marimo.lru_cache) for caching function return values, as well as [`mo.persistent_cache`](../../api/caching.md/#marimo.persistent_cache) for caching variables to disk. + ### 8. Session State Streamlit uses `st.session_state` for persisting data. In marimo, you can use diff --git a/docs/guides/configuration/html_head.md b/docs/guides/configuration/html_head.md index b806eae5a13..aaba5627dce 100644 --- a/docs/guides/configuration/html_head.md +++ b/docs/guides/configuration/html_head.md @@ -2,6 +2,10 @@ You can include a custom HTML head file to add additional functionality to your notebook, such as analytics, custom fonts, meta tags, or external scripts. The contents of this file will be injected into the `` section of your notebook. +!!! note "Run mode only" + + Custom HTML head content is only injected in **run mode** (`marimo run`). It is not applied in edit mode. If you need custom styling in edit mode, use [`css_file`](theming.md). + To include a custom HTML head file, specify the relative file path in your app configuration. This can be done through the marimo editor UI in the notebook settings (top-right corner). This will be reflected in your notebook file: @@ -51,6 +55,10 @@ Here are some common use cases for custom HTML head content: ``` +!!! tip "OpenGraph previews" + + For common OpenGraph fields (title, description, image), you can also use notebook OpenGraph metadata in script metadata. See [OpenGraph previews](../publishing/opengraph.md). + 4. **External Scripts and Libraries** ```html diff --git a/docs/guides/configuration/index.md b/docs/guides/configuration/index.md index cdea8c0acab..68ba84d6294 100644 --- a/docs/guides/configuration/index.md +++ b/docs/guides/configuration/index.md @@ -1,14 +1,16 @@ # Configuration -marimo offers two types of configuration: User Configuration and App -Configuration. Both can be easily managed through the Settings menu in the -marimo editor. +marimo offers two types of configuration: User Settings, which apply globally +to all your notebooks, and Notebook Settings, which apply to a single notebook. +Both can be configured in the marimo editor. - +Notebook settings dialog showing display, data, custom files, and exporting options -## App Configuration +## Notebook settings -App Configuration is specific to each notebook and is stored in the `notebook.py` file. This allows you to customize various aspects of your notebook, including: +Notebook settings are specific to each notebook and are stored in the +`notebook.py` file. This allows you to customize various aspects of your +notebook, including: - Notebook width - Notebook title @@ -20,15 +22,16 @@ Configure these settings through the notebook menu (⚙️) in the top-right cor
-## User Configuration +## User settings -User Configuration applies globally across all marimo notebooks and is stored -in a `.marimo.toml` file. +User settings apply globally across all marimo notebooks and are stored +in a `$XDG_CONFIG_HOME/marimo/marimo.toml` file. -While you can edit the `.marimo.toml` file directly, we recommend using the -marimo UI for a more user-friendly experience. - - +While you can edit the `$XDG_CONFIG_HOME/marimo/marimo.toml` file directly, we +recommend using the marimo UI for a more user-friendly experience. Click the +"User settings" button in the bottom of the notebook settings menu to open +configuration UI, or hit `Cmd/Ctrl + k` to open the command palette and type +`"User settings"`. You can customize the following settings: @@ -42,6 +45,7 @@ You can customize the following settings: - [VIM keybindings](../editor_features/overview.md#vim-keybindings) - Formatting settings - [AI assistance](../editor_features/ai_completion.md) +- [Snippets](snippets.md) - Experimental features ### User configuration file @@ -78,25 +82,53 @@ For example, the following `pyproject.toml` file overrides the `autosave` settin in the user configuration: ```toml title="pyproject.toml" -[tool.marimo.format] +[tool.marimo.formatting] line_length = 120 [tool.marimo.display] default_width = "full" + +[tool.marimo.runtime] +default_sql_output = "native" ``` You can override any user configuration setting in this way. To find these settings run `marimo config show`. -## Environment Variables +!!! note "Overridden settings" + Settings overridden in `pyproject.toml` or script metadata cannot be changed through the marimo editor's settings menu. Any changes made to overridden settings in the editor will not take effect. + +### Script metadata configuration + +You can also configure marimo settings directly in your notebook files using script metadata (PEP 723). Add a `script` block at the top of your notebook: + +```python +# /// script +# [tool.marimo.runtime] +# auto_instantiate = false +# on_cell_change = "lazy" +# [tool.marimo.display] +# theme = "dark" +# cell_output = "below" +# /// +``` + +!!! note "Configuration precedence" + Script metadata configuration has the highest precedence, followed by `pyproject.toml` configuration, then user configuration: + + **Script config > pyproject.toml config > user config** + +## Environment variables marimo supports the following environment variables for advanced configuration: | Environment Variable | Description | Default Value | | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------- | -| `MARIMO_OUTPUT_MAX_BYTES` | Maximum size of output that marimo will display. Outputs larger than this will be truncated. | 5,000,000 (5MB) | -| `MARIMO_STD_STREAM_MAX_BYTES` | Maximum size of standard stream (stdout/stderr) output that marimo will display. Outputs larger than this will be truncated. | 1,000,000 (1MB) | +| `MARIMO_OUTPUT_MAX_BYTES` (deprecated, use `pyproject.toml`) | Maximum size of output that marimo will display. Outputs larger than this will be truncated. | 8,000,000 (8MB) | +| `MARIMO_STD_STREAM_MAX_BYTES` (deprecated, use `pyproject.toml`) | Maximum size of standard stream (stdout/stderr) output that marimo will display. Outputs larger than this will be truncated. | 1,000,000 (1MB) | | `MARIMO_SKIP_UPDATE_CHECK` | If set to "1", marimo will skip checking for updates when starting. | Not set | | `MARIMO_SQL_DEFAULT_LIMIT` | Default limit for SQL query results. If not set, no limit is applied. | Not set | +| `MARIMO_SESSION_COOKIE_SECURE` | If set to `true`/`1`, marks the session cookie as `Secure` so browsers only send it over HTTPS. Enable when serving marimo behind TLS. | `false` | +| `MARIMO_SERVER_TRANSPORT` | Experimental. The transport for streaming kernel messages to the browser: `websocket` or `sse`. Use `sse` when deploying behind proxies or services that do not support WebSockets. | `websocket` | ### Tips diff --git a/docs/guides/configuration/internationalization.md b/docs/guides/configuration/internationalization.md new file mode 100644 index 00000000000..25951c7b148 --- /dev/null +++ b/docs/guides/configuration/internationalization.md @@ -0,0 +1,79 @@ +# Internationalization (i18n) + +marimo provides basic internationalization support through locale configuration. This primarily affects how dates, numbers, and relative times are formatted throughout the interface. + +## What is localized + +The `locale` setting configures formatting for: + +- **Date formatting**: How dates are displayed in data tables and outputs +- **Datetime formatting**: How timestamps are displayed +- **Number formatting**: How numbers are formatted (decimal separators, thousands separators) +- **Relative times**: How relative time expressions like "Today at 8:00 AM" or "Yesterday at 2:30 PM" are displayed + +!!! note "Text is not localized" + marimo does **not** localize UI text, error messages, or documentation as this requires significant effort which we currently cannot afford on our own. + +## Configuration + +You can configure the locale in three different ways, with the following precedence order: + +**Script config > pyproject.toml config > user config** + +### User Settings + +Configure the locale globally for all notebooks through the Settings menu in the marimo editor: + +1. Click the Settings button (⚙️) in the top-right corner +2. Navigate to the "Display" section +3. Set the "Locale" field to your desired locale (e.g., `en-US`, `fr-FR`, `de-DE`) + +This setting is stored in your user configuration file (`~/.config/marimo/marimo.toml` or similar). + +### Project Configuration (pyproject.toml) + +For team projects, you can set the locale in your `pyproject.toml` file to ensure consistency across all team members: + +```toml title="pyproject.toml" +[tool.marimo.display] +locale = "en-US" +``` + +This configuration will apply to all notebooks in the project and override individual user settings. + +### Notebook Settings (Script Metadata) + +You can also configure the locale for a specific notebook using script metadata. Add this at the top of your notebook file: + +```python +# /// script +# [tool.marimo.display] +# locale = "fr-FR" +# /// +``` + +This has the highest precedence and will override both project and user configurations for that specific notebook. + +!!! note "Default behavior" + If no locale is configured, marimo will use the user's browser locale (detected from `navigator.language`). + +## Supported locales + +The locale should follow the BCP 47 language tag format (`language-country`). + +## Troubleshooting + +If your locale setting isn't taking effect: + +1. **Check the precedence**: Script metadata overrides pyproject.toml, which overrides user settings +2. **Verify the locale format**: Ensure you're using a valid BCP 47 language tag +3. **Restart marimo**: Some configuration changes may require restarting the marimo server +4. **Check browser support**: The locale must be supported by your browser's `Intl` API + +To verify your current configuration, run: + +```bash +marimo config show +``` + +This will display your active configuration including the current locale setting. diff --git a/docs/guides/configuration/llm_providers.md b/docs/guides/configuration/llm_providers.md new file mode 100644 index 00000000000..40283c2035c --- /dev/null +++ b/docs/guides/configuration/llm_providers.md @@ -0,0 +1,575 @@ +# Configuring LLM providers + +Connect marimo to an LLM via the notebook Settings panel (recommended) or by editing +`marimo.toml` directly. **Prefer going through the notebook settings menu to avoid errors with the config file.** + +To locate your config file: + +```bash +marimo config show +``` + +The path to `marimo.toml` is printed at the top of the output. + +## Model configuration + +### Model roles and routing + +marimo uses three roles: + +* **`chat_model`**: chat panel +* **`edit_model`**: refactor cells (Cmd/Ctrl-Shift-E) and Generate with AI +* **`autocomplete_model`**: inline code completion + +Models are written as `provider/model`, and the provider prefix routes to the matching config section: + +```toml title="marimo.toml" +[ai.models] +chat_model = "openai/gpt-4o-mini" # Routes to OpenAI config +edit_model = "anthropic/claude-3-sonnet" # Routes to Anthropic config +autocomplete_model = "ollama/codellama" # Routes to Ollama config +autocomplete_model = "some_other_provider/some_model" # Routes to OpenAI compatible config +``` + +### Custom models + +Add custom entries to the model dropdown: + +```toml title="marimo.toml" +[ai.models] +custom_models = ["ollama/somemodel"] +``` + +## Rules and Max tokens + +Add custom rules or set the maximum number of tokens that the AI model can use. + +```toml title="marimo.toml" +[ai] +rules = """ +- Always use type hints; prefer polars over pandas +""" +max_tokens = 1000 +``` + +## Supported providers + +
+
+ +
Configure LLM providers in the notebook settings panel.
+
+
+ +You can configure the following providers: + +* Anthropic +* AWS Bedrock +* GitHub +* Google AI +* DeepSeek +* xAI +* LM Studio +* Mistral +* Ollama +* OpenAI +* OpenCode Go +* OpenRouter +* Weights & Biases +* Together AI +* Vercel v0 +* and any OpenAI-compatible provider + +Below we describe how to connect marimo to your AI provider. + +### OpenAI + +**Requirements** + +* `pip install openai` or `uv add openai` + +**Configuration** + +```toml title="marimo.toml" +[ai.models] +chat_model = "openai/gpt-4o-mini" +edit_model = "openai/gpt-4o" +# See https://platform.openai.com/docs/models for the latest list + +[ai.open_ai] +# Get an API key at https://platform.openai.com/account/api-keys +api_key = "sk-proj-..." +``` + +!!! note "OpenAI-compatible providers" + If your model does not start with `openai/`, it will not be routed to the OpenAI config, and likely will be routed to the OpenAI-compatible config. + +??? tip "Reasoning models (o1, o3, etc.)" + These models can incur higher costs due to separate reasoning tokens. Prefer smaller responses for refactors or autocompletion, and review your provider limits. + +### Anthropic + +**Requirements** + +* Create an account and key: [Anthropic Console](https://console.anthropic.com/settings/keys) + +**Configuration** + +```toml title="marimo.toml" +[ai.models] +chat_model = "anthropic/claude-3-7-sonnet-latest" # other options: claude-3-haiku, claude-3-opus +# See Anthropic model list: https://docs.anthropic.com/en/docs/about-claude/models + +[ai.anthropic] +api_key = "sk-ant-..." +``` + +### AWS Bedrock + +AWS Bedrock exposes multiple foundation models via a unified AWS API. + +**Requirements** + +* `pip install boto3` +* Enable model access in the Bedrock console +* AWS credentials via `aws configure`, env vars, or `~/.aws/credentials` + +**Configuration** + +```toml title="marimo.toml" +[ai.models] +chat_model = "bedrock/anthropic.claude-3-sonnet-latest" +# Example model families include Anthropic Claude, Meta Llama, Cohere Command + +[ai.bedrock] +region_name = "us-east-1" # AWS region where Bedrock is available +# Optional AWS profile name (from ~/.aws/credentials) +profile_name = "my-profile" +``` + +Use `profile_name` for a non-default named profile, or rely on env vars/standard AWS resolution. For regional inference models, specify the inference profile ID (e.g., `bedrock/eu.anthropic.claude-sonnet-4-20250514-v1:0`) and corresponding region. + +??? tip "Required AWS Bedrock permissions" + Ensure your IAM policy allows `bedrock:InvokeModel` and `bedrock:InvokeModelWithResponseStream` for the models you plan to use. + +### Google AI + +**Requirements** + +* `pip install google-genai` + +You can use Google AI via two backends: **Google AI Studio** (API key) or **Google Vertex AI** (no API key required). + +#### Using Google AI Studio (API key) + +1. Sign up at [Google AI Studio](https://aistudio.google.com/app/apikey) and obtain your API key. +2. Configure `marimo.toml` (or set these in the editor Settings): + +```toml title="marimo.toml" +[ai.models] +chat_model = "google/gemini-2.5-pro" +# or any model from https://ai.google.dev/gemini-api/docs/models/gemini + +[ai.google] +api_key = "AI..." +``` + +#### Using Google Vertex AI (no API key required) + +1. Ensure you have access to a Google Cloud project with Vertex AI enabled. +2. Set the following environment variables before starting marimo: + +```bash +export GOOGLE_GENAI_USE_VERTEXAI=true +export GOOGLE_CLOUD_PROJECT='your-project-id' +export GOOGLE_CLOUD_LOCATION='us-central1' +``` + +* `GOOGLE_GENAI_USE_VERTEXAI=true` tells the client to use Vertex AI. +* `GOOGLE_CLOUD_PROJECT` is your GCP project ID. +* `GOOGLE_CLOUD_LOCATION` is your region (e.g., `us-central1`). + +3. No API key is needed in your `marimo.toml` for Vertex AI. + +For details and advanced configuration, see the `google-genai` Python client docs: `https://googleapis.github.io/python-genai/#create-a-client`. + +### Azure + +There are two offerings for serving LLMs on Azure + +**Azure OpenAI** + +```toml title="marimo.toml" +[ai.models] +chat_model = "azure/gpt-4.1-mini" + +[ai.azure] +api_key = "sk-proj-..." +base_url = "https://.openai.azure.com/openai/deployments/?api-version=" +``` + +The deployment name is typically the model name. + +**Azure AI Foundry** + +AI Foundry uses OpenAI-compatible models. You can configure it as a custom provider: + +```toml title="marimo.toml" +[ai.models] +custom_models = ["azure_foundry/mistral-medium"] +chat_model = "azure_foundry/mistral-medium" + +[ai.custom_providers.azure_foundry] +api_key = "sk-proj-..." +base_url = "https://.services.ai.azure.com/openai/v1" +``` + +### GitHub Copilot + +Use Copilot for code refactoring or the chat panel (Copilot subscription required). + +**Requirements** + +* Install the [gh CLI](https://cli.github.com/) +* Get a token: `gh auth token` + +**Configuration** + +```toml title="marimo.toml" +[ai.models] +chat_model = "github/gpt-4o-mini" + +[ai.github] +api_key = "gho_..." +``` + +??? question "My token starts with `ghp_` instead of `gho_`?" + + This usually happens when you previously authenticated `gh` by pasting a _personal_ access token (`ghp_...`). However, GitHub Copilot is not available through `ghp_...`, and you will encounter errors such as: + + > bad request: Personal Access Tokens are not supported for this endpoint + + To resolve this issue, you could switch to an _OAuth_ access token (`gho_...`): + + 1. Re-authenticate by running `gh auth login`. + 2. Choose _Login with a web browser_ (instead of _Paste an authentication token_) this time. + +### OpenRouter + +Route to many providers through OpenRouter with a single API. + +**Requirements** + +* Create an API key: [OpenRouter Dashboard](https://openrouter.ai/) +* `pip install openai` or `uv add openai` (OpenRouter is OpenAI‑compatible) + +**Configuration** + +```toml title="marimo.toml" +[ai.models] +# Use OpenRouter's model slugs (vendor/model). Examples: +chat_model = "openrouter/openai/gpt-4o-mini" +edit_model = "openrouter/anthropic/claude-3-7-sonnet" + +[ai.openrouter] +api_key = "sk-or-..." +base_url = "https://openrouter.ai/api/v1/" +# Optional but recommended per OpenRouter best practices +# extra_headers = { "HTTP-Referer" = "https://your-app.example", "X-Title" = "Your App Name" } +``` + +See available models at `https://openrouter.ai/models`. Make sure to prepend `openrouter/` to the model slug (e.g., `openrouter/deepseek/deepseek-chat`, `openrouter/meta-llama/llama-3.1-8b-instruct`). + +### Weights & Biases + +Access hosted AI models through Weights & Biases Weave for ML development and inference. + +**Requirements** + +* Create an API key: [Weights & Biases Settings](https://wandb.ai/authorize) +* `pip install openai` or `uv add openai` (Weights & Biases is OpenAI‑compatible) + +**Configuration** + +```toml title="marimo.toml" +[ai.models] +# Use wandb/ prefix for Weights & Biases models. Examples: +chat_model = "wandb/meta-llama/llama-3-70b-instruct" +edit_model = "wandb/gpt-4o" + +[ai.wandb] +api_key = "your-wandb-api-key" +base_url = "https://api.inference.wandb.ai/v1/" # Optional, this is the default +``` + +See available models at the [Weights & Biases documentation](https://docs.wandb.ai/inference). Make sure to prepend `wandb/` to the model name. + +### OpenCode Go + +Access curated open coding models tested and benchmarked for coding agents through the OpenCode Go gateway. + +**Requirements** + +* Sign in at [opencode.ai/auth](https://opencode.ai/auth), subscribe to Go, and copy your API key +* `pip install openai` or `uv add openai` (OpenCode Go is OpenAI‑compatible) + +**Configuration** + +```toml title="marimo.toml" +[ai.models] +# Use opencode-go/ prefix for OpenCode Go models. Examples: +chat_model = "opencode-go/kimi-k2.6" +edit_model = "opencode-go/qwen3.7-plus" + +[ai.opencode_go] +api_key = "your-opencode-api-key" +base_url = "https://opencode.ai/zen/go/v1/" # Optional, this is the default +``` + +You can also set the `OPENCODE_API_KEY` environment variable instead of `api_key`. See available models at the [OpenCode Go documentation](https://opencode.ai/docs/go/). Make sure to prepend `opencode-go/` to the model slug. + +### Local models with Ollama { #using-ollama } + +Run open-source LLMs locally and connect via an OpenAI‑compatible API. + +**Requirements** + +* Install [Ollama](https://ollama.com/) +* `pip install openai` or `uv add openai` + +**Setup** + +1. Pull a model + + ```bash + # View available models at https://ollama.com/library + ollama pull llama3.1 + ollama pull codellama # recommended for code generation + + # View your installed models + ollama ls + ``` + +2. Start the Ollama server: + + ```bash + ollama serve + # In another terminal, run a model (optional) + ollama run codellama + ``` + +3. Visit to confirm that the server is running. + +!!! note "Port already in use" + If you get a "port already in use" error, you may need to close an existing Ollama instance. On Windows, click the up arrow in the taskbar, find the Ollama icon, and select "Quit". This is a known issue (see [Ollama Issue #3575](https://github.com/ollama/ollama/issues/3575)). Once you've closed the existing Ollama instance, you should be able to run `ollama serve` successfully. + +**Configuration** + +```toml title="marimo.toml" +[ai.models] +chat_model = "ollama/llama3.1:latest" +edit_model = "ollama/codellama" +autocomplete_model = "ollama/codellama" # or another model from `ollama ls` +``` + +??? warning "Important: Use the `/v1` endpoint" + + marimo uses Ollama's OpenAI‑compatible API. Ensure your `base_url` includes `/v1`: + + ```toml + [ai.ollama] + base_url = "http://127.0.0.1:11434/v1" # ✅ Correct - includes /v1 + ``` + + Common mistake: + ```toml + base_url = "http://127.0.0.1:11434" # ❌ Will cause 404 errors + ``` + + If you see 404s, verify the model is installed with `ollama ls` and test the endpoint: + ```bash + curl http://127.0.0.1:11434/v1/models + ``` + +## Custom providers + +Add multiple OpenAI-compatible providers through the Settings UI. Each custom provider gets its own configuration section and can be referenced by name in your model settings. The following section is a non-exhaustive list of supported providers. + +**Requirements** + +* Provider API key +* Provider OpenAI-compatible `base_url` +* `pip install openai` or `uv add openai` + +**Adding a custom provider via UI (recommended)** + +1. Open marimo's Settings panel +2. Navigate to **AI** → **AI Providers** +3. Scroll to **Custom Providers** and click **Add Provider** +4. Navigate to the **AI Models** tab and select your custom provider. + +
+ +
+ +**Configuration via marimo.toml** + +You can also configure custom providers directly in `marimo.toml`: + +```toml title="marimo.toml" +[ai.models] +chat_model = "groq/llama-3.1-70b-versatile" +edit_model = "together/meta-llama/Llama-3-70b-chat-hf" + +[ai.custom_providers.groq] +api_key = "gsk-..." +base_url = "https://api.groq.com/openai/v1" + +[ai.custom_providers.together] +api_key = "tg-..." +base_url = "https://api.together.xyz/v1" + +[ai.custom_providers.my_local_server] +base_url = "http://localhost:8000/v1" +``` + +??? tip "Use the `/v1` path if required by your provider" + Some OpenAI-compatible providers expose their API under `/v1` (e.g., `https://host/v1`). If you see 404s, add `/v1` to your `base_url`. + +### DeepSeek + +Use DeepSeek via its OpenAI‑compatible API. + +**Requirements** + +* DeepSeek API key + +**Configuration** + +```toml title="marimo.toml" +[ai.models] +chat_model = "deepseek/deepseek-chat" # or "deepseek-reasoner" + +[ai.custom_providers.deepseek] +api_key = "dsk-..." +base_url = "https://api.deepseek.com/" +``` + +### xAI + +Use Grok models via xAI's OpenAI‑compatible API. + +**Requirements** + +* xAI API key + +**Configuration** + +```toml title="marimo.toml" +[ai.models] +chat_model = "xai/grok-2-latest" + +[ai.custom_providers.xai] +api_key = "xai-..." +base_url = "https://api.x.ai/v1/" +``` + +### LM Studio + +Connect to a local model served by LM Studio's OpenAI‑compatible endpoint. + +**Requirements** + +* Install LM Studio and start its server + +**Configuration** + +```toml title="marimo.toml" +[ai.models] +chat_model = "lmstudio/qwen2.5-coder-7b" + +[ai.custom_providers.lmstudio] +base_url = "http://127.0.0.1:1234/v1" # LM Studio server +``` + +### Mistral + +Use Mistral via its OpenAI‑compatible API. + +**Requirements** + +* Mistral API key + +**Configuration** + +```toml title="marimo.toml" +[ai.models] +chat_model = "mistral/mistral-small-latest" # e.g., codestral-latest, mistral-large-latest + +[ai.custom_providers.mistral] +api_key = "mistral-..." +base_url = "https://api.mistral.ai/v1/" +``` + +### Together AI + +Access multiple hosted models via Together AI's OpenAI‑compatible API. + +**Requirements** + +* Together AI API key + +**Configuration** + +```toml title="marimo.toml" +[ai.models] +chat_model = "together/mistralai/Mixtral-8x7B-Instruct-v0.1" + +[ai.custom_providers.together] +api_key = "tg-..." +base_url = "https://api.together.xyz/v1/" +``` + +### Vercel v0 + +Use Vercel's v0 OpenAI‑compatible models for app-oriented generation. + +**Requirements** + +* v0 API key + +**Configuration** + +```toml title="marimo.toml" +[ai.models] +chat_model = "v0/v0-1.5-md" + +[ai.custom_providers.v0] +api_key = "v0-..." +base_url = "https://api.v0.dev/" # Verify the endpoint in v0 docs +``` + +See the [LiteLLM provider list](https://litellm.vercel.app/docs/providers) for more options. For non‑compatible APIs, submit a +[feature request](https://github.com/marimo-team/marimo/issues/new?template=feature_request.yaml). + + +### OpenAI-compatible (legacy) + +!!! note "Prefer custom providers" + The `[ai.open_ai_compatible]` section is still supported for backward compatibility, but we recommend using **custom providers** instead, which allows you to configure multiple providers with distinct names. + +For a single OpenAI-compatible provider, you can use: + +```toml title="marimo.toml" +[ai.models] +chat_model = "provider-x/some-model" + +[ai.open_ai_compatible] +api_key = "..." +base_url = "https://api.provider-x.com/" +``` + +Models that don't match a known provider prefix will fall back to this configuration. diff --git a/docs/guides/configuration/runtime_configuration.md b/docs/guides/configuration/runtime_configuration.md index a0dff51cfdf..4881920e9ac 100644 --- a/docs/guides/configuration/runtime_configuration.md +++ b/docs/guides/configuration/runtime_configuration.md @@ -1,32 +1,21 @@ # Runtime configuration -Through the notebook settings menu, you can configure how and when marimo -runs cells. +Through the notebook settings menu or notebook footer, you can configure how +and when marimo runs cells. -## On startup +## On startup { #on-startup } -By default, marimo notebooks run automatically on startup; just how the command - -```bash -python main.py -``` - -executes a script, - -```bash -marimo edit notebook.py -``` - -executes the notebook. - -Disable this behavior by unchecking "Autorun on startup". +Toggle this setting to control whether marimo notebooks opened with `marimo +edit` automatically run on startup _When sharing a notebook as an app with `marimo run`, this setting has no effect._ -## On cell change +## On cell change: disabling autorun on cell change (lazy execution) + + By default, when a cell is run or a UI element is interacted with, marimo automatically runs cells that reference any of its variables. **You can disable @@ -46,15 +35,34 @@ when working on notebooks with expensive cells. !!! tip "Tip: speed up expensive notebooks with marimo's smart caching" In addition to runtime configuration, marimo also provides [opt-in caching](../../api/caching.md) - to help you work with expensive or side-effectful notebooks. marimo's + to help you work with expensive or side-effectful notebooks. marimo can cache expensive functions in memory and expensive blocks of code to disk, letting you skip entire sections of your code and automatically loading - variables in memory on notebook startup. Read our [caching - guide](../../api/caching.md) to learn more. + variables in memory on notebook startup. marimo can also automatically + cache every executed cell notebook-wide. See [`cache_cells`](#cache-cells) + below. Read our [caching guide](../../api/caching.md) to learn more. _When sharing a notebook as an app with `marimo run`, this setting has no effect._ +## Cache cells { #cache-cells } + +By default, marimo only caches the functions or code blocks you explicitly +decorate with [`mo.cache`][marimo.cache] or +[`mo.persistent_cache`][marimo.persistent_cache]. You can instead have marimo +attempt to cache every executed cell in the notebook by setting `cache_cells` +in the runtime configuration: + +```toml title="pyproject.toml" +[tool.marimo.runtime] +cache_cells = true +``` + +Set it in `pyproject.toml` as shown above, or in the notebook's PEP 723 +metadata. See [Automatic cell +caching](../../api/caching.md#automatic-cell-caching) for how this behaves and +its current limitations. + ## On module change When module autoreloading is enabled, marimo automatically runs cells when you @@ -83,3 +91,52 @@ Autoreloading comes in two types:
When set to lazy, marimo's reloader marks cells as stale when you edit Python files.
+ +## Python path + +By default, marimo will not add any additional directories to the Python path. +This keeps the behavior of `marimo edit nb.py` and `python nb.py` consistent. + +You can add directories to the Python path by setting the `pythonpath` key in +the runtime configuration. These directories will be added to the head of +`sys.path`, similar to how the `PYTHONPATH` environment variable works. This +allows Python to find and import modules from these directories. + +```toml title="pyproject.toml" +[tool.marimo.runtime] +pythonpath = ["project/src"] +``` + +!!! tip "Consider alternatives to path manipulation" + + When possible, it's preferred to avoid path manipulation. If you want to + work on a module in a separate directory alongside your notebooks, we + recommend creating a _package_ and including marimo as a project dependency. + + ```sh + uv init --lib my_package + cd my_package + uv add --dev marimo + uv run marimo edit notebook.py # my_package is available in notebook environment + ``` + + This will make it easier to share your library code. For multiple packages, + consider configuring [uv workspaces](https://docs.astral.sh/uv/concepts/projects/workspaces/). + + See our guide on [notebooks in existing + projects](../package_management/notebooks_in_projects.md) for more details. + +## Environment variables + +### .env files + +marimo supports loading environment variables from `.env` files. This is useful for managing configuration that should not be committed to version control, such as API keys or database credentials. + +The `.env` next to your `pyproject.toml` is loaded by default. To configure multiple or a different location, you can specify them in your configuration: + +```toml title="pyproject.toml" +[tool.marimo.runtime] +dotenv = [".env", ".env.testing"] +``` + +Environment variables from your `dotenv` will be surfaced in the UI when creating databases. diff --git a/docs/guides/configuration/snippets.md b/docs/guides/configuration/snippets.md new file mode 100644 index 00000000000..a24a28a5714 --- /dev/null +++ b/docs/guides/configuration/snippets.md @@ -0,0 +1,60 @@ +# Snippets Configuration + +marimo provides a snippets feature that allows you to quickly insert commonly used code blocks into your notebooks. You can configure both the default snippets and add your own custom snippets. + +## Configuration Options + +You can configure snippets through your `marimo.toml` file: + +```toml +[snippets] +custom_paths = ["/path/to/your/snippets/dir"] # List of paths to directories containing custom snippets +include_default_snippets = true # Whether to include marimo's default snippets (defaults to true) +``` + +## Custom Snippets + +To add your own snippets: + +1. Create a directory to store your snippets +2. Add the directory path to the `custom_paths` list in your configuration +3. Create snippet files in your directory following the marimo snippet format + +### Snippet Format + +Snippets are Python files that follow a specific format. Each snippet should be a marimo notebook file with a title and code: + +Example snippet file (`my_snippet.py`): + +```python +import marimo + +app = marimo.App(width="medium") + +@app.cell(hide_code=True) +def _(mo): + mo.md(r"""# Load .env""") + return + + +@app.cell +def _(): + import dotenv + + dotenv.load_dotenv(dotenv.find_dotenv(usecwd=True)) + return (dotenv,) + + +@app.cell +def _(): + import marimo as mo + return (mo,) + + +if __name__ == "__main__": + app.run() +``` + +## Default Snippets + +marimo comes with a set of default snippets for common operations. You can disable the default snippets by setting `include_default_snippets = false` in your configuration. diff --git a/docs/guides/configuration/theming.md b/docs/guides/configuration/theming.md index 91c7b885f04..e664062fed4 100644 --- a/docs/guides/configuration/theming.md +++ b/docs/guides/configuration/theming.md @@ -8,6 +8,15 @@ To include a custom CSS file, in the configuration dropdown, add the relative fi app = marimo.App(css_file="custom.css") ``` +## Theming at the project level + +You may also set the `custom_css` field in your project configuration to apply a custom CSS file. This theme won't be applied if the notebook is shared with someone else, but it will be applied to all notebooks open inside the project. + +```toml title="pyproject.toml" +[tool.marimo.display] +custom_css = ["additional.css"] +``` + ## CSS Variables We support only a few CSS variables as part of the "public API" for theming. These are: @@ -41,15 +50,44 @@ Here is an example of a custom CSS file that changes the font of the notebook: } ``` +You can push custom CSS pretty far in marimo, check out [this demo on YouTube](https://www.youtube.com/shorts/GiMnWT_ZMOc) on how to create a custom theme based on [NES.css](https://nostalgic-css.github.io/NES.css/). + ## Custom HTML Head You can further customize your notebook by adding custom HTML in the `` section of your notebook. This allows you to add additional functionality to your notebook, such as analytics, custom fonts, meta tags, or external scripts. See the [Custom HTML Head](html_head.md) guide for more details. +## Forcing dark mode + +In order to force a theme for an application, you can override the marimo configuration specifically for an application using the script metadata. See the [Script Configuration](../configuration/index.md#script-metadata-configuration) for more details. + +```python +# /// script +# [tool.marimo.display] +# theme = "dark" +# /// +``` + +## Targeting cells + +You can target a cell's styles from the `data-cell-name` attribute. You can also target a cell's output with the `data-cell-role="output"` attribute. + +```css +/* Target the cell named "My Cell" */ +[data-cell-name='my_cell'] { + background-color: light-dark(navy, pink); +} + +/* Target the output of the cell named "My Cell" */ +[data-cell-name='my_cell'] [data-cell-role='output'] { + background-color: light-dark(navy, pink); +} +``` + ## Community Themes -The marimo community maintains a [library of custom themes](https://github.com/metaboulie/marimo-themes) that you can use in your notebooks. The library includes various themes like "coldme", "nord", "mininini", and "wigwam", each supporting both light and dark modes. +The marimo community maintains a [library of custom themes](https://github.com/Haleshot/marimo-themes) that you can use in your notebooks. The library includes various themes like "coldme", "nord", "mininini", and "wigwam", each supporting both light and dark modes. You can: @@ -57,7 +95,7 @@ You can: - Use them in your own notebooks - Contribute your own themes to share with the community -Visit the [marimo-themes repository](https://github.com/metaboulie/marimo-themes) to explore available themes and learn how to contribute your own. +Visit the [marimo-themes repository](https://github.com/Haleshot/marimo-themes) to explore available themes and learn how to contribute your own. ## More customizations diff --git a/docs/guides/debugging.md b/docs/guides/debugging.md new file mode 100644 index 00000000000..c14a6157604 --- /dev/null +++ b/docs/guides/debugging.md @@ -0,0 +1,188 @@ +# Debugging + +## In the marimo editor + +### Setting breakpoints with pdb + +marimo supports pdb, the Python debugger. Set breakpoints in your code using +the built-in `breakpoint()` function. When the code execution reaches a +breakpoint, it will pause, and you can inspect variables, step through the +code, and evaluate expressions. + +Here's a live example of how to use `breakpoint()` in a marimo notebook cell. Type +`help` in the debugger for a list of commands. + +/// marimo-embed + +```python +@app.cell +def _(): + # Compute triangle numbers + triangle = 0 + triangle_count = 20 + for i in range(1, triangle_count): + triangle += i # T_i = sum of 1..i + # Debug at the 10th iteration + # as a sanity check. Should be 55. + if i == 10: + breakpoint() + return +``` + + +/// + +!!! warning "The debugger blocks execution" + + When the debugger is active, you cannot execute cells. Remember to continue + or quit the debugger to avoid hanging the notebook! + +??? note "Adding breakpoints in the stack trace" + Click the little bug icon in the stack trace to add breakpoints. + + + Clicking on the cell link will also take you to the cell where the error occurred. + +### Live debugger + +!!! warning "Experimental Feature" + The live debugger is currently experimental and under active development. Features and APIs may change. + +Enable **Debugger** under experimental settings to turn on a live debugger for +the notebook editor: + +- Click a line in the gutter to set a breakpoint on it. Click it again to + remove it. Breakpoints are session-only; they're not saved to the notebook + file. +- While a cell is running, the line it's currently executing is highlighted. +- When execution reaches a breakpoint, the cell drops into `pdb` just like + `breakpoint()` does. + +This setting requires a page refresh to take effect. + +### Postmortem debugging + +If your code raises an exception, you can use postmortem debugging to inspect +the state of the program at the point where the exception occurred. Click on +the "Launch debugger" button as shown below: + + + +## Debugging notebooks as scripts + +Since marimo notebooks are standard Python files, you can run them as scripts +from the command line. The following command will run your marimo notebook and +drop you into the pdb debugger if an exception occurs, or if you hit a +breakpoint. + +```bash +python -m pdb your_script.py +``` + +## Debugging with AI + +marimo provides built-in integration with AI assistants to help you debug your +notebooks. + +### Ask about notebook errors + +When interacting with the AI chat, you can reference the notebook "Errors" with +the `@-symbol` to bring in comprehensive error information from your notebook, +making it easier to get targeted debugging help. + +![Notebook Errors context in marimo](../_static/docs-notebook-errors-context.png) + +### Best practices for AI-assisted debugging + +**Provide context beyond just the error.** Include information about: +- What you were trying to accomplish +- Recent changes you made to the notebook +- Whether the error is new or recurring +- Related cells that might be involved + +**Leverage marimo's debugging tools alongside AI.** Use marimo's [dataflow +tools](../guides/troubleshooting.md#verify-cell-connections) to understand cell +relationships, then share this information with AI assistants for more targeted +advice. + +**Ask specific questions.** Instead of "Why is this broken?", try: +- "Why might this reactivity issue be occurring between these cells?" +- "How can I fix this import error in my marimo notebook?" +- "What's the best way to debug this performance issue in my data processing pipeline?" + +!!! tip + AI assistants are particularly helpful for explaining marimo-specific + concepts like reactive execution, cell dependencies, and the differences + between marimo notebooks and traditional Jupyter notebooks. + +## Integrating with external IDEs + +marimo supports debugging with IDEs, like VS Code, which natively support the +`debugpy` library. This allows you to set breakpoints, step through code, and +inspect variables directly from your IDE. + +### Running as scripts + +You can debug marimo notebooks in VS Code using the following `launch.json`. +This launch configuration will debug a marimo notebook executing it as a +[script](./scripts.md). + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "type": "python", + "request": "launch", + "name": "marimo Debug: script mode", + "program": "${file}", + "debugOptions": [ + "--ignore", "*/site-packages/marimo/*" + ] + }, + ] +} +``` + +### Interactive development + +Edit mode debugging allows the marimo editor to trigger breakpoints set in an +IDE like VS Code. Running in this mode will automatically start your notebook in +[watch mode](./editor_features/watching.md). Note that the file state and +editor must be consistent for break points to correctly work. If debugging is +not acting as expected, force a notebook save and toggle the relevant +breakpoints. + +Use the following `launch.json` configuration to enable edit mode debugging: + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "type": "debugpy", + "request": "launch", + "name": "marimo Debug: edit mode", + "program": "${file}", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}", + "env": { + "MARIMO_SCRIPT_EDIT": "1" + }, + "justMyCode": false + } + ] +} +``` + + + +!!! note + This will disable marimo's internal debugging features. + +!!! danger + This mode is blocking in VS Code, so you will need to interact with the + debugger in your editor to regain control of the marimo notebook. diff --git a/docs/guides/deploying/authentication.md b/docs/guides/deploying/authentication.md index f689b4697ca..451aa9cb52f 100644 --- a/docs/guides/deploying/authentication.md +++ b/docs/guides/deploying/authentication.md @@ -68,4 +68,4 @@ if __name__ == "__main__": uvicorn.run(app, host="localhost", port=8000) ``` -or for a full example on implementing OAuth2 with FastAPI, see the [FastAPI OAuth2 example](https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/). +For a full example on implementing OAuth2 with FastAPI, see the [FastAPI OAuth2 example](https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/). diff --git a/docs/guides/deploying/deploying_kubernetes.md b/docs/guides/deploying/deploying_kubernetes.md new file mode 100644 index 00000000000..6ddfc5c260e --- /dev/null +++ b/docs/guides/deploying/deploying_kubernetes.md @@ -0,0 +1,246 @@ +# Deploy with Kubernetes + +The [marimo-operator](https://github.com/marimo-team/marimo-operator) is a +Kubernetes operator that manages marimo notebook deployments. It handles +persistent storage, resource allocation, and lifecycle management for notebooks +running on Kubernetes clusters. + +!!! tip "Quick start" + For the fastest path to running notebooks on Kubernetes, use the `kubectl-marimo` CLI plugin. + It handles manifest generation, port forwarding, and file synchronization automatically. + +## Prerequisites + +- Kubernetes cluster (v1.25+) +- `kubectl` configured with cluster access +- Python 3.9+ with `pip` or `uv` +- Cluster admin permissions (for initial operator installation) + +## Install the operator + +Install the marimo operator on your cluster: + +```bash +kubectl apply -f https://raw.githubusercontent.com/marimo-team/marimo-operator/main/deploy/install.yaml +``` + +Verify the operator is running: + +```bash +kubectl get pods -n marimo-operator-system +``` + +The output should show the operator pod running: + +``` +NAME READY STATUS RESTARTS AGE +marimo-operator-controller-manager-xxxxx 1/1 Running 0 30s +``` + +## Quickstart with kubectl-marimo + +The `kubectl-marimo` plugin is the recommended way to deploy notebooks from local files. + +### Install the plugin + +```bash +# With uv (recommended) +uv tool install kubectl-marimo + +# Or with pip +pip install kubectl-marimo +``` + +### Run a notebook + +Edit a notebook interactively on the cluster: + +```bash +kubectl marimo edit notebook.py +``` + +This command: + +1. Uploads your notebook to the cluster +2. Creates persistent storage for your changes +3. Starts the marimo server +4. Sets up port forwarding to your local machine + +When you stop the command (`Ctrl+C`), it syncs changes back to your local file and tears down the pod. + +To run a notebook as a read-only application: + +```bash +kubectl marimo run notebook.py +``` + +### Configure resources + +Configure your notebook's Kubernetes resources using frontmatter in your notebook file. + +**Python notebooks (.py):** + +```python +# /// script +# requires-python = ">=3.12" +# dependencies = ["marimo", "pandas", "numpy"] +# +# [tool.marimo.k8s] +# image = "ghcr.io/marimo-team/marimo:latest" +# storage = "5Gi" +# [tool.marimo.k8s.resources] +# requests.cpu = "500m" +# requests.memory = "2Gi" +# limits.cpu = "2" +# limits.memory = "8Gi" +# /// +``` + +**Markdown notebooks (.md):** + +```yaml +--- +title: my-analysis +image: ghcr.io/marimo-team/marimo:latest +storage: 5Gi +resources: + requests: + cpu: "500m" + memory: "2Gi" + limits: + cpu: "2" + memory: "8Gi" +--- +``` + +### Configuration fields + +| Field | Description | Default | +|-------|-------------|---------| +| `title` | Resource name in Kubernetes | filename | +| `image` | Container image | `ghcr.io/marimo-team/marimo:latest` | +| `port` | Server port | 2718 | +| `storage` | Persistent volume size | none (ephemeral) | +| `resources` | CPU, memory, GPU requests/limits | none | +| `auth` | Set to `"none"` to disable authentication | token auth | +| `env` | Environment variables | none | + +### Manage deployments + +```bash +# Sync changes back to local file +kubectl marimo sync notebook.py + +# Delete deployment +kubectl marimo delete notebook.py + +# List active deployments +kubectl marimo status +``` + +## GPU workloads + +Specify GPU resources in your notebook frontmatter: + +**Python notebooks:** + +```python +# /// script +# [tool.marimo.k8s.resources] +# limits."nvidia.com/gpu" = 1 +# /// +``` + +**Markdown notebooks:** + +```yaml +--- +resources: + limits: + nvidia.com/gpu: 1 +--- +``` + +The Kubernetes scheduler will place your notebook on an appropriate GPU node. + +## Cloud storage integration + +The marimo operator supports mounting cloud storage (S3-compatible buckets, SSHFS, rsync) in your notebooks. See the [operator documentation](https://github.com/marimo-team/marimo-operator) for mount configuration details. + +## Deploy with manifests + +For advanced users who need fine-grained control, you can create `MarimoNotebook` resources directly. + +### Basic manifest + +```yaml +apiVersion: marimo.io/v1alpha1 +kind: MarimoNotebook +metadata: + name: my-notebook +spec: + source: https://github.com/marimo-team/examples.git + storage: + size: 1Gi +``` + +Apply the manifest: + +```bash +kubectl apply -f notebook.yaml +``` + +Check the status: + +```bash +kubectl get marimos +``` + +Port forward to access: + +```bash +kubectl port-forward svc/my-notebook 2718:2718 +``` + +### With GPU and sidecars + +```yaml +apiVersion: marimo.io/v1alpha1 +kind: MarimoNotebook +metadata: + name: gpu-notebook +spec: + source: https://github.com/your-org/notebooks.git + storage: + size: 5Gi + resources: + requests: + memory: 4Gi + limits: + memory: 16Gi + nvidia.com/gpu: 1 + sidecars: + - name: ssh + image: linuxserver/openssh-server:latest + exposePort: 2222 +``` + +## Clean up + +```bash +# Via plugin (syncs changes first) +kubectl marimo delete notebook.py + +# Via kubectl (does not sync) +kubectl delete marimo my-notebook +``` + +!!! warning "Sync before deleting" + Using `kubectl delete` directly will **not** sync your changes back to your local file. Use `kubectl marimo delete` to automatically sync before deletion. + +## Learn more + +- [marimo-operator on GitHub](https://github.com/marimo-team/marimo-operator) +- [SkyPilot deployment](./deploying_skypilot.md) - For multi-cloud VM deployment without Kubernetes +- [Docker deployment](./deploying_docker.md) - For container basics +- [Inlining dependencies](../package_management/inlining_dependencies.md) - For reproducible notebooks diff --git a/docs/guides/deploying/deploying_nginx.md b/docs/guides/deploying/deploying_nginx.md new file mode 100644 index 00000000000..2645d6d14a2 --- /dev/null +++ b/docs/guides/deploying/deploying_nginx.md @@ -0,0 +1,88 @@ +# Deploy with nginx + +nginx is a popular web server that can be used as a reverse proxy for web applications. This guide will show you how to deploy marimo behind an nginx reverse proxy. + +## Prerequisites + +- A marimo notebook or app that you want to deploy +- nginx installed on your server +- Basic understanding of nginx configuration + +## Configuration + +Create a new configuration file in `/etc/nginx/conf.d/` (e.g., `marimo.conf`): + +```nginx +server { + server_name your-domain.com; + + location / { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://127.0.0.1:2718; + + # Required for WebSocket support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 600; + } + + # Optional: Serve static files + location /static/ { + alias /path/to/your/static/files/; + } +} +``` + +## Breaking it down + +- `server_name`: Replace with your domain name +- `proxy_pass`: Points to your marimo application (default port is 2718) +- WebSocket support: The following lines are required for marimo to function properly: + + ```nginx + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + ``` + +- `proxy_read_timeout`: Increased to 600 seconds to handle long-running operations + +## Running your application + +1. Start your marimo application: + +```bash +marimo run app.py --host 127.0.0.1 --port 2718 +``` + +2. Test your nginx configuration: + +```bash +nginx -t +``` + +3. Reload nginx to apply changes: + +```bash +nginx -s reload +``` + +Your marimo application should now be accessible at your domain. + +## SSL/HTTPS + +For production deployments, it's recommended to use HTTPS. You can use [Certbot](https://certbot.eff.org/) to automatically configure SSL with Let's Encrypt certificates. + +## Common Issues + +### Kernel Not Found + +If you see a "kernel not found" error, ensure that: + +1. WebSocket support is properly configured in your nginx configuration +2. The proxy headers are correctly set +3. Your marimo application is running and accessible at the specified proxy_pass address diff --git a/docs/guides/deploying/deploying_ploomber.md b/docs/guides/deploying/deploying_ploomber.md deleted file mode 100644 index f0fbd9b4cb2..00000000000 --- a/docs/guides/deploying/deploying_ploomber.md +++ /dev/null @@ -1,5 +0,0 @@ -# Deploy to Ploomber Cloud - -For production deployments, you can use Ploomber Cloud. It allows you to deploy -marimo in a secure and scalable way. See -[deployment instructions here](https://docs.cloud.ploomber.io/en/latest/apps/marimo.html) diff --git a/docs/guides/deploying/deploying_public_gallery.md b/docs/guides/deploying/deploying_public_gallery.md deleted file mode 100644 index 455f73836a8..00000000000 --- a/docs/guides/deploying/deploying_public_gallery.md +++ /dev/null @@ -1,9 +0,0 @@ -# Deploy to our public gallery - -If you would like to deploy your application to our [public -gallery](https://marimo.io/gallery), please reach out on -[Discord](https://marimo.io/discord?ref=docs). - -You can also easily share your notebooks on the public web using [WASM -notebooks](../../guides/wasm.md), which run entirely in the browser, no backend -required. diff --git a/docs/guides/deploying/deploying_skypilot.md b/docs/guides/deploying/deploying_skypilot.md new file mode 100644 index 00000000000..ba2a91201d7 --- /dev/null +++ b/docs/guides/deploying/deploying_skypilot.md @@ -0,0 +1,162 @@ +# Deploy with SkyPilot + +[SkyPilot](https://skypilot.readthedocs.io/) is an open-source framework that allows you to run workloads on any cloud (AWS, GCP, Azure, Coreweave, and more) with a unified interface. It's particularly well-suited for running marimo notebooks on GPU instances for machine learning and data science workloads. + +!!! tip "New to SkyPilot?" + If you haven't used SkyPilot before, start with their [getting started guide](https://skypilot.readthedocs.io/en/latest/getting-started/installation.html) to install SkyPilot and set up cloud credentials. + +marimo notebooks work exceptionally well with SkyPilot because they are stored as pure Python scripts and can be run both interactively and as batch jobs. With marimo's built-in `uv` integration, your notebooks are fully reproducible across different environments. + +## Interactive Development + +For interactive development with marimo on a SkyPilot cluster, you can launch a cluster and connect to it with SSH port forwarding. + +### Launch a cluster + +First, create a cluster with your desired resources, ([see the guide](https://docs.skypilot.co/en/latest/getting-started/installation.html#set-up-kubernetes-or-clouds)): + +```bash +sky launch --gpus V100:1 -c dev +``` + +### Connect with port forwarding + +Connect to the cluster and forward the port that marimo will use: + +```bash +ssh -L 8080:localhost:8080 dev +``` + +### Start marimo + +Inside the cluster, install `uv` and start marimo with the `--sandbox` flag for isolated dependencies: + +```bash +pip install uv +uvx marimo edit --sandbox demo.py --port 8080 --token-password=supersecret +``` + +!!! note "Sandboxed environments" + The `uvx` command runs marimo without installing it in your environment, and the `--sandbox` flag ensures that notebook dependencies are installed in a separate environment. This makes your development fully reproducible and isolated. See [inlining dependencies](../package_management/inlining_dependencies.md) for more on creating reproducible notebooks. + +You can now access your marimo notebook at `localhost:8080` in your local browser and authenticate with the password you set. + +## Running as Batch Jobs + +Because marimo notebooks are Python scripts, they can be submitted as managed SkyPilot jobs. This is useful for training models, running experiments, or processing data without needing an interactive session. + +### Create a job-compatible notebook + +marimo notebooks can accept command-line arguments using `mo.cli_args()`. Here's an example notebook that demonstrates this: + +```python +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "marimo", +# ] +# /// + +import marimo + +__generated_with = "0.18.1" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + return (mo,) + + +@app.cell +def _(mo): + # Parse command-line arguments + args = mo.cli_args() + print(f"Running with arguments: {args}") + return + + +if __name__ == "__main__": + app.run() +``` + +You can test this locally: + +```bash +uv run demo.py --hello world --demo works --lr 0.01 +``` + +This will print: + +``` +{'hello': 'world', 'demo': 'works', 'lr': '0.01'} +``` + +### Create a SkyPilot job configuration + +Create a YAML file to configure your job: + +```yaml +# marimo-job.yaml +name: marimo-demo + +# Specify resources for this job +resources: + accelerators: V100:1 + +# Point to the folder containing your marimo notebook +workdir: . + +# Environment variables (e.g., for W&B, HuggingFace) +envs: + WANDB_API_KEY: ${WANDB_API_KEY} + +# Install uv +setup: pip install uv + +# Run the notebook with arguments +run: uv run demo.py --hello world --demo works --lr 0.01 +``` + +### Launch the job + +Submit the job to SkyPilot: + +```bash +sky jobs launch -n marimo-demo marimo-job.yaml +``` + +SkyPilot will provision cloud resources, run your notebook, and automatically tear down the resources after the job completes (with a configurable idle timeout). + +### Monitor job progress + +You can monitor your job using: + +```bash +# View logs +sky jobs logs marimo-demo + +# Check job status +sky jobs queue + +# Launch dashboard +sky jobs dashboard +``` + +## Benefits of marimo + SkyPilot + +- **Reproducible**: marimo's `uv` integration ensures consistent dependency management +- **Cost-effective**: SkyPilot finds the cheapest resources across clouds and automatically terminates idle instances +- **Flexible**: Use the same notebook interactively or as a batch job +- **Cloud-agnostic**: Run on any cloud provider without changing your code + +## Multi-cloud setup + +SkyPilot supports AWS, GCP, Azure, Lambda Labs, and more. See their [cloud setup guide](https://skypilot.readthedocs.io/en/latest/getting-started/installation.html#cloud-account-setup) to configure credentials for your preferred cloud providers. + +## Learn more + +- [SkyPilot documentation](https://skypilot.readthedocs.io/) +- [SkyPilot managed jobs guide](https://skypilot.readthedocs.io/en/latest/examples/managed-jobs.html) +- [marimo CLI arguments](../../api/cli_args.md) diff --git a/docs/guides/deploying/deploying_slurm.md b/docs/guides/deploying/deploying_slurm.md new file mode 100644 index 00000000000..c0689e16692 --- /dev/null +++ b/docs/guides/deploying/deploying_slurm.md @@ -0,0 +1,215 @@ +# Deploy on Slurm/HPC Clusters + +This guide shows how to run marimo notebooks on Slurm-managed clusters, including traditional HPC systems and Slurm-on-Kubernetes setups like [SUNK](https://docs.coreweave.com/docs/products/sunk) (CoreWeave). + +Since marimo notebooks are pure Python scripts, it's easy to submit as Slurm jobs for both interactive development and batch processing. + +## Interactive Development + +For interactive development, submit a job that runs `marimo edit` and connect via SSH port forwarding. + +### Submit the job + +Create a script (`run_marimo.sh`): + +```bash +#!/usr/bin/env bash +#SBATCH --job-name=marimo +#SBATCH --output=marimo-%j.out +#SBATCH --cpus-per-task=4 +#SBATCH --mem=16GB +#SBATCH --time=4:00:00 + +# module load or otherwise set up environment + +python -m marimo edit notebook.py --headless --port 3000 +``` + +Submit it: + +```bash +sbatch run_marimo.sh +``` + +### Connect with port forwarding + +Once the job is running, find the compute node and create an SSH tunnel: + +```bash +# Find which node your job is running on +squeue -u $USER -o "%.18i %.9P %.8j %.8u %.2t %.10M %.6D %R" + +# Create tunnel (replace NODE with actual node name) +ssh -L 3000:NODE:3000 username@cluster.edu +``` + +Open `http://localhost:3000` in your browser. + +## Running as Batch Jobs + +Because marimo notebooks are just Python scripts, they can readily be run as +batch jobs. This simple example uses [`mo.cli_args()`][marimo.cli_args] to pass +parameters: + +```python +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "marimo", +# ] +# /// + +import marimo + +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + args = mo.cli_args() + print(f"Running with: {args}") + return + + +if __name__ == "__main__": + app.run() +``` + +Submit as a job: + +```bash +#!/usr/bin/env bash +#SBATCH --job-name=marimo-job +#SBATCH --output=marimo-%j.out +#SBATCH --cpus-per-task=4 +#SBATCH --mem=16GB + +python notebook.py --learning-rate 0.01 --epochs 100 +``` + +### Using GPUs + +Add GPU resources to your SBATCH directives: + +/// tab | Interactive development +```bash +#!/usr/bin/env bash +#SBATCH --job-name=marimo +#SBATCH --output=marimo-%j.out +#SBATCH --partition=gpu +#SBATCH --gres=gpu:1 +#SBATCH --cpus-per-task=4 +#SBATCH --mem=16GB +#SBATCH --time=4:00:00 + +# module load or otherwise set up environment + +python -m marimo edit notebook.py --headless --port 3000 +``` + +/// + +/// tab | Batch jobs + +```bash +#!/usr/bin/env bash +#SBATCH --job-name=marimo-gpu +#SBATCH --partition=gpu +#SBATCH --gres=gpu:1 +#SBATCH --cpus-per-task=8 +#SBATCH --mem=32GB + +python notebook.py +``` + +/// + +## Inlining configuration in notebook files + +You can inline SBATCH directives in your notebook file. If used alongside marimo's support for +[inlining package dependencies](../package_management/inlining_dependencies.md) ("sandboxing"), +this lets you create fully self-contained notebooks. + +/// tab | Interactive development + +```python +#!/usr/bin/env -S python -m marimo edit --sandbox +#SBATCH --job-name=marimo-job +#SBATCH --output=marimo-%j.out +#SBATCH --cpus-per-task=4 +#SBATCH --mem=16GB + +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "marimo", +# ] +# /// + +import marimo + +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + print("Hello World!") + return + + +if __name__ == "__main__": + app.run() +``` + +/// tab | Batch job + +``` +#!/usr/bin/env -S python +#SBATCH --job-name=marimo-job +#SBATCH --output=marimo-%j.out +#SBATCH --cpus-per-task=4 +#SBATCH --mem=16GB + +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "marimo", +# ] +# /// + +import marimo + +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + print("Hello World!") + return + + +if __name__ == "__main__": + app.run() +``` + +/// + + +Make executable and submit directly: + +```bash +chmod +x notebook.py +sbatch notebook.py +``` + +Sandboxing requires [uv](https://docs.astral.sh/uv/getting-started/installation/) to be installed. + +## Learn more + +- [Slurm examples](https://github.com/marimo-team/marimo/tree/main/examples/slurm) - Complete working examples +- [Slurm documentation](https://slurm.schedmd.com/documentation.html) +- [SUNK (Slurm on Kubernetes)](https://docs.coreweave.com/docs/products/sunk) +- [marimo CLI arguments](../../api/cli_args.md) diff --git a/docs/guides/deploying/index.md b/docs/guides/deploying/index.md index 915da2bc2a3..7dfc47bc11a 100644 --- a/docs/guides/deploying/index.md +++ b/docs/guides/deploying/index.md @@ -1,45 +1,41 @@ -# Deploying +# Deploy notebook servers or apps -You can deploy marimo in three ways: +marimo can be deployed as an "edit" server for creating, running, and editing +notebooks, or an app server for serving read-only web apps. For convenience, +you can use our [pre-built containers](prebuilt_containers.md). -1. via an **edit server**, which allows you to create and edit notebooks. On - the CLI, this is launched with `marimo edit`, and is similar to `jupyter notebook`. -2. via a **run server**, which allows you serve marimo notebooks as read-only - web apps. On the CLI, this is launched with `marimo run notebook.py` -3. programmatically, which allows you serve **read-only** marimo apps - as part of other ASGI applications, for example using FastAPI. +!!! tip "Sharing notebooks on the public web" + To share notebooks on the public web without managing infrastructure, try + [molab](../molab.md), our free cloud-hosted notebook environment -!!! tip "Sharing lightweight notebooks on the web" - To share notebooks on the public web, try using [our online playground](https://marimo.new). Our playground runs entirely in the browser -- no - backend required, via [WASM](../../guides/wasm.md). +## Notebook servers - Or, to share notebooks with email-based authorization, you can also - try our free [community cloud](https://marimo.io/sign-up), which is - also powered by WASM. +Deploy an edit server (`marimo edit`) to let users create and edit notebooks +on a remote instance. - WASM notebooks support most but not all Python features and packages. +| Guide | Description | +| ----- | ----------- | +| [JupyterHub](jupyterhub.md) | Run marimo inside JupyterHub with our JupyterLab extension | +| [Kubernetes](deploying_kubernetes.md) | Deploy on Kubernetes | +| [SkyPilot](deploying_skypilot.md) | Deploy on cloud VMs with SkyPilot | +| [Slurm](deploying_slurm.md) | Run on HPC clusters with Slurm | -## Deploying an edit server +You can also deploy an edit server with [ssh port forwarding](../../faq.md#faq-remote) +using `marimo edit --headless`. -Here are a few ways to deploy an edit server on a remote instance: +## Apps -1. With [ssh-port forwarding](../../faq.md#faq-remote), using `marimo edit --headless`. -2. Via docker and our [prebuilt containers](prebuilt_containers.md). -3. Via a deployment service [such as Railway](deploying_railway.md). -4. [Behind JupyterHub](../../faq.md#faq-jupyter-hub). +Deploy notebooks as read-only web apps (`marimo run`) or embed them in +other applications. -## Deploying as read-only apps - -These guides help you deploy marimo notebooks as read-only apps. - -| | | -| :------------------------------ | :------------------------------------------------------- | -| [programmatically](programmatically.md) | Programmatically run and customize read-only marimo apps | -| [deploying_docker](deploying_docker.md) | Deploy with Docker | -| [authentication](authentication.md) | Authentication and security | -| [deploying_public_gallery](deploying_public_gallery.md) | Deploy to our public gallery | -| [deploying_hugging_face](deploying_hugging_face.md) | Deploy to Hugging Face | -| [deploying_ploomber](deploying_ploomber.md) | Deploy to Ploomber Cloud | +| Guide | Description | +| ----- | ----------- | +| [FastAPI](programmatically.md) | Programmatically run marimo apps as part of ASGI applications | +| [Authentication](authentication.md) | Authentication and security | +| [Docker](deploying_docker.md) | Deploy with Docker | +| [HuggingFace](deploying_hugging_face.md) | Deploy to Hugging Face Spaces | +| [Railway](deploying_railway.md) | Deploy to Railway | +| [nginx](deploying_nginx.md) | Deploy behind nginx | ### Health and status endpoints @@ -57,10 +53,56 @@ If you would like to deploy your application at a subpath, you can set the `--ba marimo run app.py --base-url /subpath ``` +### Deploying without WebSockets (experimental) + +marimo streams kernel messages to the browser over a WebSocket by default. +Some proxies and hosting services handle WebSockets poorly or not at all; for +these deployments, you can switch the kernel connection to server-sent +events (SSE) over plain HTTP with the `MARIMO_SERVER_TRANSPORT` environment +variable: + +```bash +MARIMO_SERVER_TRANSPORT=sse marimo run app.py +``` + +This setting is experimental. Keep in mind: + +- The terminal and language servers (LSP) in `marimo edit` still require + WebSockets and are unaffected by this setting. +- Real-time collaboration (`rtc_v2`) requires WebSockets and is disabled + when using SSE. +- Make sure proxies do not buffer the `/sse` endpoint's + `text/event-stream` responses (marimo sends `Cache-Control: no-transform` + and `X-Accel-Buffering: no` headers, and a keep-alive comment every 20 + seconds). +- Browsers cap concurrent HTTP/1.1 connections per origin (typically 6), so + prefer serving over HTTP/2 when many notebook tabs may be open at once. + ### Including code in your application -You can include code in your application by using the `--include-code` flag when running your application. +By default, `marimo run` does not send your notebook's source code to +browser clients, so viewers cannot read it from the browser (including +through dev tools). + +If you want to expose the code to viewers, pass the `--include-code` flag: ```bash marimo run app.py --include-code ``` + +### Redirecting standard output to the browser + +By default, Marimo doesn't send standard output (stdout) to the browser, but for debugging purposes you can enable this +with `--redirect-console-to-browser`: + +```bash +marimo run app.py --redirect-console-to-browser +``` + +### Showing error tracebacks in the browser + +By default, Marimo redacts error tracebacks in the browser for security reasons, but you can enable showing full tracebacks with `--show-tracebacks`: + +```bash +marimo run app.py --show-tracebacks +``` diff --git a/docs/guides/deploying/jupyterhub.md b/docs/guides/deploying/jupyterhub.md new file mode 100644 index 00000000000..6598fad42dc --- /dev/null +++ b/docs/guides/deploying/jupyterhub.md @@ -0,0 +1,73 @@ +# JupyterHub + +> For full documentation, visit the +> [marimo-jupyter-extension repository](https://github.com/marimo-team/marimo-jupyter-extension). + +The [marimo JupyterLab extension](https://github.com/marimo-team/marimo-jupyter-extension) +integrates marimo into JupyterLab and JupyterHub, letting you launch +marimo notebooks directly from JupyterLab's launcher, manage running sessions, +and convert Jupyter notebooks to marimo format. + +
+ marimo JupyterLab extension showing sidebar and editor +
marimo running in a JupyterHub deployment, in JupyterLab.
+
+ +## Features + +- **Launcher integration**: marimo appears in the JupyterLab launcher with its own icon +- **First-class notebook support**: double-click `_mo.py` files to open directly in marimo +- **Sidebar panel**: monitor server status, view running sessions with kill buttons, and access quick actions +- **Environment selection**: choose from available Python environments when creating notebooks; the extension discovers Jupyter kernel specs, letting you use your own venvs or conda environments. +- **Context menus**: right-click `.py` files to edit with marimo, or `.ipynb` files to convert to marimo format +- **Sandbox mode**: run marimo in isolated environments with `uvx` + +### File type handling + +| File Type | Double-click | Right-click | +|-----------|-------------|-------------| +| `_mo.py` | Opens in marimo | "Edit with marimo" | +| `.py` | Opens in standard editor | "Edit with marimo" | +| `.ipynb` | Opens in Jupyter | "Convert to marimo" | + +## Installation + +### Single environment + +```bash +uv pip install 'marimo[sandbox]>=0.19.11' marimo-jupyter-extension +``` + +### JupyterHub (multiple environments) + +| Package | Install location | Why | +|---------|-----------------|-----| +| `marimo` | User's environment | Access user's packages | +| `marimo-jupyter-extension` | Jupyter's environment | Jupyter must import it | + +## Configuration + +Configure the extension in `jupyterhub_config.py`: + +```python +# Explicit marimo path +c.MarimoProxyConfig.marimo_path = "/opt/bin/marimo" + +# Or use uvx mode (sandbox) +c.MarimoProxyConfig.uvx_path = "/usr/local/bin/uvx" + +# Startup timeout (default: 60s) +c.MarimoProxyConfig.timeout = 120 +``` + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| marimo icon missing | Install `marimo-jupyter-extension` in Jupyter's environment | +| marimo fails to launch | Ensure marimo is in PATH or configure `MarimoProxyConfig.marimo_path` | +| Modules not found | Install marimo in the same environment as your packages | +| Sandbox features not working | Upgrade to `marimo[sandbox]>=0.19.11` | + +For more troubleshooting tips, see the +[full documentation](https://github.com/marimo-team/marimo-jupyter-extension). diff --git a/docs/guides/deploying/prebuilt_containers.md b/docs/guides/deploying/prebuilt_containers.md index d9f335e055c..b3fe2189879 100644 --- a/docs/guides/deploying/prebuilt_containers.md +++ b/docs/guides/deploying/prebuilt_containers.md @@ -7,12 +7,12 @@ You can find the containers and tags on [marimo's GitHub packages page](https:// We provide the following variants: - `marimo:latest` - The latest version of marimo -- `marimo:latest-data` - The latest version of marimo with `altair`, `pandas`, and `numpy` preinstalled. -- `marimo:latest-sql` - The latest version of marimo with `marimo[sql]` and `duckdb` preinstalled. +- `marimo:latest-data` - The latest version of marimo with `marimo[recommended,lsp]`, `altair`, `pandas`, and `numpy` preinstalled. +- `marimo:latest-sql` - The latest version of marimo with `marimo[recommended,lsp,sql]` preinstalled. or any particular version of marimo; for example, `marimo:0.8.3`, `marimo:0.8.3-data`, `marimo:0.8.3-sql`. -Each container is built on `3.12-slim`, but if you'd like to see different configurations, please file an issue or submit a PR! +Each container is built on `3.13-slim`, but if you'd like to see different configurations, please file an issue or submit a PR! ## Running locally diff --git a/docs/guides/deploying/programmatically.md b/docs/guides/deploying/programmatically.md index 19e698b04db..63b7cba8d8b 100644 --- a/docs/guides/deploying/programmatically.md +++ b/docs/guides/deploying/programmatically.md @@ -38,6 +38,8 @@ if __name__ == "__main__": For a more complete example, see the [FastAPI example](https://github.com/marimo-team/marimo/tree/main/examples/frameworks/fastapi). +Note that when run in this mode, marimo will serve its static assets under the name of the notebook (in the example above, that would be `http://hostname//assets/`). If you are using custom authorization middleware, skip authentication for these assets to avoid server round-trips. There are _many_ of them. + ## Dynamic directory If you'd like to create a server to dynamically load marimo notebooks from a directory, you can use the `with_dynamic_directory` method. This is useful if the contents of the directory change often, such as a directory of notebooks for a dashboard, without restarting the server. @@ -64,3 +66,126 @@ for filename in sorted(notebooks_dir.iterdir()): server = server.with_app(path=f"/{app_name}", root=filename) app_names.append(app_name) ``` + +## Accessing Request Data + +Inside your marimo notebooks, you can access the current request data using `mo.app_meta().request`. This is particularly useful when implementing authentication or accessing user data. + +```python +import marimo as mo + +# Access request data in your notebook +request = mo.app_meta().request +if request and request.user and request.user["is_authenticated"]: + content = f"Welcome {request.user['username']}!" +else: + content = "Please log in" + +mo.md(content) +``` + +### Authentication Middleware Example + +Here's an example of how to implement authentication middleware that populates `request.user`. + +/// admonition | Use a pure ASGI middleware + type: warning + +Use a pure ASGI middleware (not Starlette's `BaseHTTPMiddleware`) so that +`scope["user"]` and `scope["meta"]` are set for **both** HTTP and WebSocket +connections. marimo uses WebSocket for real-time communication, and +`BaseHTTPMiddleware` only runs for HTTP requests. +/// + +```python +class AuthMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] in ("http", "websocket"): + # Add user data to the request scope + # This will be accessible via mo.app_meta().request.user + scope["user"] = { + "is_authenticated": True, + "username": "example_user", + # Add any other user data + } + # Optionally add metadata to the request + scope["meta"] = { + "some_key": "some_value", + } + await self.app(scope, receive, send) + +# Add the middleware to your FastAPI app +app.add_middleware(AuthMiddleware) +``` + +The `request` object provides access to: + +- `request.headers`: Request headers +- `request.cookies`: Request cookies +- `request.query_params`: Query parameters +- `request.path_params`: Path parameters +- `request.user`: User data added by authentication middleware +- `request.url`: URL information including path, query parameters +- `request.meta`: Metadata added by your custom middleware + +### Documenting and Validating Query Parameters + +When mounted apps accept [query parameters](../../api/query_params.md), it can be helpful to declare, validate, and document them with the help of a [Pydantic model](https://fastapi.tiangolo.com/tutorial/query-param-models/). + +If a marimo app called `notebooks/items.py` is mounted to `/items`, declaring an endpoint with the same route will take the query parameters through Pydantic model validation first, then redirect to the marimo endpoint. + +```python +# src/main.py +from fastapi import FastAPI, Request, Query +from fastapi.responses import RedirectResponse +from marimo import create_asgi_app +from pathlib import Path +from pydantic import BaseModel, Field +from typing import Annotated, Literal +from urllib.parse import urlencode + + +app = FastAPI() + + +class FilterParams(BaseModel): + limit: int = Field(100, gt=0, le=100) + offset: int = Field(0, ge=0) + order_by: Literal["created_at", "updated_at"] = "created_at" + tags: list[str] = [] + + +@app.get("/items") +async def marimo_items( + request: Request, filter_query: Annotated[FilterParams, Query()] +): + query_params = urlencode(filter_query.model_dump(), doseq=True) + return RedirectResponse(url=f"/items/?{query_params}") + + +server = create_asgi_app(include_code=True, quiet=False) +notebooks_dir = Path(__file__).parent.parent / "notebooks" + +for filename in notebooks_dir.iterdir(): + if filename.suffix == ".py": + app_name = filename.stem + server = server.with_app(path=f"/{app_name}", root=filename) + + +app.mount("/", server.build()) +``` + +## Under the Hood + +Behind the scenes, in this mode, marimo is spinning up a new computational kernel +in a separate sub-thread (same process) for each new session / app created. +There are a few implications of this from a performance and reliability perspective: + +- If you are running multiple instances of this same server for load balancing, you will need to use sticky sessions in your load balancer to ensure that the same client gets the same kernel each time. +- Similarly, attempting to run multiple instances of the same FastAPI + process (a common approach with Python web services) on the same node will not work reliably, since only one of them will actually be running the kernel. + +In summary, there are limitations to how far the approach described here can horizontally scale, so we recommend scaling vertically first. In other words, increase the container CPU/Memory specs before increasing the number of container instances. diff --git a/docs/guides/editor_features/agents.md b/docs/guides/editor_features/agents.md new file mode 100644 index 00000000000..c723f987ba0 --- /dev/null +++ b/docs/guides/editor_features/agents.md @@ -0,0 +1,189 @@ +# Agents + +!!! warning "Experimental Feature" + Agents are currently experimental and under active development. Features and APIs may change. + +!!! tip "Looking to pair an agent CLI with your notebook?" + Most users should start with [marimo pair](../generate_with_ai/marimo_pair.md), + which gives agent CLIs like Claude Code full access to your running notebook + from the terminal. The integration described on this page instead embeds an + agent inside the marimo editor's chat panel. + +marimo supports external AI agents that can interact with your codebase through the [Agent Client Protocol](https://agentclientprotocol.com/) (ACP). Agents can read and write marimo notebooks, helping you with coding tasks directly from the chat panel. + + + +## Supported agents + +marimo currently supports the following agents: + +### Claude Code Agent + +Claude Code Agent that uses your [Claude Code CLI subscription](https://docs.claude.com/en/docs/claude-code/overview) to help you with coding tasks. + +**Installation and login:** + +```bash +# Install +npm install -g @anthropic-ai/claude-code +# Login +claude +# Then type /login +``` + +**Connection command:** + +=== "macOS/Linux" + + ```bash + npx stdio-to-ws "npx @zed-industries/claude-code-acp" --port 3017 + ``` + +=== "Windows" + + ```bash + npx stdio-to-ws "cmd /c npx @zed-industries/claude-code-acp" --port 3017 + ``` + +### Gemini Agent + +Google's Gemini agent offers a limited free tier and login for more advanced features. + +See login and authentication instructions in the [Gemini CLI documentation](https://github.com/google-gemini/gemini-cli?tab=readme-ov-file#-authentication-options). + +**Connection command:** + +=== "macOS/Linux" + + ```bash + npx stdio-to-ws "npx @google/gemini-cli --experimental-acp" --port 3019 + ``` + +=== "Windows" + + ```bash + npx stdio-to-ws "cmd /c npx @google/gemini-cli --experimental-acp" --port 3019 + ``` + +### Codex Agent + +OpenAI's Codex agent uses [Codex CLI](https://github.com/openai/codex) via the [`@zed-industries/codex-acp`](https://github.com/zed-industries/codex-acp) adapter. + +**Installation and login:** + +```bash +# Install Codex CLI +npm install -g @openai/codex +# or: brew install --cask codex + +# Login (or set OPENAI_API_KEY / CODEX_API_KEY) +codex +``` + +**Connection command:** + +=== "macOS/Linux" + + ```bash + npx stdio-to-ws "npx @zed-industries/codex-acp" --port 3021 + ``` + +=== "Windows" + + ```bash + npx stdio-to-ws "cmd /c npx @zed-industries/codex-acp" --port 3021 + ``` + +### OpenCode Agent + +[OpenCode](https://opencode.ai/) is an open source AI coding agent built for the terminal, but also supports ACP. + +**Installation:** + +```bash +# Install +npm install -g opencode-ai@latest + +# You can now use and configure opencode from the commandline +opencode +``` + +**Connection command:** + +=== "macOS/Linux" + + ```bash + npx stdio-to-ws "npx opencode-ai acp" --port 3023 + ``` + +=== "Windows" + + ```bash + npx stdio-to-ws "cmd /c npx opencode-ai acp" --port 3023 + ``` + + +Opencode supports many models, including local ones through Ollama, and can be configured via a [configuration file](https://opencode.ai/docs/config/). + +```json +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "ollama": { + "npm": "@ai-sdk/openai-compatible", + "options": { + "baseURL": "http://localhost:11434/v1" + }, + "models": { + "": { + "tools": true + } + } + } + } +} +``` + +If you choose to use a local model with Ollama, make sure that you set the maximum context length to be much higher than the 4K default. [This video tutorial](https://www.youtube.com/watch?v=4hUI2GF90nQ) explains how to set this up. + +Opencode can also be configured to use remote models like those hosted by [OpenRouter](https://openrouter.ai/) or via [the Zen service](https://opencode.ai/docs/zen/). For more information on configuring OpenCode providers, see the [provider documentation](https://opencode.ai/docs/providers). + + +## Connecting to an agent + +1. **Start the agent server**: Run the connection command for your chosen agent in a terminal +2. **Enable the feature flag**: Enable the feature flag under the "Lab" section in the settings menu +3. **Open the agent panel**: Click the agents icon in marimo's sidebar +4. **Select your agent**: Choose the agent from the dropdown menu +5. **Start chatting**: The agent can now read and modify your notebooks + +!!! tip "Terminal integration" + + If you have terminal access enabled in marimo, you can run agent connection commands directly from the agent panel using the terminal button. + +!!! tip "Auto-run on agent edits" + + By default, when an agent modifies your notebook, cells are marked as stale instead of running automatically. To have cells run automatically when the agent saves changes, add this configuration to your `pyproject.toml`: + + ```toml + [tool.marimo.runtime] + watcher_on_save = "autorun" + ``` + + This provides a more seamless experience when working with agents, as you'll see results immediately after the agent makes changes. + +## Custom agents + +!!! info "Custom agents" + + Support for custom agents is coming soon. This will allow you to connect to your own ACP-compatible agents + +## Troubleshooting + +**Connection issues**: Ensure the agent server is running on the correct port before connecting in marimo. + +**Permission requests**: Agents may request permission to read or write files. Review these carefully before approving. + +**Session limits**: Currently, only one session per agent is supported for optimal performance. diff --git a/docs/guides/editor_features/ai_completion.md b/docs/guides/editor_features/ai_completion.md index 75fd7801174..887cea31f39 100644 --- a/docs/guides/editor_features/ai_completion.md +++ b/docs/guides/editor_features/ai_completion.md @@ -1,82 +1,115 @@ -# AI completion +# AI-assisted coding -marimo comes with GitHub Copilot, a tool that helps you write code faster by -suggesting in-line code suggestions based on the context of your current code. +!!! tip "Using an agent CLI like Claude Code? Try marimo pair" -marimo also comes with the ability to use AI for refactoring a cell, finishing writing a cell, or writing a full cell from scratch. -This feature is currently experimental and is not enabled by default. + This guide covers the AI assistant built into the marimo editor, which + connects to an [LLM provider](#connecting-to-an-llm) that you configure. + If you'd rather work with an agent CLI such as Claude Code, Codex, or + OpenCode — driven from your terminal, on your existing subscription — use + [marimo pair](../generate_with_ai/marimo_pair.md) instead, which gives + your agent full access to your running notebook. -## GitHub Copilot +marimo is an AI-native editor, with support for full-cell AI code generation: -The marimo editor natively supports [GitHub Copilot](https://copilot.github.com/), -an AI pair programmer, similar to VS Code. +* generating new cells from a prompt +* refactoring existing cells from a prompt +* generating entire notebooks -_Get started with Copilot_: +as well as inline autocompletion (like GitHub Copilot). -1. Install [Node.js](https://nodejs.org/en/download). -2. Enable Copilot via the settings menu in the marimo editor. +marimo's AI assistant is specialized for working with data: unlike traditional +assistants that only have access to the text of your program, marimo's assistant +has access to the values of variables in memory, letting it code against +your dataframe and database schemas. -!!! note "Installation Requirement" - Copilot is not yet available in our conda distribution; please install - marimo using ``pip`` if you need Copilot. +This guide provides an overview of these features and how to configure them. -## Codeium Copilot +!!! tip "Locating your marimo.toml config file" -1. Go to the Codeium website and sign up for an account: -2. Install the browser extension: -3. Open the settings for the Chrome extension and click on "Get Token" -4. Right-click on the extension window and select "Inspect" to open the developer tools for the extension. Then click on "Network" -5. Copy the token and paste it into the input area, and then press "Enter Token" -6. This action will log a new API request in the **Network** tab. Click on "Preview" to get the API key. -7. Paste the API key in the marimo settings in the UI, or add it to your `marimo.toml` file as follows: + Various instructions in this guide refer to the marimo.toml configuration + file. Locate this file with `marimo config show | head`. -```toml title="marimo.toml" -[completion] -copilot = "codeium" -codeium_api_key = "" -``` +## Generating cells with AI + + -### Alternative: Obtain Codeium API key using VS Code +marimo has built-in support for generating and refactoring code with LLMs. +marimo works with hosted AI providers, such as OpenAI, Anthropic, and Google, +as well as local models served via Ollama. -1. Go to the Codeium website and sign up for an account: -2. Install the [Codeium Visual Studio Code extension](vscode:extension/codeium.codeium) (see [here](https://codeium.com/vscode_tutorial) for complete guide) -3. Sign in to your Codeium account in the VS Code extension -4. Select the Codeium icon on the Activity bar (left side), which opens the Codeium pane -5. Select the **Settings** button (gear icon) in the top-right corner of the Codeium pane +**Enabling AI code generation.** To enable AI code generation, first install +required dependencies through the notebook settings.
-
- -
Open Codeium settings
+
+ +
Install required dependencies for AI generation through the notebook settings.
-6. Click the **Download** link under the **Extension Diagnostics** section -7. Open the diagnostic file and search for `apiKey` +Then configure your LLM provider through the AI tab in the settings menu; see +the section on [connecting your LLM](#connecting-to-an-llm) for detailed instructions. + +### Variable context + +marimo's AI assistant has your notebook code as context. You can additionally +pass variables and their values to the assistant by referencing them by name +with `@`. For example, to include the columns of a dataframe `df` in your +prompt, write `@df`.
-
- -
Download diagnostics file with API key
-
+
+ +
Pass variables to your prompt by tagging them with `@`.
+
-8. Copy the value of the `apiKey` to `.marimo.toml` in your home directory +### Refactor existing cells -```toml title="marimo.toml" -[completion] -codeium_api_key = "a1e8..." # <-- paste your API key here -copilot = "codeium" -activate_on_typing = true -``` +Make edits to an existing cell by hitting `Ctrl/Cmd-shift-e`, which opens a prompt box +that has your cell's code as input. + +
+
+ +
Use AI to modify a cell by pressing `Ctrl/Cmd-Shift-e`.
+
+
+ +### Generate new cells + +#### Generate with AI button + +At the bottom of every notebook is a button titled "Generate with AI". Click this +button to add entirely new cells to your notebook. + +#### Chat panel + +The chat panel on the left sidebar lets you chat with an LLM and ask questions +about your notebook. The LLM can also generate code cells that you can insert +into your notebook. + +The chat panel currently supports the following modes: + +- **Manual**: No tool access; the AI responds based only on the conversation and manually injected context +- **Ask**: Enables read-only [AI tools](tools.md) and [tools from added MCP Client servers](mcp.md#mcp-client) for context gathering, allowing the assistant to inspect your notebooks +- **Agent**: Enables all tools in **Ask Mode** plus additional tools to [edit notebook cells (add, remove, update) and run stale cells](tools.md#editing-agent-mode-only) +- **Code mode**: Gives the assistant access to the [notebook kernel](tools.md#code-mode) which allows the assistant to inspect the marimo runtime and manipulate the notebook in powerful ways. + +??? tip "See the chat panel in action" -## Generate code with our AI assistant + -marimo has built-in support for generating and refactoring code with AI, with a variety of providers. marimo works with hosted AI providers, such as OpenAI, Anthropic, and Google, as well as local models served via Ollama. +### Generating entire notebooks -### Custom AI Rules +Generate entire notebooks with `marimo new PROMPT` at the command-line; see the +[text-to-notebook docs](../generate_with_ai/text_to_notebook.md) to learn more. -You can customize how the AI assistant behaves by adding rules in the marimo settings. These rules help ensure consistent code generation across all AI providers. You can find more information about marimo's supported plotting libraries and data handling in the [plotting guide](https://docs.marimo.io/guides/working_with_data/plotting.html#plotting) and [working with data guide](https://docs.marimo.io/guides/working_with_data/index.html). +### Custom rules + +You can customize how the AI assistant behaves by adding rules in the marimo settings. These rules help ensure consistent code generation across all AI providers. You can find more information about marimo's supported plotting libraries and data handling in the [plotting guide](../working_with_data/plotting.md#plotting) and [working with data guide](../working_with_data/index.md).
@@ -87,10 +120,10 @@ You can customize how the AI assistant behaves by adding rules in the marimo set For example, you can add rules about: -- Preferred plotting libraries (matplotlib, plotly, altair) -- Data handling practices -- Code style conventions -- Error handling preferences +* Preferred plotting libraries (matplotlib, plotly, altair) +* Data handling practices +* Code style conventions +* Error handling preferences Example custom rules: @@ -112,122 +145,145 @@ For plotting: - Set appropriate color schemes ``` -To locate your configuration file, run: +### Connecting to an LLM + +You can connect to an LLM through the notebook settings menu, or by manually editing +your `marimo.toml` configuration file. **Prefer going through the notebook settings menu.** + +You can configure the following providers: + +* OpenAI +* Anthropic +* AWS Bedrock +* Google AI +* GitHub +* Ollama +* and any OpenAI-compatible provider + +See the [llm_providers](../configuration/llm_providers.md) guide for detailed instructions on how to configure each provider. -```bash -marimo config show +## Hide AI actions and panels + +If you don't use marimo's AI assistant, you can hide AI actions and panels in the editor: + +```toml title="marimo.toml" +[ai] +enabled = false ``` -At the top, the path to your `marimo.toml` file will be shown. You can Ctrl/Cmd+click the path to open it in your editor. For more information about configuration, see the [Configuration Guide](../configuration/index.md). +This hides AI assistant surfaces such as the Chat panel and Generate with AI. Code completion settings including GitHub Copilot, remain available. + +You can still collaborate with an AI agent using [marimo pair](../generate_with_ai/marimo_pair.md). -Below we describe how to connect marimo to your AI provider. Once enabled, you can generate entirely new cells by clicking the "Generate with AI" button at the bottom of your notebook. You can also refactor existing cells by inputting `Ctrl/Cmd-Shift-e` in a cell, opening an input to modify the cell using AI. +## Agents + +!!! example "Experimental: Agents panel" + + marimo can also embed agents like Claude Code, Codex, and Gemini CLI + directly in the editor's chat panel. Learn more in the + [agents](agents.md) guide. + +## Copilots + +Copilots allow you to tab-complete code based on your notebook's context, similar to editors like Cursor. + + + +### GitHub Copilot + +The marimo editor natively supports [GitHub Copilot](https://copilot.github.com/), +an AI pair programmer, similar to VS Code: + +1. Install [Node.js](https://nodejs.org/en/download). +2. Enable Copilot via the settings menu in the marimo editor.
- -
Use AI to modify a cell by pressing `Ctrl/Cmd-Shift-e`.
+ +
Follow these instructions.
-### Using OpenAI +_GitHub Copilot is not yet available in our conda distribution; please install +marimo using `pip`/`uv` if you need Copilot._ -1. Install openai: `pip install openai` +#### Advanced configuration -2. Add the following to your `marimo.toml`: +You can customize GitHub Copilot's behavior by adding `copilot_settings` to your `marimo.toml` configuration file: ```toml title="marimo.toml" -[ai.open_ai] -# Get your API key from https://platform.openai.com/account/api-keys -api_key = "sk-proj-..." -# Choose a model, we recommend "gpt-4-turbo" -model = "gpt-4-turbo" -# Available models: gpt-4-turbo-preview, gpt-4, gpt-3.5-turbo -# See https://platform.openai.com/docs/models for all available models - -# Change the base_url if you are using a different OpenAI-compatible API -base_url = "https://api.openai.com/v1" +[ai.github] +copilot_settings = { http = { proxy = "http://proxy.example.com:8888", proxyStrictSSL = true } } ``` -### Using Anthropic - -To use Anthropic with marimo: - -1. Sign up for an account at [Anthropic](https://console.anthropic.com/) and grab your [Anthropic Key](https://console.anthropic.com/settings/keys). -2. Add the following to your `marimo.toml`: +Or in a more readable format: ```toml title="marimo.toml" -[ai.open_ai] -model = "claude-3-5-sonnet-20240620" -# or any model from https://docs.anthropic.com/en/docs/about-claude/models +[ai.github.copilot_settings.http] +proxy = "http://proxy.example.com:8888" +proxyStrictSSL = true -[ai.anthropic] -api_key = "sk-ant-..." +[ai.github.copilot_settings.github-enterprise] +uri = "https://github.enterprise.com" # For GitHub Enterprise users ``` -### Using Google AI +Available configuration options (these are the same settings directory from the [npm package](https://github.com/orgs/github/packages/npm/package/copilot-language-server): -To use Google AI with marimo: +* **HTTP settings**: Configure proxy settings for network connections + * `proxy`: HTTP proxy URL (e.g., `"http://proxy.example.com:8888"`) + * `proxyStrictSSL`: Whether to verify SSL certificates for the proxy (default: `false`) + * `proxyKerberosServicePrincipal`: Kerberos service principal for proxy authentication -1. Sign up for an account at [Google AI Studio](https://aistudio.google.com/app/apikey) and obtain your API key. -2. Install the Google AI Python client: `pip install google-generativeai` -3. Add the following to your `marimo.toml`: +* **Telemetry settings**: Control telemetry data collection + * `telemetryLevel`: Level of telemetry to send - `"off"`, `"crash"`, `"error"`, or `"all"` (default: `"off"`) -```toml title="marimo.toml" -[ai.open_ai] -model = "gemini-1.5-flash" -# or any model from https://ai.google.dev/gemini-api/docs/models/gemini +* **GitHub Enterprise**: Configure GitHub Enterprise Server + * `uri`: URL of your GitHub Enterprise Server instance -[ai.google] -api_key = "AI..." -``` +### Windsurf Copilot -### Using local models with Ollama { #using-ollama } +[Windsurf](https://windsurf.com/) (formerly codeium) provides tab-completion tooling that can also be used from within marimo. -Ollama allows you to run open-source LLMs on your local machine. To integrate Ollama with marimo: +To set up Windsurf: -1. Download and install [Ollama](https://ollama.com/). -2. Download the model you want to use: +1. Go to [windsurf.com](https://windsurf.com/) website and sign up for an account. +2. Download the [Windsurf app](https://windsurf.com/download). +3. After installing Windsurf and authenticating, open up the command palette, via cmd+shift+p, and ask it to copy the api key to your clipboard. - ```bash - # View available models at https://ollama.com/library - ollama pull llama3.1 - ollama pull codellama # recommended for code generation +![Copy Windsurf API key](../../_static/windsurf-api.png) - # View your installed models - ollama ls - ``` +4a. Configure the UI settings in the editor to use Windsurf. -3. Start the Ollama server in a terminal: +![Paste Windsurf API key](../../_static/windsurf-settings.png) - ```bash - ollama serve - # In a new terminal - ollama run codellama # or any model from ollama ls - ``` +4b. Alternatively you can also configure the api key from the marimo config file. -4. Visit to confirm that the server is running. +```toml title="marimo.toml" +[completion] +copilot = "codeium" +codeium_api_key = "" +``` - > **Note**: If you get a "port already in use" error, you may need to close an existing Ollama instance. On Windows, click the up arrow in the taskbar, find the Ollama icon, and select "Quit". This is a known issue (see [Ollama Issue #3575](https://github.com/ollama/ollama/issues/3575)). Once you've closed the existing Ollama instance, you should be able to run `ollama serve` successfully. +### Custom copilots -5. Open a new terminal and start marimo: +marimo also supports integrating with custom LLM providers for code completion suggestions. This allows you to use your own LLM service to provide in-line code suggestions based on internal providers or local models (e.g. Ollama). You may also use OpenAI, Anthropic, Google, or any other providers by providing your own API keys and configuration. - ```bash - marimo edit notebook.py - ``` +To configure a custom copilot: -6. Add the following to your `marimo.toml`: +1. Ensure you have an LLM provider that offers API access for code completion (either external or running locally) +2. Add the following configuration to your `marimo.toml` (or configure in the UI settings in the editor): ```toml title="marimo.toml" -[ai.open_ai] -api_key = "ollama" # This is not used, but required -model = "codellama" # or another model from `ollama ls` -base_url = "http://127.0.0.1:11434/v1" -``` - -### Using other AI providers +[ai.models] +autocomplete_model = "provider/model-name" -marimo supports OpenAI's API by default. Many providers offer OpenAI API-compatible endpoints, which can be used by simply changing the `base_url` in your configuration. For example, providers like [GROQ](https://console.groq.com/docs/openai) follow this pattern. +[completion] +copilot = "custom" +``` -For a comprehensive list of compatible providers and their configurations, please refer to the [liteLLM Providers documentation](https://litellm.vercel.app/docs/providers). +The configuration options include: -For providers not compatible with OpenAI's API, please submit a [feature request](https://github.com/marimo-team/marimo/issues/new?template=feature_request.yaml) or "thumbs up" an existing one. +* `autocomplete_model`: The specific model to use for inline autocompletion. +* `copilot`: The name of the copilot to use for code generation. diff --git a/docs/guides/editor_features/dataflow.md b/docs/guides/editor_features/dataflow.md new file mode 100644 index 00000000000..b71cb5d4dea --- /dev/null +++ b/docs/guides/editor_features/dataflow.md @@ -0,0 +1,298 @@ +# Understanding dataflow + +Unlike traditional notebooks, marimo understands the relationships between +cells and uses this information to keep your code and outputs consistent. These +relationships are represented as a **dataflow graph**, which encodes how +variables flow from one cell to another. + +The dataflow graph, which is inferred statically from variable definitions and +references, is used to automatically run (or mark stale) cells in the correct +sequence; it's also why cells can be arranged "out of order" on the page, or +across columns. + +marimo provides several tools to help you visualize and understand the +relationships it identifies between cells. + +## Variables explorer + +The **variables explorer panel** collects marimo's understanding of the +variables in your notebook into a single searchable list. + +
+ + + Variables explorer panel showing variable names, types, and relationships + +
+ +To open the panel, click the **variables icon** in the **left sidebar panel**. +The variable explorer shows each variable's name, type, value, where it's +defined, and where it's used. + +## Dependencies panel + +The **Dependencies panel** consolidates dataflow visualization tools into a +single location with two tabs: **Minimap** (default) and **Graph**. + +To open it, click the **Dependencies icon** in the sidebar, or use the hotkey +`Cmd/Ctrl-Shift-I` to toggle the panel. + +
+ + + Dependencies panel showing minimap and graph tabs + +
+ +### Minimap + +The **Minimap** tab (shown by default) provides a _focused slice_ of your +notebook's dataflow, helping you understand the reactive context of a given +cell and navigate related cells. + +Click a cell in the minimap to jump to it: + +
+ +
+ +Connections are read **left to right**: + +- Connections to the **left** are _direct inputs_ — cells the current cell reads from +- Connections to the **right** are _direct outputs_ — cells that read from the current cell +- Cells positioned left or right but not directly connected are _transitive +dependencies_ — cells that influence or are influenced by the current cell, but +only through one or more intermediate cells + +The minimap can take some getting used to, but it's an effective representation +for understanding how data flows around the current cell. It's meant to show +_just enough_ local context to help you debug, trace relationships, and +navigate complex notebooks. For a high level overview, use the [Graph +view](#graph-view). + +### Cell symbols + +The minimap uses visual indicators to show the status and connectivity of each cell: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SymbolMeaning
+ + + + + + Cell uses variables from other cells
+ + + + + + Cell defines variables used by other cells
+ + + + + + + + + Cell uses variables and defines variables used by others +
+ + + + + Cell defines variables but isn't connected to anything (safe to delete) +
+ + + + + Cell doesn't define or use variables from other cells (often markdown) +
+ + + + Cell has an error
+ + +### Reading cell connections + +When you select a cell, the minimap draws lines showing how data flows between +cells. Since marimo cells can define multiple variables, downstream connections +show all cells that reference any variable from your selected cell. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathInterpretation
+ + + + + + + First cell defines variables used by the second cell. Second cell will + re-run when the first runs +
+ + + + + + + First cell uses variables from the second cell. First cell will re-run + when the second runs +
+ + + + + + + + Gray cell has no connection to the blue cells above +
+ + + + + + + + + Gray cell indirectly uses variables from the first cell. Whiskers + indicate transitive dependencies +
+ + + + + + + + + Gray cell's variables are indirectly used by the second cell. Whiskers + indicate transitive dependencies +
+ + + + + + + Cells have circular dependencies - each uses variables from the other + (error) +
+ + +### Implementation notes + +The minimap was heavily inspired by [Observable's +minimap](https://observablehq.com/documentation/debugging/minimap), a +[thoughtfully +designed](https://observablehq.com/@observablehq/introducing-visual-dataflow) +dataflow visualization for their reactive JavaScript notebooks. + +We adapted Observable's visual design to marimo's execution model. A key +difference: Observable cells are named (declaring one variable), while marimo +cells can define multiple variables. This leads to asymmetric dataflow tracing. +When tracing upstream, we can identify exactly which variables from a cell +depends on. When tracing downstream, all variables in a dependent cell are +considered affected. Our minimap also accounts for marimo's support for +multi-column layouts. + +### Graph view + +The **Graph** tab provides a _bird's-eye view_ of your notebook's dataflow, +showing all cells as an interactive graph. It helps you understand high-level +patterns, overall connectedness, and the broader structure of your notebook. + +
+ + + Graph view showing all cells as an interactive dataflow graph + +
+ +You can choose between vertical or horizontal layouts for the graph. + +## Reactive reference highlighting + +marimo's **reactive reference highlighting** provides an _in-editor_ indicator +when variables defined by other cells are used in the current cell. These +"reactive references" are emphasized with an underline and lightly bolded text: + +
+ + + Reactive reference highlighting showing variable usage across cells + +
+ +Hover over any underlined variable and `Cmd/Ctrl-Click` to jump to its +definition. diff --git a/docs/guides/editor_features/home.md b/docs/guides/editor_features/home.md new file mode 100644 index 00000000000..d8d7b9f5c1f --- /dev/null +++ b/docs/guides/editor_features/home.md @@ -0,0 +1,95 @@ +# Home page + +Running `marimo edit` without a filename opens the home page, which lets you +browse and manage notebooks in a directory. + +## Usage + +```bash +# Open home page for current directory +marimo edit + +# Open home page for a specific folder +marimo edit folder/ +``` + +The home page shows all marimo notebooks in the directory, letting you: + +- Open existing notebooks +- Create new notebooks +- See notebook metadata + +## Sandboxed Home + +You can run the home page in sandbox mode (called "Sandboxed Home"), where +each notebook gets its own isolated environment: + +```bash +marimo edit --sandbox folder/ +``` + +When using Sandboxed Home: + +1. Each notebook runs in its own isolated environment +2. Dependencies are read from each notebook's [inline script metadata](../package_management/inlining_dependencies.md) (PEP 723) +3. Environments are created on-demand when you open a notebook + +This is useful when you have a collection of notebooks with different +dependencies and want to keep them isolated from each other. + +!!! note "Additional dependencies required" + + Sandboxed Home requires additional packages: + + ```bash + uv add 'marimo[sandbox]' + ``` + + This installs `pyzmq` (for inter-process communication) and `uv` + (for environment management). + +### Using custom virtual environments + +When using Sandboxed Home, you can specify an existing virtual environment +for a notebook instead of having marimo create one automatically. +This is configured using `[tool.marimo.venv]` in your script metadata: + +```python +# /// script +# [tool.marimo.venv] +# path = "path/to/venv" # relative or absolute path +# writable = false # optional, default is false +# /// +``` + +!!! note "Sandboxed Home only" + + The `[tool.marimo.venv]` configuration only applies when using + Sandboxed Home (`marimo edit --sandbox folder/`). For single notebooks, + activate your virtual environment before running marimo: + + ```bash + source path/to/venv/bin/activate + marimo edit notebook.py + ``` + +#### Configuration options + +| Option | Description | +|--------|-------------| +| `path` | Path to the virtual environment (relative or absolute) | +| `writable` | Whether marimo can install packages into the venv (default: `false`) | + +#### Behavior + +| `writable` | marimo installed? | What happens | +|:-----------|:------------------|:-------------| +| `true` | - | marimo installs itself and required dependencies into the venv | +| `false` | Yes | Uses the venv as-is (warns if marimo version differs) | +| `false` | No | Injects `PYTHONPATH` for marimo (requires matching Python version) | + +This is useful when: + +- You have a conda or poetry environment you want to reuse +- You're working in a team with a shared environment +- You want notebooks in a folder to use different pre-configured environments diff --git a/docs/guides/editor_features/hotkeys.md b/docs/guides/editor_features/hotkeys.md index 3bb66af73e5..d8976095a75 100644 --- a/docs/guides/editor_features/hotkeys.md +++ b/docs/guides/editor_features/hotkeys.md @@ -1,57 +1,62 @@ # Hotkeys -If you'd like to override the default hotkeys, you can do so in the hotkeys menu (`Mod-Shift-h`), or modifying your `marimo.toml`. +If you'd like to override the default hotkeys, you can do so in the hotkeys menu (`Ctrl/Cmd-Shift-h`), or modifying your `marimo.toml`. You can find a list of available hotkeys below: -| Hotkey | -| --------------------------- | -| `cell.aiCompletion` | -| `cell.cellActions` | -| `cell.complete` | -| `cell.createAbove` | -| `cell.createBelow` | -| `cell.delete` | -| `cell.findAndReplace` | -| `cell.focusDown` | -| `cell.focusUp` | -| `cell.fold` | -| `cell.foldAll` | -| `cell.format` | -| `cell.goToDefinition` | -| `cell.hideCode` | -| `cell.moveDown` | -| `cell.moveUp` | -| `cell.redo` | -| `cell.run` | -| `cell.runAndNewAbove` | -| `cell.runAndNewBelow` | -| `cell.selectNextOccurrence` | -| `cell.sendToBottom` | -| `cell.sendToTop` | -| `cell.splitCell` | -| `cell.undo` | -| `cell.unfold` | -| `cell.unfoldAll` | -| `cell.viewAsMarkdown` | -| `global.commandPalette` | -| `global.focusBottom` | -| `global.focusTop` | -| `global.foldCode` | -| `global.formatAll` | -| `global.hideCode` | -| `global.interrupt` | -| `global.runStale` | -| `global.save` | -| `global.showHelp` | -| `global.toggleLanguage` | -| `global.toggleTerminal` | -| `global.toggleSidebar` | -| `global.unfoldCode` | -| `markdown.blockquote` | -| `markdown.bold` | -| `markdown.code` | -| `markdown.italic` | -| `markdown.link` | -| `markdown.orderedList` | -| `markdown.unorderedList` | +| Hotkey | +| --------------------------------------------- | +| `cell.aiCompletion` | +| `cell.cellActions` | +| `cell.complete` | +| `cell.createAbove` | +| `cell.createBelow` | +| `cell.delete` | +| `cell.findAndReplace` | +| `cell.focusDown` | +| `cell.focusUp` | +| `cell.fold` | +| `cell.foldAll` | +| `cell.format` | +| `cell.goToDefinition` | +| `cell.hideCode` | +| `cell.moveUp` | +| `cell.moveDown` | +| `cell.moveLeft` | +| `cell.moveRight` | +| `cell.redo` | +| `cell.run` | +| `cell.runAndNewAbove` | +| `cell.runAndNewBelow` | +| `cell.selectNextOccurrence` | +| `cell.sendToBottom` | +| `cell.sendToTop` | +| `cell.splitCell` | +| `cell.undo` | +| `cell.unfold` | +| `cell.unfoldAll` | +| `cell.viewAsMarkdown` | +| `completion.moveDown` | +| `completion.moveUp` | +| `global.commandPalette` | +| `global.focusBottom` | +| `global.focusTop` | +| `global.foldCode` | +| `global.formatAll` | +| `global.hideCode` | +| `global.interrupt` | +| `global.runStale` | +| `global.save` | +| `global.showHelp` | +| `global.toggleLanguage` | +| [`global.toggleMinimap`](dataflow.md#minimap) | +| `global.toggleTerminal` | +| `global.toggleSidebar` | +| `global.unfoldCode` | +| `markdown.blockquote` | +| `markdown.bold` | +| `markdown.code` | +| `markdown.italic` | +| `markdown.link` | +| `markdown.orderedList` | +| `markdown.unorderedList` | diff --git a/docs/guides/editor_features/index.md b/docs/guides/editor_features/index.md index a2e94a3c784..a7264f5f4b1 100644 --- a/docs/guides/editor_features/index.md +++ b/docs/guides/editor_features/index.md @@ -1,4 +1,3 @@ - # Editor features The **marimo editor** is the browser-based IDE in which you write marimo @@ -6,22 +5,26 @@ notebooks. We've taken a batteries-included approach to designing the editor: it comes _packed_ with features to make you productive when working with code and data. -| Guide | Description | -|-------|-------------| -| [Overview](overview.md) | An overview of editor features and configuration | -| [Package Management](package_management.md) | Using package managers in marimo | -| [AI Completion](ai_completion.md) | Code with the help of a language model | -| [Hotkeys](hotkeys.md) | Our hotkeys | +| Guide | Description | +| ------------------------------------------- | ------------------------------------------------ | +| [Overview](overview.md) | An overview of editor features and configuration | +| [Sidebar and Developer Panel](panels.md) | Customize your workspace with panels | +| [Understanding dataflow](dataflow.md) | Visualize and navigate cell dependencies | +| [Package Management](package_management.md) | Using package managers in marimo | +| [AI Completion](ai_completion.md) | Code with the help of a language model | +| [Language Server](language_server.md) | Code intelligence via LSP | +| [Hotkeys](hotkeys.md) | Our hotkeys | Highlights include: -- a variables panel that lets you explore variable values and see where they are defined +- [dataflow tools](dataflow.md) including a variables panel, dependency graph, and minimap for understanding notebook structure - a data explorer that lets you inspect dataframes and tables at a glance - smart module autoreloading that tells you which cells need to be rerun - code completion -- GitHub Copilot +- [GitHub Copilot](ai_completion.md#github-copilot) - language-model assisted coding -- vim keybindings +- [language server protocol](language_server.md) (LSP) for diagnostics and code intelligence +- [vim keybindings](overview.md#vim-keybindings) - live documentation preiews as you type and much more. diff --git a/docs/guides/editor_features/language_server.md b/docs/guides/editor_features/language_server.md new file mode 100644 index 00000000000..e95801751ba --- /dev/null +++ b/docs/guides/editor_features/language_server.md @@ -0,0 +1,155 @@ +# Language Server Protocol (LSP) + +The marimo editor supports the Language Server Protocol (LSP) to provide enhanced code intelligence features like: + +- Code completion +- Hover information +- Go to definition +- Error checking and diagnostics + +Multiple language servers can be run simultaneously. When +enabled, their results are merged — for example, you can +get completions from pylsp and diagnostics from basedpyright at the same time. + +## Supported language servers + +### pylsp (python-lsp-server) + +The core Python language server providing completions, hover, go-to-definition, +diagnostics, code actions, rename, and signature help. + +**Install:** + +```bash +pip install "marimo[lsp]" +# or +uv add "marimo[lsp]" +# or +conda install -c conda-forge python-lsp-server python-lsp-ruff +``` + +This installs: + +- [`python-lsp-server`](https://github.com/python-lsp/python-lsp-server): The core Python language server +- [`python-lsp-ruff`](https://github.com/python-lsp/python-lsp-ruff): Ruff integration for fast linting + +You may optionally install other `pylsp` plugins (e.g. `pylsp-mypy`). + +**Configuration:** + +```toml title="pyproject.toml" +[tool.marimo.language_servers.pylsp] +enabled = true # Enable/disable pylsp +enable_mypy = true # Type checking with mypy (enabled by default, if installed) +enable_ruff = true # Linting with ruff (enabled by default, if installed) +enable_flake8 = false # Linting with flake8 +enable_pydocstyle = false # Check docstring style +enable_pylint = false # Linting with pylint +enable_pyflakes = false # Syntax checking with pyflakes +``` + +### basedpyright + +A type checker for Python built on Pyright. In marimo, basedpyright is used +for **diagnostics only** (type checking errors and warnings). + +**Install:** + +```bash +uv pip install basedpyright +``` + +**Configuration:** + +```toml title="pyproject.toml" +[tool.marimo.language_servers.basedpyright] +enabled = true +``` + +See the [basedpyright docs](https://docs.basedpyright.com) for more information. + +### ty + +A type checker for Python from [Astral](https://astral.sh/) (the creators of +Ruff). In marimo, ty is used for **diagnostics only**. + +**Install:** + +```bash +uv pip install ty +``` + +**Configuration:** + +```toml title="pyproject.toml" +[tool.marimo.language_servers.ty] +enabled = true +``` + +See the [ty docs](https://docs.astral.sh/ty/features/language-server/) for more information. + +### pyrefly + +A type checker for Python from Meta. Pyrefly provides completions, hover, +go-to-definition, and diagnostics. + +**Install:** + +```bash +uv pip install pyrefly +``` + +**Configuration:** + +```toml title="pyproject.toml" +[tool.marimo.language_servers.pyrefly] +enabled = true +``` + +See the [pyrefly docs](https://pyrefly.org/en/docs/IDE-features/) for more information. + +### GitHub Copilot + +AI-powered code completions via the GitHub Copilot language server. Copilot is +configured separately from the other language servers through the completion +settings. + +**Configuration:** + +```toml title="pyproject.toml" +[tool.marimo.completion] +copilot = "github" +``` + +See the [AI completion guide](ai_completion.md#github-copilot) for +more details on Copilot setup and configuration. + +## Using multiple language servers + +You can enable multiple language servers at once. marimo uses a federated +architecture that merges results from all active servers. For example, you might +use pylsp for completions and code actions while relying on basedpyright or ty +for stricter type-checking diagnostics. + +## Diagnostics + +Diagnostics from all language servers can be toggled globally: + +```toml title="pyproject.toml" +[tool.marimo.diagnostics] +enabled = true # Show diagnostics in the editor +``` + +## WebAssembly + +Language servers are not available when running marimo in WebAssembly. + +## Troubleshooting + +If you encounter issues with a language server: + +1. Make sure you've installed the required dependencies with `uv pip install "marimo[lsp]"` +2. For basedpyright, ty, and pyrefly, ensure [Node.js](https://nodejs.org/) is installed +3. Check if the language server is enabled in your configuration +4. Try restarting the marimo server +5. Check the terminal for error messages or the log files in your marimo log directory (e.g. `~/.cache/marimo/logs/`) diff --git a/docs/guides/editor_features/mcp.md b/docs/guides/editor_features/mcp.md new file mode 100644 index 00000000000..7bd286d10e4 --- /dev/null +++ b/docs/guides/editor_features/mcp.md @@ -0,0 +1,149 @@ +# Model Context Protocol (MCP) + +!!! warning "Experimental Feature" + MCP features are currently experimental and under active development. Features and APIs may change. + +marimo supports the Model Context Protocol (MCP) in two ways: as an [MCP server](mcp.md#mcp-server) that exposes marimo's [AI tools](tools.md) to external applications, and as an [MCP client](mcp.md#mcp-client) that connects [supported servers](mcp.md#supported-servers) to marimo's [chat panel](ai_completion.md#chat-panel). + +## Prerequisites + +Both MCP server and client features require the MCP dependencies. Run marimo with MCP support using one of the following methods: + +/// tab | uv +```bash +# run with uv in a project +uv run --with="marimo[mcp]" marimo edit notebook.py --mcp --no-token +``` +/// + +/// tab | uvx +```bash +# run with uvx anywhere +uvx "marimo[mcp]" edit notebook.py --mcp --no-token +``` +/// + +/// tab | pip +```bash +# install with pip and a venv +pip install "marimo[mcp]" +marimo edit notebook.py --mcp --no-token +``` +/// + +!!! note "Flags" + The `--mcp` flag exposes an endpoint that provides access to your notebook data via the MCP server endpoint. Remove `--mcp` if you only want MCP Client features. The `--no-token` flag removes authentication, which should only be used for local development. Remove `--no-token` in production environments. + +## MCP Server + +marimo can expose its [AI tools](tools.md) through an MCP server endpoint, allowing external AI applications to interact with your notebooks. If your goal is to have a coding agent drive a live notebook, see [marimo pair](../generate_with_ai/marimo_pair.md) instead; the MCP server exposes lower-level, read-only tools. + + + +### Available tools + +When connected to marimo's MCP server, external applications can access all [AI tools](tools.md). + +### Available prompts + +When connected to marimo's MCP server, external applications can access the following prompts: + +| Prompt | Description | +|--------|-------------| +| **active_notebooks** | Get current active notebooks and their session IDs and file paths. Returns session IDs and file paths for all active marimo notebook sessions, along with guidance on using these IDs with marimo MCP tools. | +| **errors_summary** | Get error summaries for all active notebooks. Returns a summary of all errors across active notebooks organized by notebook and cell, including error types, messages, and affected cell IDs. | + +### Connecting external applications + +marimo's MCP server works with any MCP-compatible application. Below are setup instructions for some commonly used applications: + +!!! tip "Connection details" + Replace `PORT` with your marimo server port in the examples below. If authentication is enabled, append `?access_token=YOUR_TOKEN` to the URL and replace `YOUR_TOKEN` with your marimo access token. + +#### Claude Code + +Use Claude Code's CLI to connect to marimo: + +```bash +claude mcp add --transport http marimo http://localhost:PORT/mcp/server +``` + +#### Cursor + +Configure Cursor to connect to marimo's MCP server: + +```json +{ + "mcpServers": { + "marimo": { + "url": "http://localhost:PORT/mcp/server" + } + } +} +``` + +#### VS Code + +Create a .vscode/mcp.json file in your workspace and configure it to connect to marimo's MCP server: + +```json +{ + "servers": { + "marimo": { + "type": "http", + "url": "http://localhost:PORT/mcp/server" + } + } +} +``` + +### DNS rebinding protection + +marimo's MCP server enables DNS rebinding protection by default, which validates the `Host` header on incoming requests. This is common when using proxies, gateways (e.g., ECS), CDNs (Cloudflare), or custom domains, where the forwarded `Host` header won't match the expected local host, causing `421 Misdirected Request` or `Invalid Host Header` errors. Pass `--mcp-allow-remote` to disable this check: + +```bash +marimo edit notebook.py --mcp --mcp-allow-remote +``` + +## MCP Client + +marimo can connect to external MCP servers to add additional tools and context to the [chat panel](ai_completion.md#chat-panel). + +### Supported servers + +marimo currently supports the following MCP servers: + +| Server | Description | +|--------|-------------| +| `marimo` | Provides marimo's official documentation, API reference, and code examples | +| `context7` | Fetches up-to-date, version-specific documentation and code examples from official sources | + +### Configuration + +Enable MCP client servers through the marimo settings UI: + +
+
+ +
Enable MCP servers in the AI settings panel.
+
+
+ +Alternatively, configure MCP servers in your marimo configuration file: + +```toml title="marimo.toml" +[mcp] +presets = ["marimo", "context7"] +``` + +Once configured, tools from these servers will be automatically available in the [chat panel when using ask mode](ai_completion.md#chat-panel). + +!!! info "Custom MCP servers" + Support for custom MCP server configuration is not yet available. + +## Related documentation + +- [AI tools](tools.md) - Available tools exposed by the MCP server +- [AI-assisted coding](ai_completion.md#chat-panel) - Using the chat panel with MCP tools diff --git a/docs/guides/editor_features/module_autoreloading.md b/docs/guides/editor_features/module_autoreloading.md new file mode 100644 index 00000000000..f1ad2279778 --- /dev/null +++ b/docs/guides/editor_features/module_autoreloading.md @@ -0,0 +1,34 @@ +# Module autoreloading + +marimo has an advanced module autoreloader built-in, which you can +enable in the [notebook settings](../configuration/runtime_configuration.md). +When you make edits to Python modules that your notebook has imported, the +module autoreloader will automatically mark cells that use them as stale and, +optionally, automatically run them. + +!!! question "Why autoreload?" + + Autoreloading enables a workflow that many developers find + productive: develop complex logic in Python modules, and use the marimo + notebook as a DAG or main script that orchestrates your logic. + +Based on static analysis, the reloader only runs cells affected by your edits. +The reloader is recursive, meaning that marimo tracks modifications for modules +imported by your notebook's imported modules too. These two features make +marimo's module autoreloader far more advanced than IPython's. + +Autoreloading comes in two types: + +1. **autorun**: automatically re-runs cells affected by module modification. + +
+ +
When set to autorun, marimo's reloader automatically run cells when you edit Python files.
+
+ +2. **lazy**: marks cells affected by module modifications as stale, letting you know which cells need to be re-run. + +
+ +
When set to lazy, marimo's reloader marks cells as stale when you edit Python files.
+
diff --git a/docs/guides/editor_features/overview.md b/docs/guides/editor_features/overview.md index f3c9dccf96d..c4a5b0686f6 100644 --- a/docs/guides/editor_features/overview.md +++ b/docs/guides/editor_features/overview.md @@ -2,14 +2,14 @@ This guide introduces some of marimo editor's features, including a variables panel, dependency graph viewer, table of contents, HTML export, -GitHub copilot, code formatting, a feedback form, and more. +[GitHub copilot](ai_completion.md#github-copilot), code formatting, a feedback form, and more. ## Configuration The editor exposes of a number of settings for the current notebook, as well as user-wide configuration that will apply to all your notebooks. These settings include the option to display the current notebook in -full width, to use vim keybindings, to enable GitHub copilot, and more. +full width, to use [vim keybindings](#vim-keybindings), to enable [GitHub copilot](ai_completion.md#github-copilot), and more. To access these settings, click the gear icon in the top-right of the editor: @@ -19,45 +19,105 @@ To access these settings, click the gear icon in the top-right of the editor: A non-exhaustive list of settings: +- [Command mode](#command-mode) - Outputs above or below code cells -- [Disable/enable autorun](../reactivity.md#runtime-configuration) +- [Disable/enable autorun](../reactivity.md#configuring-how-marimo-runs-cells) - Package installation -- Vim keybindings +- [Vim keybindings](#vim-keybindings) - Dark mode - Auto-save - Auto-complete - Editor font-size - Code formatting with ruff/black -- [GitHub Copilot](ai_completion.md) +- [GitHub Copilot](ai_completion.md#github-copilot) - [LLM coding assistant](ai_completion.md) - [Module autoreloading](../configuration/runtime_configuration.md#on-module-change) +- [Reactive reference highlighting](dataflow.md#reactive-reference-highlighting) + +## Command mode + +marimo distinguishes between editing cell content and working with cells at the +notebook level. + +**Command mode** lets you navigate, select, and manipulate _cells_ rather than +editing their contents. + +**Enter/Exit:** + +- Enter command mode: `Esc` (from cell editor) or `Ctrl+Esc`/`Cmd+Esc` (when [vim keybindings](#vim-keybindings) are enabled, `Shift+Esc` on Windows) +- Exit command mode: `Enter` or click on a cell + +**Shortcuts:** + +- `↓`/`↑` - navigate cells +- `Shift+↓`/`Shift+↑` - multi-select cells +- `Enter` - edit selected cell +- `a`/`b` - new cell above/below +- `c`/`v` - copy/paste cells +- `s` - save notebook +- `Shift+Enter` - run cell and move to next +- `Ctrl/Cmd+↑` / `Ctrl/Cmd+↓` - jump to top/bottom of notebook + +When [vim keybindings](#vim-keybindings) are enabled, additional shortcuts are available. ### Vim keybindings -marimo supports vim keybindings. +marimo supports vim keybindings that extend to notebook editing. Within cells, +use standard vim modes. Press `Ctrl+Esc` (or `Cmd+Esc` on macOS, `Shift+Esc` on Windows) from normal mode to enter [command +mode](#command-mode) for notebook navigation. -**Additional bindings/features:** +**Cell editing additions:** - `gd` - go to definition -- `dd` - when a cell is empty, delete it +- `dd` - delete empty cell +- `:w` - save notebook + +**Custom vimrc:** + +You can customize your vim experience by adding a `.vimrc` configuration in the user settings or pyproject.toml + +/// tab | User config + +```toml title="marimo.toml" +[keymap] +vimrc = "/User/absolute/path/to/.vimrc" +``` + +/// + +/// tab | pyproject.toml + +```toml title="pyproject.toml" +[tool.marimo.keymap] +vimrc = "relative/path/.vimrc" +``` + +/// + +**Command mode additions:** + +When vim keybindings are enabled, press `Ctrl+Esc` (or `Cmd+Esc` on macOS, `Shift+Esc` on Windows) from normal mode to enter +[command mode](#command-mode) with additional vim-specific keybindings: -## Overview panels +- `j`/`k` - navigate cells +- `gg`/`G` - first/last cell +- `Shift+j`/`k` - extend selection +- `dd` - delete cell +- `yy` - copy cell +- `p`/`P` - paste below/above +- `o`/`O` - new cell below/above +- `u` - undo deletion +- `i` - edit cell (i.e., return to normal mode) -marimo ships with the IDE panels that provide an overview of your notebook +Press `i` or `Enter` to return to cell editing. -- **file explorer**: view the file tree, open other notebooks -- **variables**: explore variable values, see where they are defined and used, with go-to-definition -- **data explorer**: see dataframe and table schemas at a glance -- **dependency graph**: view dependencies between cells, drill-down on nodes and edges -- **package manager**: add and remove packages, and view your current environment -- **table of contents**: corresponding to your markdown -- **documentation** - move your text cursor over a symbol to see its documentation -- **logs**: a continuous stream of stdout and stderr -- **scratchpad**: a scratchpad cell where you can execute throwaway code -- **snippets** - searchable snippets to copy directly into your notebook -- **feedback** - share feedback! +## Sidebar and Developer Panel -These panels can be toggled via the buttons in the left of the editor. +marimo organizes editor tools into two areas: the **sidebar** on the left for everyday notebook tools (files, variables, packages, AI, documentation), and the **developer panel** at the bottom for debugging utilities (errors, logs, terminal, tracing). + +You can customize both areas by dragging panels to reorder them or move them between sections. Toggle the sidebar with `Cmd/Ctrl-Shift-S` and the developer panel with `Cmd/Ctrl-J`. + +See the [Sidebar and Developer Panel](panels.md) guide for a complete list of available panels and customization options. ## Cell actions @@ -80,6 +140,18 @@ create. - `Cmd/Ctrl-Click` on a variable to jump to its definition - Right-click on a variable to see a context menu with options to jump to its definition +## Signature hints + +Signature hints show a function’s docstring above your code as you type, making it easy to recall its arguments and usage. +Enable this feature under the **Editor** section in the Settings panel. + +
+
+ +
+
+ + ## Keyboard shortcuts We've kept some well-known [keyboard @@ -91,6 +163,7 @@ configuration. _Missing a shortcut? File a [GitHub issue](https://github.com/marimo-team/marimo/issues)._ + ## Command palette Hit `Cmd/Ctrl+K` to open the command palette. @@ -105,13 +178,33 @@ Hit `Cmd/Ctrl+K` to open the command palette. _Missing a command? File a [GitHub issue](https://github.com/marimo-team/marimo/issues)._ +## Editor widths + +You can set the width of the editor in the notebook settings: + +- **Compact**: A narrow width with generous margins, ideal for reading +- **Wide**: A wider layout that gives more space for content +- **Full**: Uses the full width of your browser window, ideal for dashboard-style notebooks +- **Multi-column**: Splits your notebook into multiple columns, letting you view and edit cells side-by-side. This is only possible because marimo models your notebook as a directed acyclic graph (DAG) and the [execution order](../reactivity.md#execution-order) is determined by the relationships between +cells and their variables, not by the order of cells on the page. + +
+
+ +
Multi-column notebook
+
+
+ ## Share on our online playground Get a link to share your notebook via our [online playground](../wasm.md):
- +
diff --git a/docs/guides/editor_features/package_management.md b/docs/guides/editor_features/package_management.md index 3bfc70109bd..de475ce38de 100644 --- a/docs/guides/editor_features/package_management.md +++ b/docs/guides/editor_features/package_management.md @@ -1,6 +1,8 @@ # Package management -marimo supports package management for `pip, rye, uv, poetry, pixi`. When marimo comes across a module that is not installed, you will be prompted to install it using your preferred package manager. +marimo supports package management for `pip`, `uv`, `poetry`, `pixi`, and +`rye`. When marimo comes across a module that is not installed, you will be +prompted to install it using your preferred package manager. Once the module is installed, all cells that depend on the module will be rerun. @@ -8,50 +10,11 @@ Once the module is installed, all cells that depend on the module will be rerun. We use some heuristic for guessing the package name in your registry (e.g. PyPI) from the module name. It is possible that the package name is different from the module name. If you encounter an error, please file an issue or help us by adding your mapping [directly to the codebase](https://github.com/marimo-team/marimo/blob/main/marimo/_runtime/packages/module_name_to_pypi_name.py). -## Running `marimo` in a sandbox environment (`uv` only) +## Package reproducibility -If you want to run marimo in a sandbox environment, you can use the `--sandbox` flag. This will create an isolated virtual environment (using [uv](https://docs.astral.sh/uv)) and install any packages listed in the script metadata, per [PEP 723](https://peps.python.org/pep-0723/). If there is no package metadata in the script, marimo will still prompt you to install any missing packages. +marimo is the only Python notebook that is reproducible down to the packages +they use. This makes it possible to share standalone notebooks without shipping +`requirements.txt` files alongside them, and guarantees your notebooks will +work weeks, months, even years into the future. -This is useful when you want to run marimo in a clean environment without affecting your global environment. - -```bash -marimo edit --sandbox notebook.py -``` - -### Auto-tracking inline script metadata { #auto-tracking-inline-script-metadata } - -When running with `--sandbox`, marimo will automatically track the package name metadata in your notebook file, per [PEP 723](https://peps.python.org/pep-0723/). This metadata is used to manage the notebook's dependencies and Python version. - -For example, whenever you add or remove a package, marimo will automatically update the script metadata in your notebook file: - -```python -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "pandas==", -# "altair==", -# ] -# /// -``` - -This means your notebook file is a completely self-contained artifact with all the necessary information to run. - -### Sandbox creation configuration - -Running marimo in a sandbox environment uses `uv` to create a virtual environment. You can use any of `uv`'s [supported environment variables](https://docs.astral.sh/uv/configuration/environment/). - -#### Choosing the Python version - -For example, you can specify the Python version using the `UV_PYTHON` environment variable: - -```bash -UV_PYTHON=3.13 marimo edit --sandbox notebook.py -``` - -#### Other common configuration - -or you can use `uv`'s link mode: - -```bash -UV_LINK_MODE="copy" marimo edit --sandbox notebook.py -``` +To learn more, see the [package reproducibility guide](../package_management/inlining_dependencies.md). diff --git a/docs/guides/editor_features/panels.md b/docs/guides/editor_features/panels.md new file mode 100644 index 00000000000..fbb1228ccc7 --- /dev/null +++ b/docs/guides/editor_features/panels.md @@ -0,0 +1,73 @@ +# Sidebar and Developer Panel + +marimo organizes editor tools into two main areas: the **sidebar** on the left and the **developer panel** at the bottom. This layout keeps everyday notebook tools easily accessible while providing a dedicated space for debugging and development utilities. + +
+ + + Editor showing sidebar on the left and developer panel at the bottom + +
+ +## Sidebar + +The sidebar provides quick access to panels you'll use frequently while working on notebooks. Click the icons on the left edge of the editor to open panels. + +**Default sidebar panels:** + +| Panel | Description | +|-------|-------------| +| **Files** | Browse workspace files and [inspect remote storage connections](../working_with_data/remote_storage.md) | +| **Variables** | Explore variables and data sources in your notebook | +| **Packages** | View installed packages and manage dependencies | +| **AI** | Chat with AI assistants and use agents | +| **Outline** | Navigate your notebook via table of contents | +| **Documentation** | View live documentation as you type | +| **Dependencies** | Visualize cell relationships with the minimap and dependency graph | + +Toggle the sidebar with `Cmd/Ctrl-Shift-S`. + +## Developer Panel + +The developer panel houses tools for debugging, tracing execution, and other advanced functionality. It appears at the bottom of the editor, similar to the developer tools in VS Code or browser DevTools. + +**Default developer panel tabs:** + +| Panel | Description | +|-------|-------------| +| **Errors** | View all errors across your notebook | +| **Scratchpad** | A scratch cell for quick experiments without affecting your notebook | +| **Tracing** | Monitor cell execution and performance | +| **Secrets** | Manage environment secrets | +| **Logs** | View stdout and stderr output | +| **Terminal** | Integrated terminal for shell commands | +| **Snippets** | Browse and insert code snippets | + +Toggle the developer panel with `Cmd/Ctrl-J`. + + + +## Customizing your layout + +Both the sidebar and developer panel are fully customizable. You can: + +- **Reorder panels**: Drag panels to rearrange their order within a section +- **Move panels between sections**: Drag a panel from the sidebar to the developer panel (or vice versa) to relocate it +- **Hide panels**: Right-click a panel icon to access options for hiding or moving it + +Your layout preferences are saved and persist across sessions. Panels automatically adapt their appearance based on their location — showing a more compact vertical layout in the sidebar and a wider horizontal layout in the developer panel. + +
+ +
+ +## Keyboard shortcuts + +| Shortcut | Action | +|----------|--------| +| `Cmd/Ctrl-J` | Toggle developer panel | +| `Cmd/Ctrl-Shift-S` | Toggle sidebar | +| `Cmd/Ctrl-Shift-I` | Open minimap (in Dependencies panel) | +| `` Ctrl-` `` | Open terminal | diff --git a/docs/guides/editor_features/tools.md b/docs/guides/editor_features/tools.md new file mode 100644 index 00000000000..793e0faaff4 --- /dev/null +++ b/docs/guides/editor_features/tools.md @@ -0,0 +1,117 @@ +# AI tools + +!!! warning "Experimental Feature" + Tools are currently experimental and under active development. Tool definitions and availability may change. + +marimo exposes a set of tools that allow AI assistants to interact with your notebooks. These tools enable AI agents to read notebook content, inspect cell runtime data, access variables, handle errors, and more. + +## Using tools + +Tool availability depends on which [chat panel mode](ai_completion.md#chat-panel) you use: + +| Mode | Marimo notebook tools | +|------|----------------------| +| **Manual** | — | +| **Ask** | Read-only inspection, data, debugging, and reference tools | +| **Agent** | All **Ask** tools plus editing tools | +| **Code mode** | Code execution tool and on-demand reference guides (see below) | + +External AI applications can also access the **Ask** and **Agent** notebook tools through the [marimo MCP server](mcp.md#mcp-server). + +## Available tools + +### Inspection + +| Tool | Description | +|------|-------------| +| **get_active_notebooks** | List all currently active marimo notebooks. Returns summary statistics and notebook details including names, paths, and session IDs. Start here to discover which notebooks are available. | +| **get_lightweight_cell_map** | Get an overview of notebook structure showing a preview of each cell. Takes a `session_id` and optional `preview_lines` parameter. Returns cell IDs, preview text, line counts, cell types (code, markdown, SQL), runtime state (`idle`, `running`, `queued`, etc.), and `has_output`/`has_console_output` flags. | +| **get_cell_runtime_data** | Get detailed runtime information for one or more cells. Takes `session_id` and `cell_ids` (list) parameters. Returns full cell code, error details, runtime metadata (execution time, runtime state), and variables defined by each cell. | +| **get_cell_outputs** | Get execution output from one or more cells. Takes `session_id` and `cell_ids` (list) parameters. Returns visual output (HTML, charts, tables, etc.) with mimetype, stdout messages, and stderr messages for each cell. | +| **get_cell_dependency_graph** | Get the cell dependency graph showing variable ownership and cell relationships. Takes `session_id` and optional `cell_id` and `depth` parameters. Returns cell dependency info (defined variables with kind and runtime type, referenced variables, parent/child cells), a variable ownership map, multiply-defined variables, and cycle information. Use `cell_id` to center on a specific cell and `depth` to limit traversal hops. | + +### Data + +| Tool | Description | +|------|-------------| +| **get_tables_and_variables** | Get information about variables and data tables in a session. Takes `session_id` and `variable_names` parameters (empty list returns all). Returns table metadata (columns, primary keys, indexes, row counts) and variable values with data types. | +| **get_database_tables** | Get database schema information with optional query filtering. Takes `session_id` and optional `query` parameter (supports regex). Returns tables with connection name, database, schema, and table details. | + +### Debugging + +| Tool | Description | +|------|-------------| +| **get_notebook_errors** | Get all errors in the notebook organized by cell. Takes `session_id` parameter. Returns error summary (total errors, affected cells) and per-cell error details (type, message, traceback). | +| **lint_notebook** | Get all marimo lint errors in the notebook. Returns lint errors as defined in the [lint rules documentation](../lint_rules/index.md). | + +### Reference + +| Tool | Description | +|------|-------------| +| **get_marimo_rules** | Get official marimo guidelines and best practices for AI assistants. Returns the content of the marimo rules file and source URL for understanding marimo-specific conventions. | + +### Editing (Agent mode only) + +!!! note "Availability" + These tools are only available when using the [chat panel in agent mode](ai_completion.md#chat-panel). They are not exposed through the [MCP server](mcp.md#mcp-server). + +| Tool | Description | +|------|-------------| +| **edit_notebook** | Add, remove, or update cells in the notebook. Takes cell operations and modifications as parameters. Allows the AI agent to generate diffs that modify notebook structure and content. | +| **run_stale_cells** | Run cells that are stale (outdated due to upstream changes). Triggers execution of affected cells to update the notebook state. | + +## Web search and fetch + +In any chat panel mode (**Manual**, **Ask**, **Agent**, or **Code mode**), marimo can give the assistant access to web search and URL fetching. These are [provider-adaptive capabilities](https://pydantic.dev/docs/ai/core-concepts/capabilities/#provider-adaptive-tools) from [Pydantic AI](https://ai.pydantic.dev): marimo enables them automatically based on your installed packages and the model you are using. + +### How capabilities are enabled + +marimo picks the best available option for each capability: + +| Capability | Local fallback (installed in your environment) | Native (model provider supports it) | +|------------|-----------------------------------------------|-------------------------------------| +| **Web search** | DuckDuckGo search when `ddgs` is installed | Provider-native web search (e.g. Anthropic, OpenAI Responses) | +| **Web fetch** | URL fetching via `markdownify` when installed | Provider-native web fetch | +| **X search** | — | xAI models with native X search support | + +Local fallbacks take priority when their packages are installed. Otherwise, marimo uses the provider's native tools when your configured model supports them. + +### Install local web search and fetch + +To enable web search and fetch on any model — including local models via Ollama — install the optional Pydantic AI extras: + +```bash +pip install "pydantic-ai-slim[duckduckgo,web-fetch]" +``` + +### Use provider-native tools + +When local packages are not installed, marimo enables native tools only if your model supports them. For example: + +- **Anthropic** and **OpenAI Responses** models can use native web search and web fetch +- **xAI** models (e.g. `xai/grok-2-latest`) can use native web search and X search + +Configure xAI in your `marimo.toml` or through the notebook settings — see the [xAI provider guide](../configuration/llm_providers.md#xai). + + +## Code mode + +!!! warning "Experimental" + Code mode gives the assistant direct access to your notebook's kernel so it can make destructive changes to your notebook. + +Code mode is available from the chat panel mode selector. Instead of the inspection and editing tools above, the assistant uses a different toolset oriented around running Python in the live kernel: + +| Tool / capability | Description | +|-------------------|-------------| +| **execute_code** | Run Python in the notebook kernel's scratchpad. The assistant uses this for all notebook mutations — adding cells, updating code, inspecting variables, and running logic. | +| **gotchas** | On-demand reference for name redefinition, cached module proxies, and other notebook traps. | +| **notebook-improvements** | On-demand reference for improving, optimizing, or cleaning up an existing notebook. | +| **rich-representations** | On-demand reference for custom widgets, visual encodings, and interactive output. | + +Code mode loads the [marimo pair](../generate_with_ai/marimo_pair.md) skill as its system prompt, so the assistant follows the same conventions as external agent CLIs paired on your notebook. + + +## Related documentation + +- [Model Context Protocol (MCP)](mcp.md) - Learn how to expose tools through the marimo MCP server +- [AI-assisted coding](ai_completion.md) - Learn about more AI coding features diff --git a/docs/guides/editor_features/watching.md b/docs/guides/editor_features/watching.md new file mode 100644 index 00000000000..4e34339f918 --- /dev/null +++ b/docs/guides/editor_features/watching.md @@ -0,0 +1,258 @@ +# Using your own editor + +While we recommend using the [marimo editor](index.md), +we understand that you may prefer to use your own. marimo provides a +`--watch` flag that watches your notebook file for changes, syncing them to +the marimo editor or running application. This lets you edit your notebook +using an editor of your choice, like neovim, VSCode, Cursor, or PyCharm, and +have the changes automatically reflected in your browser. + +/// admonition | Use our editor extensions + type: tip + +This guide teaches you how to use marimo with arbitrary text editors. +If you use VS Code, Cursor, or PyCharm, you might prefer using our [VS Code +extension](https://marketplace.visualstudio.com/items?itemName=marimo-team.vscode-marimo) +or [PyCharm plugin](https://plugins.jetbrains.com/plugin/32416-marimo), which +provide first-class experiences for editing and running marimo notebooks. +/// + +## marimo's file format + +marimo stores notebooks as Python files. Cells are stored +as functions, decorated with`@app.cell`; you can optionally give cells names in +the editor UI or by editing the notebook file. + +```python +@app.cell +def memorable_cell_name(auto, determined, references): # signature denotes cell references + computed_value = auto + determined + references + "hello!" # final statement is the visual output + return computed_value # return denotes cell definitions +``` + +Run `marimo tutorial fileformat` at the command line for a full guide. + +!!! note "Cell signature and returns" + + Don't worry about maintaining the signatures of cells and their return + values; marimo will handle this for you. + +### Exposing functions and classes top-level + +You can expose top-level functions and classes in your +notebook, so that other Python modules can import them: + +```python +from my_notebook import my_function, MyClass +``` + +Top-level functions are added to a notebook using the `@app.function` +decorator, and classes with `@app.class_definition`; these appear in your +notebook as cells with just a function or class definition. These functions and +classes must be pure, closing over only other pure functions and classes, or +imports and constants defined in an `app.setup` `with` block. + +Here is a complete example that you can copy/paste and run locally: + + +```python +import marimo + +app = marimo.App() + +with app.setup: + # These symbols can be used by top-level functions and classes + # (as well as by regular cells) + import numpy as np + + CONSTANT: int = 1 + +@app.function +def my_function(x: np.ndarray): + return np.mean(x) + CONSTANT + +@app.class_definition +class MyClass: + ... + +@app.cell +def _(): + my_function(np.random.randn(2, 2)) + return + +if __name__ == "__main__": + app.run() +``` + +For more details see the [guide on reusable functions and classes](../reusing_functions.md). + +### Types and autocompletion + +Add type hints to your variables, and marimo will carry over these type hints +to cells where these variables are used. This, combined with importing modules +in the setup cell (see below for an example), makes it possible for your editor +to give completions on the references of your cell. + + +For example: + +```python +# setup cell +import numpy as np + +# cell 1 +x: np.ndarray + +# cell 2 +np.mean(x) +``` + +will be serialized as + +```python +import marimo + +app = marimo.App() + +with app.setup: + import numpy as np + +@app.cell +def _(): + x: np.ndarray + return x, + +@app.cell +def _(x: np.ndarray): + np.mean(x) + +if __name__ == "__main__": + app.run() +``` + +### As markdown + +!!! tip "Markdown File format tutorial" + Run `marimo tutorial markdown-format` at the command line for a full guide. + +marimo notebooks can also be stored as Markdown files. This is a good option +for prose heavy text, and can be easy to navigate and edit in external editors. +To convert a marimo notebook to markdown, use + +``` +marimo export md notebook.py -o notebook.md +``` + +at the command-line, or rename your file to have an `.md` extension in the notebook editor. + +marimo conforms to standard markdown document format, and will render most +places like Github. Metadata in this file format is saved in the frontmatter, +which marimo may use for information like +[sandboxing](../package_management/inlining_dependencies.md), and the marimo version. All other +fields are kept, but ignored. + +For execution, marimo extracts code fences that contain `marimo` in braces. For +instance `python {marimo}`, `{marimo}` or `{.marimo .python}`. The marimo +editor uses `python {.marimo}` which is Pandoc compatible, and correctly +processed by text highlighters. + +````markdown +--- +title: My Notebook +marimo-version: 0.0.0 +description: A notebook with a description +--- + +# Just a notebook + +```python {.marimo} +print("Hello World!") +``` +```` + +marimo's markdown format can be used with a [`mkdocs +plugin`](https://github.com/marimo-team/mkdocs-marimo) and +[`Quarto`](https://github.com/marimo-team/quarto-marimo). + +Note that the markdown format is not as fully featured as the Python format. +Reactive tests will not work, markdown notebooks cannot be imported or used as +a library, and they cannot be run as scripts. + +## Watching for changes to your notebook + +!!! tip "Install watchdog for better file watching" + For better performance, install [watchdog](https://pypi.org/project/watchdog/). + Without watchdog, marimo resorts to polling. + +!!! warn "Watch works best with autosave" + Verify your settings in `User Settings` > `Editor` > `Autosave` > `Autosave enabled` + + +### `marimo edit --watch` + +When you run `marimo edit` with the `--watch` flag, the marimo server +will open your notebook in the browser and watch the underlying notebook +file for changes. When you make changes to the notebook file, they will be +streamed to the marimo editor in the browser. + +By default, synced code will not be executed automatically, with cells marked +as stale instead. Run all stale cells with the marimo editor's "Run" button, or +the [`runStale` hotkey](hotkeys.md), to see the new outputs. + +If you want to run all affected cells automatically when you save, change the +`runtime` config in your `pyproject.toml` file. + +```toml +[tool.marimo.runtime] +watcher_on_save = "autorun" +``` + +### `marimo run --watch` + +When you run `marimo run` with the `--watch` flag, whenever the file watcher +detects a change to the notebook file, the application will be refreshed. The +browser will trigger a page refresh to ensure your notebook starts from a fresh +state. + +For gallery mode (`marimo run --watch`), marimo also refreshes the +folder index on each workspace request. This means added and removed notebooks +appear after refreshing or revisiting the gallery page. + +!!! warn "Security note for watched folders" + In `marimo run --watch`, newly created notebooks in that folder + can appear in the gallery and execute code when opened. Only watch trusted + directories, and use authentication when exposing the server remotely. + +## Watching for changes to other modules + +marimo can also watch for changes to Python modules that your notebook imports, +letting you edit auxiliary Python files in your own editor as well. Learn how +to enable this feature in our [Module Autoreloading +Guide](module_autoreloading.md) + +## Watching for data changes + +marimo supports watching data files and automatically refreshing cells that +depend on them using [`mo.watch.file`][marimo.watch.file] and [`mo.watch.directory`][marimo.watch.directory]. + +Learn more in the [watch API documentation](../../api/watch.md). + +## Hot-reloading WebAssembly notebooks + +Follow these steps to develop a notebook using your own editor while previewing +it as a [WebAssembly notebook](../wasm.md) in the browser. This lets you take +advantage of local development tools while seeing the notebook as it appears +when deployed as a WebAssembly notebook. + +```bash +# in one terminal, start a watched edit (or run) session +marimo edit notebook.py --watch + +# in another terminal +marimo export html-wasm notebook.py -o output_dir --watch + +# in a third terminal, serve the WASM application +cd path/to/output_dir +python -m http.server # or a server that watches for changes +``` diff --git a/docs/guides/expensive_notebooks.md b/docs/guides/expensive_notebooks.md index b3035246286..8988a7302eb 100644 --- a/docs/guides/expensive_notebooks.md +++ b/docs/guides/expensive_notebooks.md @@ -16,7 +16,7 @@ mo.stop(condition) expensive_function_call() ``` -Use [`mo.stop()`][marimo.stop] in conjunction with +Use [`mo.stop`][marimo.stop] with [`mo.ui.run_button()`][marimo.ui.run_button] to require a button press for expensive cells: @@ -41,15 +41,15 @@ def __(): ## Configure how marimo runs cells -### Disabling cell autorun +### Disable cell autorun If you habitually work with very expensive notebooks, you can [disable automatic -execution](../guides/configuration/runtime_configuration.md#on-cell-change). When +execution](../guides/configuration/runtime_configuration.md#on-cell-change-disabling-autorun-on-cell-change-lazy-execution). When automatic execution is disabled, when you run a cell, marimo marks dependent cells as stale instead of running them automatically. -### Disabling autorun on startup +### Disable autorun on startup marimo autoruns notebooks on startup, with `marimo edit notebook.py` behaving analogously to `python notebook.py`. This can also be disabled through the @@ -62,74 +62,131 @@ helpful when you want to edit one part of a notebook without triggering execution of other parts. See the [reactivity guide](../guides/reactivity.md#disabling-cells) for more info. -## Caching +## Manage memory -### Cache computations with `@mo.cache` +Here are a few tips for managing the memory consumption of your notebooks, +on host or GPU. -Use [`mo.cache`][marimo.cache] to cache the return values of -expensive functions, based on their arguments: +### Wrap intermediate computations in functions + +By default, global variables live in the kernel memory. Intermediate variables +that are defined in functions are cleaned up automatically. + +For example, if `X` is a temporary: + +**Do this:** ```python -import mo +def _(): + X = torch.randn(1e4, 1e4, device='cuda') + Y = f(X) + return Y +``` -@mo.cache -def compute_predictions(problem_parameters): - # do some expensive computations and return a value - ... +**Don't do this:** + +```python +X = torch.randn(1e4, 1e4, device='cuda') +Y = f(X) +# X still lives in program memory! +``` + +### Use `del` to remove variables from kernel memory + + +Use the `del` operator to remove variables from kernel memory. + +**In a single cell.** Prefer deleting variables in the cell they were defined +in. For example, +if `X` is a temporary that you don't need after computing `Y`: + +```python +X = torch.randn(1e4, 1e4, device='cuda') +Y = f(X) +del X +``` + +**In another cell.** Sometimes, computations are spread across multiple cells, +and you only realize later on that you need to free memory that you've already +allocated. In such cases you can still use the `del` keyword. For example: + +```python +data = load_large_dataset() +``` + +```python +derived_data = f(data) +``` + +```python +del data ``` -When `compute_predictions` is called with a value of -`problem_parameters` it hasn't seen, it will compute the predictions and store -them in an in-memory cache. The next time it is called with the same -parameters, instead of recomputing the predictions, it will return the -previously computed value from the cache. +marimo inserts control dependences to make sure that variables are not deleted +before they are used. When `del` is used to delete a variable that was defined +in a another cell, the cell where `del` was used becomes a child of all other +cells that reference that variable. In this case, that means marimo knows to +run the third cell after the second cell, since the second cell references +`data` and the third cell deletes it. However, once `data` is deleted, +attempting to manually run the second cell will raise a `NameError`, and you'll +need to re-run the defining cell in order to get your notebook back to a +consistent state. -??? note "Comparison to `functools.cache`" +### Local variables - `mo.cache` is like `functools.cache` but smarter. `functools` will sometimes - evict values from the cache when it doesn't need to. +[Local or temporary +variables](../guides/reactivity.md#creating-temporary-variables) (i.e., +variables prefixed with an underscore) are automatically removed from the +kernel globals after cell-run. If another Python object retains a reference to +the variable, it will remain in memory; otherwise, Python's garbage collector +will automatically reclaim its allocated memory. - In particular, consider the case when a cell defining a `@mo.cache`-d function - re-runs due to an ancestor of it running, or a UI element value changing. - `mo.cache` will use sophisticated analysis of the dataflow graph to determine - whether or not the decorated function has changed, and if it hasn't, it's - cache won't be invalidated. In contrast, on re-run a `functools` cache is - always invalidated, because `functools` has no knowledge about the structure - of marimo's dataflow graph. +## Automatically snapshot outputs as HTML or IPYNB - Conversely, `mo.cache` knows to invalidate the cache if closed over variables - change, whereas `functools.cache` doesn't, yielding incorrect cache hits. +To keep a record of your cell outputs while working on your +notebook, you can configure notebooks to automatically save as HTML or ipynb +through the notebook menu (these files are saved in addition to the +notebook's `.py` file). Snapshots are saved to a folder called +`__marimo__` in the notebook directory. - `mo.cache` is slightly slower than `functools.cache`, but in most applications - the overhead is negligible. For performance critical code, where the decorated - function will be called in a tight loop, prefer `functools.cache`. +Learn more about exporting notebooks in our [exporting guide](../guides/exporting/index.md). -### Save and load from disk with `mo.persistent_cache` +## Cache expensive computations -Use [`mo.persistent_cache`][marimo.persistent_cache] to cache variables to -disk. The next time your run your notebook, the cached variables will be loaded -from disk instead of being recomputed, letting you pick up where you left off. +marimo provides two decorators to cache the return values of expensive functions: -Reserve this for expensive computations that you would like to persist across -notebook restarts. Cached outputs are automatically saved to `__marimo__/cache`. +1. In-memory caching with [`mo.cache`][marimo.cache] +2. Disk caching with [`mo.persistent_cache`][marimo.persistent_cache] -**Example.** +Both utilities can be used as decorators or context managers. + +/// tab | `mo.cache` ```python import marimo as mo -with mo.persistent_cache(name="my_cache"): - # This block of code and its computed variables will be cached to disk - # the first time it's run. The next time it's run, `my_variable` - # will be loaded from disk. - my_variable = some_expensive_function() +@mo.cache +def compute_embedding(data: str, embedding_dimension: int, model: str) -> np.ndarray: ... ``` -Roughly speaking, `mo.persistent_cache` registers a cache hit when the cell -is not stale, meaning its code hasn't changed and neither have its ancestors. -On cache hit the code block won't execute and instead variables will be loaded -into memory. +/// + +/// tab | `mo.persistent_cache` + +```python +import marimo as mo + +@mo.persistent_cache +def compute_embedding(data: str, embedding_dimension: int, model: str) -> np.ndarray + ... +``` + +/// + + +See our [guide on caching](../api/caching.md) for details, including how the cache +key is constructed, and limitations. ## Lazy-load expensive UIs @@ -159,7 +216,7 @@ def expensive_component(): data = db.query("SELECT * FROM data") return mo.ui.table(data) -accordion = mo.ui.accordion({ +accordion = mo.accordion({ "Charts": mo.lazy(expensive_component) }) ``` diff --git a/docs/guides/exporting.md b/docs/guides/exporting.md deleted file mode 100644 index e4622e8e36e..00000000000 --- a/docs/guides/exporting.md +++ /dev/null @@ -1,293 +0,0 @@ -# Exporting to HTML and other formats - -Export marimo notebooks to other file formats at the command line using - -```bash -marimo export -``` - -## Export to static HTML - -### Export from a running notebook - -Export the current view your notebook to static HTML via the notebook -menu: - -
-
- -
Download as static HTML.
-
-
- -Additionally, you can configure individual notebooks to automatically -save as HTML through the notebook menu. These automatic snapshots are -saved to a folder called `__marimo__` in the notebook directory. - -### Export from the command line - -Export to HTML at the command line: - -```bash -marimo export html notebook.py -o notebook.html -``` - -or watch the notebook for changes and automatically export to HTML: - -```bash -marimo export html notebook.py -o notebook.html --watch -``` - -When you export from the command line, marimo runs your notebook to produce -its visual outputs before saving as HTML. - -!!! note "Note" - - If any cells error during the export process, the status code will be non-zero. However, the export result may still be generated, with the error included in the output. - Errors can be ignored by appending `|| true` to the command, e.g. `marimo export html notebook.py || true`. - -## Export to a Python script - -Export to a flat Python script in topological order, so the cells adhere to -their dependency graph. - -```bash -marimo export script notebook.py -o notebook.script.py -``` - -!!! warning "Top-level await not supported" - - Exporting to a flat Python script does not support top-level await. If you have - top-level await in your notebook, you can still execute the notebook as a - script with `python notebook.py`. - -## Export to markdown - -Export to markdown notebook in top to bottom order, so the cells are in the -order as they appear in the notebook. - -```bash -marimo export md notebook.py -o notebook.md -``` - -This can be useful to plug into other tools that read markdown, such as [Quarto](https://quarto.org/) or [MyST](https://myst-parser.readthedocs.io/). - -You can also convert the markdown back to a marimo notebook: - -```bash -marimo convert notebook.md > notebook.py -``` - -## Export to Jupyter notebook - -Export to Jupyter notebook in topological order, so the cells adhere to -their dependency graph. - -```bash -marimo export ipynb notebook.py -o notebook.ipynb -``` - -## Exporting to PDF, slides, or rst - -If you export to a Jupyter notebook, you can leverage various Jupyter ecosystem tools. For PDFs, you will -need to have [Pandoc](https://nbconvert.readthedocs.io/en/latest/install.html#installing-pandoc) and [Tex](https://nbconvert.readthedocs.io/en/latest/install.html#installing-tex) installed. The examples below use `uvx`, which you can obtain by [installing `uv`](https://docs.astral.sh/uv/getting-started/installation/). - -```bash -NOTEBOOK=notebook.ipynb - -# Convert to PDF using nbconvert -uvx --with nbconvert --from jupyter-core jupyter nbconvert --to pdf $NOTEBOOK - -# Convert to web PDF -uvx --with "nbconvert[webpdf]" --from jupyter-core jupyter nbconvert --to webpdf $NOTEBOOK --allow-chromium-download - -# Convert to slides -uvx --with nbconvert --from jupyter-core jupyter nbconvert --to slides $NOTEBOOK - -# Convert to rst with nbconvert -uvx --with nbconvert --from jupyter-core jupyter nbconvert --to rst $NOTEBOOK - -# Generate PNG/PDF of specific cells using nbconvert -uvx --with nbconvert --with jupyter --from jupyter-core jupyter nbconvert --to pdf --execute --stdout $NOTEBOOK \ - --TemplateExporter.exclude_input=True - -# Use nbconvert programmatically for more control -uv run --with nbconvert python -c " -from nbconvert import PDFExporter -import nbformat -nb = nbformat.read('$NOTEBOOK', as_version=4) -pdf_exporter = PDFExporter() -pdf_data, resources = pdf_exporter.from_notebook_node(nb) -with open('notebook.pdf', 'wb') as f: - f.write(pdf_data) -" -``` - -You can also use other tools that work with Jupyter notebooks: - -- [Quarto](https://quarto.org) - Create beautiful documents, websites, presentations -- [nbgrader](https://nbgrader.readthedocs.io/) - Grade notebook assignments - -## Export to WASM-powered HTML - -Export your notebook to a self-contained HTML file that runs using WebAssembly: - -```bash -# export as readonly, with code locked -marimo export html-wasm notebook.py -o output_dir --mode run -# export as an editable notebook -marimo export html-wasm notebook.py -o output_dir --mode edit -``` - -The exported HTML file will run your notebook using WebAssembly, making it completely self-contained and executable in the browser. This means users can interact with your notebook without needing Python or marimo installed. - -Options: - -- `--mode`: Choose between `run` (read-only) or `edit` (allows editing) -- `--output`: Directory to save the HTML and required assets -- `--show-code/--no-show-code`: Whether to initially show or hide the code in the notebook - -!!! note "Note" - - The exported file must be served over HTTP to function correctly - it cannot be opened directly from the filesystem (file://). - Your server must also serve the assets in the `assets` directory, next to the HTML file. For this reason, we recommend using the online playground if possible: . - -### Testing the export - -You can test the export by running the following command in the directory containing your notebook: - -```bash -cd path/to/output_dir -python -m http.server -``` - -### Deploying to GitHub Pages - -/// admonition | Template repository - type: tip - -You can fork our [template repository](https://github.com/marimo-team/marimo-gh-pages-template) for deploying multiple notebooks to GitHub Pages. Once you have forked the repository, add your notebooks to the `notebooks`/`apps` directory. -/// - -You can deploy your WebAssembly marimo notebook to GitHub Pages using the following GitHub Actions workflow: - -```yaml -jobs: - build: - runs-on: ubuntu-latest - - steps: - # ... checkout and install dependencies - - - name: 📄 Export notebook - run: | - marimo export html-wasm notebook.py -o path/to/output --mode run - - - name: 📦 Upload Pages Artifact - uses: actions/upload-pages-artifact@v3 - with: - path: path/to/output - - deploy: - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - - permissions: - pages: write - id-token: write - - steps: - - name: 🌐 Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 - with: - artifact_name: github-pages -``` - -### Exporting multiple notebooks - -In order to export multiple notebooks under the same folder, you can use the following snippet: - -```bash -files=("batch_and_form.py" "data_explorer.py") - -for file in "${files[@]}"; do - without_extension="${file%.*}" - marimo export html-wasm "$file" -o public/"$without_extension".html --mode run -done -``` - -Optionally, you can create an `index.html` file in the public directory: - -```bash -echo "
    " > public/index.html -for file in "${files[@]}"; do - without_extension="${file%.*}" - echo "
  • $without_extension
  • " >> public/index.html -done -echo "
" >> public/index.html -``` - -## 🏝️ Embed marimo outputs in HTML using Islands - -!!! note "Preview" - - Islands are an early feature. While the API likely won't change, there are some improvements we'd like to make before we consider them stable. - Please let us know on [GitHub](https://github.com/marimo-team/marimo/issues) if you run into any issues or have any feedback! - -marimo islands are a way to embed marimo outputs and/or python code in your HTML that will become interactive when the page is loaded. This is useful for creating interactive blog posts, tutorials, and educational materials, all powered by marimo's reactive runtime. - -Check out an [example island-powered document](./island_example.md). - -### Islands in action - -!!! warning "Advanced topic!" - - Islands are an advanced concept that is meant to be a building block for creating integrations with existing tools such as static site generators or documentation tools. - -In order to use marimo islands, you need to import the necessary JS/CSS headers in your HTML file, and use our custom HTML tags to define the islands. - -```html - - - - - - - - - - - - - Hello, islands! - - - - - -``` - -### Generating islands - -While you can generate the HTML code for islands yourself, it it recommend to use our `MarimoIslandGenerator` class to generate the HTML code for you. - -::: marimo.MarimoIslandGenerator diff --git a/docs/guides/exporting/index.md b/docs/guides/exporting/index.md new file mode 100644 index 00000000000..53468462bd9 --- /dev/null +++ b/docs/guides/exporting/index.md @@ -0,0 +1,32 @@ +# Export to other formats + +Export marimo notebooks to other file formats from the browser editor. Notebooks can +also be exported to other formats at the command-line with: + +```bash +marimo export +``` + +/// tip | Looking to share notebooks online? +For sharing interactive notebooks on the public web, consider using [molab](../molab.md), our free cloud-hosted notebook platform. +/// + +## Available formats + +| Format | Command | Description | +|--------|---------|-------------| +| [Static HTML](static_html.md) | `marimo export html` | Non-interactive HTML snapshot | +| [PDF](pdf.md) | `marimo export pdf` | PDF document or slide deck | +| [Jupyter notebook](jupyter_notebook.md) | `marimo export ipynb` | Jupyter `.ipynb` file | +| [Python script](python_script.md) | `marimo export script` | Flat `.py` script in topological order | +| [Markdown](markdown.md) | `marimo export md` | Markdown with code blocks | +| [WebAssembly HTML](webassembly_html.md) | `marimo export html-wasm` | Self-contained, interactive HTML powered by WebAssembly | +| [Session snapshot](sessions.md) | `marimo export session` | Serialized session snapshot (JSON) | + +## Publishing plugins + +| Tool | Description | +|------|-------------| +| [Quarto](quarto.md) | Publish marimo Markdown with the Quarto extension | +| [Jupyter Book](jupyter_book.md) | Embed marimo cells in Jupyter Book | +| [MDX](mdx.md) | Embed marimo cells in MDX pages | diff --git a/docs/guides/exporting/jupyter_book.md b/docs/guides/exporting/jupyter_book.md new file mode 100644 index 00000000000..b5e30c0f291 --- /dev/null +++ b/docs/guides/exporting/jupyter_book.md @@ -0,0 +1,23 @@ +# Publishing with Jupyter Book + +[Jupyter Book](https://jupyterbook.org/) is an open-source publishing system for +computational books, documentation, and course materials built from MyST Markdown +and notebooks. + +marimo's [`jupyter-book-marimo`](https://github.com/marimo-team/jupyter-book-marimo) +plugin lets Jupyter Book render MyST-native `{marimo}` directives as hydrated +marimo islands. Use it when you want marimo cells and outputs inside a Jupyter +Book site. + +Write a marimo cell with an explicit language: + +````markdown +```{marimo} python +import marimo as mo + +mo.md("hello") +``` +```` + +For installation, page-level configuration, and supported directive options, see +the [jupyter-book-marimo docs](https://marimo-team.github.io/jupyter-book-marimo/). diff --git a/docs/guides/exporting/jupyter_notebook.md b/docs/guides/exporting/jupyter_notebook.md new file mode 100644 index 00000000000..5a089585cd1 --- /dev/null +++ b/docs/guides/exporting/jupyter_notebook.md @@ -0,0 +1,49 @@ +# Jupyter notebook + +Export marimo notebooks to Jupyter `.ipynb` format. This lets you +go from marimo into the vast Jupyter export ecosystem, including tools +like `nbconvert`, Quarto, JupyterBook, and more. + +## Export from the marimo editor + +You can configure individual notebooks to automatically +save as ipynb through the notebook menu. These automatic snapshots are +saved to a folder called `__marimo__` in the notebook directory. + +
+
+ +
Download as static HTML.
+
+
+ +## Export from the command line + +Export to Jupyter notebook in topological order, so the notebook +can be run from top to bottom: + +```bash +marimo export ipynb notebook.py -o notebook.ipynb +``` + + +Export with cells in the same order as the marimo notebook: + +```bash +marimo export ipynb notebook.py -o notebook.ipynb --sort=top-down +``` + +See all options: + +```bash +marimo export ipynb notebook.py -o notebook.ipynb --sort=top-down +``` + + +## Convert back to a marimo notebook + +You can also convert a Jupyter notebook back to a marimo notebook: + +```bash +marimo convert notebook.ipynb -o notebook.py +``` diff --git a/docs/guides/exporting/markdown.md b/docs/guides/exporting/markdown.md new file mode 100644 index 00000000000..2c704a40543 --- /dev/null +++ b/docs/guides/exporting/markdown.md @@ -0,0 +1,41 @@ +# Markdown + +Export marimo notebooks to markdown. + +!!! warning "Outputs are not included" + + Markdown export only includes the notebook source code (code and markdown cells). Cell outputs are not saved in the exported markdown. To include outputs, export to [static HTML](static_html.md) or [Jupyter notebook](jupyter_notebook.md) instead. + +## Export from the marimo editor + +Export from the notebook settings menu, in the top right. + +
+
+ +
Download as static HTML.
+
+
+ + +## Export from the command line + +Export to markdown in top to bottom order, so the cells are in the +order as they appear in the notebook. + +```bash +marimo export md notebook.py -o notebook.md +``` + +This can be useful to plug into other tools that read markdown, such as [Quarto](https://quarto.org/) or [MyST](https://myst-parser.readthedocs.io/). + +!!! tip "marimo can open markdown files as notebooks" + Learn more with `marimo tutorial markdown-format` at the command line. + +## Convert markdown back to a marimo notebook + +You can also convert the markdown back to a marimo notebook: + +```bash +marimo convert notebook.md > notebook.py +``` diff --git a/docs/guides/exporting/mdx.md b/docs/guides/exporting/mdx.md new file mode 100644 index 00000000000..0ada1661000 --- /dev/null +++ b/docs/guides/exporting/mdx.md @@ -0,0 +1,27 @@ +# Publishing with MDX + +[MDX](https://mdxjs.com/) combines Markdown with components for web frameworks. + +marimo's [`mdx-marimo`](https://github.com/marimo-team/mdx-marimo) package +renders marimo code fences as connected, reactive cells inside MDX pages. Use +it to place Python, SQL, and Markdown cells among a site's prose and components. + +Write a Python cell by adding `marimo` after the fence language: + +````markdown +```python marimo +import marimo as mo + +mo.md("hello") +``` +```` + +Export an existing marimo notebook to MDX source: + +```bash +marimo export md notebook.py -o notebook.mdx --flavor mdx +``` + +Add `remarkMarimo` to the host's MDX compiler, then load the package stylesheet +and browser runtime. For installation, framework setup, and supported cell +options, see the [mdx-marimo docs](https://marimo-team.github.io/mdx-marimo/). diff --git a/docs/guides/exporting/pdf.md b/docs/guides/exporting/pdf.md new file mode 100644 index 00000000000..e23d35d7a07 --- /dev/null +++ b/docs/guides/exporting/pdf.md @@ -0,0 +1,150 @@ +# PDF + +Export marimo notebooks to PDF documents or slide decks. + +> PDF export works out-of-the-box on on [molab](https://molab.marimo.io/notebooks). + +## Export to PDF from the marimo editor + +Export to PDF from the notebooks action menu. This requires a few dependencies, which you will be +prompted to install. It also requires LaTeX to install. + +
+
+ +
Download as PDF.
+
+
+ +You can also export to PDF from the command palette (Ctrl/Cmd+K). + +## Export to PDF from the command line + +You can export directly with marimo: + +```bash +marimo export pdf notebook.py -o notebook.pdf +``` + +To exclude code cells: + +```bash +marimo export pdf --no-include-inputs notebook.py -o notebook.pdf +``` + +To see all options, use + +```bash +marimo export pdf --help +``` + +### Rasterized output capture + +Rasterized PNG fallback capture for marimo widget HTML (including anywidgets) +and Vega outputs is enabled by default. Use `--no-rasterize-outputs` to disable +it. + +Use `--raster-scale` (range `1.0` to `4.0`, default `4.0`) to trade export speed/file size for sharper captured output. Use `--raster-server=static` (default) for a static capture page, or `--raster-server=live` to capture through a live notebook server. + +!!! warning "Choose the raster server mode carefully" + + marimo gives you control over how output is captured. + Use `--raster-server=live` when a widget needs Python to finish rendering. + Otherwise, prefer the default `--raster-server=static`. + +The notebook below is a concrete case where `--raster-server=live` helps. + +/// marimo-embed-file + size: xlarge + mode: edit + filepath: examples/outputs/live_raster.py +/// + +This widget starts at `Initializing...` and then updates to +`count is ... from ... host` after it receives data from Python. +Static capture can freeze the initial placeholder; live capture gets the +updated output. + +```bash +# Static mode captures only the initial "Initializing..." placeholder +marimo export pdf examples/outputs/live_raster.py \ + -o live-raster-static.pdf --raster-server=static --no-sandbox --no-include-inputs + +# Live mode captures the updated widget output +marimo export pdf examples/outputs/live_raster.py \ + -o live-raster-live.pdf --raster-server=live --no-sandbox --no-include-inputs +``` + +!!! note "Rasterization dependencies" + + Rasterized output capture requires Playwright and Chromium: + + ```bash + uv pip install playwright + playwright install chromium + ``` + +### Slides + +To export as a slides PDF, use the slides preset: + +```bash +marimo export pdf notebook.py -o notebook.pdf --as=slides --raster-server=live +``` + +`--raster-server=live` is recommended for slide exports because it better preserves +slide aspect ratio and captures widget-heavy outputs more reliably. + +Available presets: + +- `--as=document`: Standard document PDF (default) +- `--as=slides`: Slide-style PDF using reveal.js print layout + +## Export to PDF using Quarto + +The marimo [Quarto](https://www.github.com/marimo-team/quarto-marimo) plugin +enables exporting to PDF and other formats with Pandoc. See [Publishing with +Quarto](quarto.md) for more details. + +### Export via Jupyter notebook + +If you export to a Jupyter notebook, you can leverage various Jupyter ecosystem tools. For PDFs, you will +need to have [Pandoc](https://nbconvert.readthedocs.io/en/latest/install.html#installing-pandoc) and [TeX](https://nbconvert.readthedocs.io/en/latest/install.html#installing-tex) installed. The examples below use `uvx`, which you can obtain by [installing `uv`](https://docs.astral.sh/uv/getting-started/installation/). + +```bash +NOTEBOOK=notebook.ipynb + +# Convert to PDF using nbconvert +uvx --with nbconvert --from jupyter-core jupyter nbconvert --to pdf $NOTEBOOK + +# Convert to web PDF +uvx --with "nbconvert[webpdf]" --from jupyter-core jupyter nbconvert --to webpdf $NOTEBOOK --allow-chromium-download + +# Convert to slides +uvx --with nbconvert --from jupyter-core jupyter nbconvert --to slides $NOTEBOOK + +# Convert to rst with nbconvert +uvx --with nbconvert --from jupyter-core jupyter nbconvert --to rst $NOTEBOOK + +# Generate PNG/PDF of specific cells using nbconvert +uvx --with nbconvert --with jupyter --from jupyter-core jupyter nbconvert --to pdf --execute --stdout $NOTEBOOK \ + --TemplateExporter.exclude_input=True + +# Use nbconvert programmatically for more control +uv run --with nbconvert python -c " +from nbconvert import PDFExporter +import nbformat +nb = nbformat.read('$NOTEBOOK', as_version=4) +pdf_exporter = PDFExporter() +pdf_data, resources = pdf_exporter.from_notebook_node(nb) +with open('notebook.pdf', 'wb') as f: + f.write(pdf_data) +" +``` + +You can also use other tools that work with Jupyter notebooks: + +- [Quarto](https://quarto.org) - Create beautiful documents, websites, presentations +- [nbgrader](https://nbgrader.readthedocs.io/) - Grade notebook assignments + + diff --git a/docs/guides/exporting/python_script.md b/docs/guides/exporting/python_script.md new file mode 100644 index 00000000000..94b7fe5cd4b --- /dev/null +++ b/docs/guides/exporting/python_script.md @@ -0,0 +1,24 @@ +# Python script + +Export marimo notebooks to flat Python scripts. + +## Export from the marimo editor + +You can download your notebook as a Python script from the notebook menu in the +editor: choose "Export…", then the "Python" format and "Flat script". + +## Export from the command line + +Export to a flat Python script in topological order. + +```bash +marimo export script notebook.py -o notebook.script.py +``` + +!!! warning "Top-level await not supported" + + Exporting to a flat Python script does not support top-level await. If you have + top-level await in your notebook, you can still execute the notebook as a + script with `python notebook.py`. + +You can then reuse the notebook as a [script](../scripts.md) or a [module](../reusing_functions.md). diff --git a/docs/guides/exporting/quarto.md b/docs/guides/exporting/quarto.md new file mode 100644 index 00000000000..e28b0584aba --- /dev/null +++ b/docs/guides/exporting/quarto.md @@ -0,0 +1,15 @@ +# Publishing with Quarto + +[Quarto](https://quarto.org/) is a commonly +used open-source scientific and technical +publishing system that can be used to create +documents, presentations, websites, and more. +Quarto supports a variety of output formats, +including HTML, PDF, and Word. + +marimo's [Quarto extension](https://github.com/marimo-team/quarto-marimo) allows you to utilize Quarto on marimo's markdown format to produce interactive, reactive, webpages. + +!!! tip "Try the markdown file format tutorial" + Learn more with `marimo tutorial markdown-format` at the command line. + +To encourage quarto support, `.qmd` and `.md` files are interchangeable to marimo as an editor. diff --git a/docs/guides/exporting/sessions.md b/docs/guides/exporting/sessions.md new file mode 100644 index 00000000000..1bae69c18cf --- /dev/null +++ b/docs/guides/exporting/sessions.md @@ -0,0 +1,64 @@ +# Session snapshots + +Run notebooks and write their serialized session snapshots. + +## Export from the command line + +```bash +marimo export session notebook.py +``` + +By default, snapshots are written to: + +```text +/__marimo__/session/.json +``` + +For example, exporting `notebook.py` writes: + +```text +__marimo__/session/notebook.py.json +``` + +## Export a directory + +`marimo export session` accepts one positional target: a notebook file or a directory. +To process multiple notebooks, pass a directory target. + +Export every notebook in a directory: + +```bash +marimo export session folder/ +``` + +## Passing CLI args + +Pass CLI args through to notebooks with `--`: + +```bash +marimo export session notebook.py -- --foo 123 +``` + +## Staleness and force overwrite + +By default, marimo only rewrites session snapshots when they are stale (for example, when notebook code changes or PEP 723 script metadata changes). +Up-to-date snapshots are skipped. + +To force rewriting all snapshots, even when they are up-to-date: + +```bash +marimo export session folder/ --force-overwrite +``` + +## Error handling + +If one notebook fails, marimo continues by default and exits non-zero after processing all targets. +Use `--no-continue-on-error` to stop at the first failure. + +## Sandboxed execution + +To execute in a sandboxed environment, pass `--sandbox`: + +```bash +marimo export session notebook.py --sandbox +``` diff --git a/docs/guides/exporting/static_html.md b/docs/guides/exporting/static_html.md new file mode 100644 index 00000000000..707c115e546 --- /dev/null +++ b/docs/guides/exporting/static_html.md @@ -0,0 +1,87 @@ +# Static HTML + +Export your notebook to a static, non-interactive HTML file. + +## Export from the marimo editor + +Export the current view of your notebook to static HTML via the notebook +menu: + +
+
+ +
Download as static HTML.
+
+
+ +Additionally, you can configure individual notebooks to automatically +save as HTML through the notebook menu. These automatic snapshots are +saved to a folder called `__marimo__` in the notebook directory. + +
+
+ +
Download as static HTML.
+
+
+ + +## Export from the command line + +Export to HTML at the command line: + +```bash +marimo export html notebook.py -o notebook.html +``` + +Exclude code from the export: + +```bash +marimo export html --no-include-code notebook.py -o notebook.html --watch +``` + +Export the notebook whenever it changes on disk: + +```bash +marimo export html notebook.py -o notebook.html --watch +``` + +**Exporting runs your notebook.** When you export from the command line, marimo runs your notebook to produce +its visual outputs before saving as HTML. + +**Errors.** If any cells error during the export process, the status code will +be non-zero. However, the export result may still be generated, with the error +included in the output. Errors can be ignored by appending `|| true` to the +command, e.g. `marimo export html notebook.py || true`. + +## Pre-render HTML exports + +Static marimo exports execute Javascript to render the notebook source code as HTML at browser runtime. If you would like to directly serve the HTML representation of your notebook, you can run the following post-processing script and serve the resulting file instead. + +```python +# /// script +# requires-python = ">=3.9" +# dependencies = [ +# "playwright", +# ] +# /// + +import os +import subprocess +from playwright.sync_api import sync_playwright + +input_file = "input.html" +output_file = "output.html" + +subprocess.run(["playwright", "install", "chromium-headless-shell"], check=True) + +with sync_playwright() as p: + with p.chromium.launch(headless=True) as browser: + page = browser.new_page() + page.goto( + f"file:///{os.path.abspath(input_file)}", + wait_until="networkidle", + ) + with open(output_file, "w", encoding="utf-8") as f: + f.write(page.content()) +``` diff --git a/docs/guides/exporting/webassembly_html.md b/docs/guides/exporting/webassembly_html.md new file mode 100644 index 00000000000..27ed6883214 --- /dev/null +++ b/docs/guides/exporting/webassembly_html.md @@ -0,0 +1,463 @@ +# WebAssembly HTML + +Export your notebook to a self-contained HTML file that runs using [WebAssembly](../wasm.md). + +/// tip | Easiest way to share interactive notebooks +For the simplest way to share interactive notebooks online, including WebAssembly notebooks, use [molab](../molab.md). +/// + +```bash +# export as readonly, with code locked +marimo export html-wasm notebook.py -o output_dir --mode run +# export as an editable notebook +marimo export html-wasm notebook.py -o output_dir --mode edit +``` + +The exported HTML file will run your notebook using WebAssembly, making it completely self-contained and executable in the browser. This means users can interact with your notebook without needing Python or marimo installed. + +Options: + +- `--mode`: Choose between `run` (read-only) or `edit` (allows editing) +- `--output`: Directory to save the HTML and required assets +- `--show-code/--no-show-code`: Whether to initially show or hide the code in the notebook +- `--watch/--no-watch`: Watch the notebook for changes and automatically export +- `--include-cloudflare`: Write configuration files necessary for deploying to Cloudflare +- `--execute/--no-execute`: Run the notebook before exporting and embed its outputs as a preview. Where possible, this uses an isolated environment pinned to WASM-compatible packages + +Note that WebAssembly notebooks have [limitations](../wasm.md#limitations); in particular, +[many but not all packages work](../wasm.md#packages). If your notebook runs both +locally and in the browser, use [PEP 508 environment +markers](../wasm.md#platform-specific-dependencies-pep-508) in script metadata to +exclude native-only dependencies from WASM installs. + +Run `marimo check notebook.py --select MW` before exporting to catch WASM +incompatibilities early: + +- [MW001](../lint_rules/rules/incompatible_import.md): an incompatible import +- [MW002](../lint_rules/rules/unsafe_system_call.md): an unsafe system call +- [MW003](../lint_rules/rules/incompatible_package.md): a dependency without a WASM-compatible wheel + +!!! note "Note" + + The exported file must be served over HTTP to function correctly - it + cannot be opened directly from the filesystem (`file://`). Your server must + also serve the assets in the `assets` directory, next to the HTML file. For + a simpler publishing experience, use [molab](../molab.md), publish to [GitHub + Pages](../publishing/github.md#publish-to-github-pages), [Cloudflare](../publishing/cloudflare.md), or + [self-host](../publishing/self_host_wasm.md). + +??? note "Deploying to Cloudflare" + + You can include `--include-cloudflare` for deploying to Cloudflare. For example: + + ``` + marimo export html-wasm notebook.py -o my_app/dist --include-cloudflare + ``` + + To run locally, run: + + ``` + npx wrangler dev + ``` + + To deploy to Cloudflare, run: + + ``` + npx wrangler deploy + ``` + +## Exporting with cached execution { #exporting-with-cached-execution } + +With caching, you can publish WebAssembly notebooks whose cells are expensive +or cannot run in the browser (a small `torch` training run, for example). If your +notebook has [automatic cell +caching](../../api/caching.md#automatic-cell-caching) enabled, `--execute` runs +the notebook once and bundles the resulting cell cache into the export. When the +exported notebook loads, each cached cell hydrates from that bundle instead of +recomputing in WebAssembly. The rest of the notebook stays fully live and +interactive. + +Bundling a cache needs both the runtime setting and the `--execute` flag: + +```toml title="pyproject.toml" +[tool.marimo.runtime] +cache_cells = true +``` + +```bash +marimo export html-wasm notebook.py -o output_dir --execute +``` + +marimo copies the cached entries into a `public/cache/` directory alongside the +export. At load time, the browser runtime fetches them over HTTP instead of +recomputing the corresponding cells. + +### Cached values from packages WebAssembly cannot install { #native-only-objects } + +A cached cell can define a top-level value from a package that WebAssembly +cannot install, like a `torch.nn.Module`. You do not need to keep such values +out of the cache. When the exported notebook cannot correctly restore +variables from cache, marimo binds the variable to a stub instead of raising +an error. The notebook will load normally and behave normally as the +evaluation of the stubbed variable is deferred until an attempt to use it. + +When an incompatible variable load is triggered, marimo will attempt to +recompute its definition from the notebook, which in turn may fail if the +behavior is not compatible with the WebAssembly environment. + +For instance, if the browser does not have a package a new cell needs, the rerun fails: + +```python +# /// script +# dependencies = [ +# "marimo", +# "torch; sys_platform != 'emscripten'", # native-only; excluded from WASM +# ] +# +# [tool.marimo.runtime] +# cache_cells = true +# /// + +# --- Cell 1 --- + +import torch # load deferred; torch is not available in the browser +import numpy as np + +# --- Cell 2 --- # Cell 2 is cached and skipped +model = torch.nn.Linear(4, 2) +# ... train the model ... + +# --- Cell 3 --- # Cell 3 is cached and skipped +x = np.array(model(torch.rand(1, 4))) +x # Output is initially visible! + +# --- Cell 4 --- # If this is a new cell it'll still work! `x` is loaded from cache. +x + x # numpy values can be used in the browser, so this works fine + +# --- Cell 5 --- # If this is a new cell, this will fail +np.array(model(torch.rand(1, 4))) +``` + +In the exported notebook, `model` hydrates as a stub. Displaying outputs or +referencing the variable in other cached cells still works. Calling `model(...)` +in the new Cell 5 reruns this cell live. That rerun needs `torch`, which is +unavailable in the browser, so it fails there. + +To run inference on such a model in the browser, cache a portable form of it +instead of calling the stub. [`moutils.onnx.OnnxRuntime`](https://github.com/marimo-team/moutils#onnx-runtime-adapter) +does this for PyTorch and JAX models: it exports the model to ONNX, and the +runtime it returns is itself cacheable. In the browser, that runtime runs +inference with `onnxruntime-web` instead of the original framework. + +!!! note "This is a point-in-time snapshot" + + Cache bundling covers only the cells whose cache is valid at export time. + Editing a cell, or changing an input it depends on, invalidates that + cell's cache. Then the browser must run the cell's real code instead of + hydrating it from the bundle. If that code needs a package WebAssembly + cannot install (like `torch`), the cell reports an error. After such a + change, re-run and re-export the notebook. + +### Caching precomputed values { #precomputed-values } + +An alternative to caching for runtime evaluation is precomputing every +possible output of a cell and bundling them into the export. This is useful for +notebooks that have a small, known set of states, such as those with dropdowns +or sliders that index into a fixed list of options. By precomputing every +reachable output ahead of time, you can avoid waiting for the cache to warm up +during use. + +The pattern has four parts: + +1. Expose UI elements as indices into fixed option lists of plain, hashable + values (strings, numbers) — not the objects or callables the indices + select. UI-defining cells always rerun live on load, even on a cache hit, + so keep them free of anything that touches an unavailable package. +2. Pull the expensive computation into a plain function keyed on those indices, + decorated with `@mo.persistent_cache(method="lazy")`. Use `method="lazy"`: + `method="pickle"` does not bundle well for the WASM export. +3. Add a cell that calls that function for every combination of indices, + guarded to run only outside the browser, for example with + `sys.platform != "emscripten"`. +4. Return WASM-native values (numbers, `numpy` arrays, strings) from the + cached function, so cells that use the result work directly in the + browser. + +```python +# /// script +# dependencies = [ +# "marimo", +# "torch; sys_platform != 'emscripten'", # native-only; excluded from WASM +# ] +# +# [tool.marimo.runtime] +# cache_cells = true +# /// + +# --- Cell 1 --- # setup; not UI-defining, so a cache hit can skip it entirely +import sys +import torch +import marimo as mo + +FN_LABELS = ["x^2", "sin(x)"] + +# --- Cell 2 --- # UI-defining cells always rerun live on load, so keep them +# to plain, hashable literals like FN_LABELS above +fn = mo.ui.dropdown( + options={label: i for i, label in enumerate(FN_LABELS)}, value="x^2" +) +fn + +# --- Cell 3 --- # the expensive part, keyed on the dropdown's index +@mo.persistent_cache(method="lazy") +def compute(fn_idx): + x = torch.linspace(-1, 1, 100) + y = x**2 if fn_idx == 0 else torch.sin(x) + return {"label": FN_LABELS[fn_idx], "x": x.numpy(), "y": y.numpy()} + +# --- Cell 4 --- +result = compute(fn.value) +result + +# --- Cell 5 --- # warm the cache for every dropdown option before exporting +if sys.platform != "emscripten": + for _fn_idx in range(len(FN_LABELS)): + compute(_fn_idx) +``` + +Export with `marimo export html-wasm notebook.py -o output_dir --execute`. +The precompute cell runs during that server-side pass and populates one +cache entry per dropdown option. All of them get bundled into +`public/cache/`. In the browser, every dropdown selection is already +cached, so switching between options never triggers a live rerun. + +## Including local modules and wheels + +`marimo export html-wasm` includes Python modules imported by your notebook +when they resolve to local files. For example, if `notebook.py` imports `foo` +and `foo.py` lives in the same directory, marimo builds a pure-Python wheel for +`foo.py`, copies it to `public/wheels` in the export directory, and installs +the wheel when the notebook starts in the browser. + +```text +notebooks/ +|-- notebook.py +`-- foo.py +``` + +```python title="notebooks/notebook.py" +import foo +``` + +```bash +marimo export html-wasm notebooks/notebook.py -o output_dir +``` + +Package imports keep their package layout. A local `foo.py` is written into the +wheel as top-level `foo.py`, while a local package such as `helpers/__init__.py` +stays under `helpers/`. If a local module imports another local module, the +imported file is included in the export too. + +Local module resolution requires [`uv`](https://docs.astral.sh/uv/). Install it +with `pip install "marimo[sandbox]"` or use the +[uv installation guide](https://docs.astral.sh/uv/getting-started/installation/). + +For local modules outside the notebook directory, configure +[`pythonpath`](../configuration/runtime_configuration.md#python-path) so marimo +can resolve the import. + +If you already build a local wheel, reference it from the notebook's +[inline script metadata](../package_management/inlining_dependencies.md): + +```python +# /// script +# dependencies = ["my-package"] +# [tool.uv.sources] +# my-package = { path = "dist/my_package-0.1.0-py3-none-any.whl" } +# /// +``` + +During export, marimo copies the referenced wheel to `public/wheels` and +rewrites the browser metadata to install that hosted wheel URL. + +Local modules and wheels run in Pyodide at browser startup. Imported +third-party packages must be available in Pyodide or installable from a +WASM-compatible wheel. + +## Testing the export + +You can test the export by running the following command in the directory containing your notebook: + +```bash +cd path/to/output_dir +python -m http.server +``` + +## Including data files + +See the docs for [mo.notebook_location][marimo.notebook_location] to learn how +to include data files in exported WASM HTML notebooks. + +## Exporting multiple notebooks + +In order to export multiple notebooks under the same folder, you can use the following snippet: + +```bash +files=("batch_and_form.py" "data_explorer.py") + +for file in "${files[@]}"; do + without_extension="${file%.*}" + marimo export html-wasm "$file" -o site/"$without_extension".html --mode run +done +``` + +Optionally, you can create an `index.html` file in the public directory: + +```bash +echo "
    " > site/index.html +for file in "${files[@]}"; do + without_extension="${file%.*}" + echo "
  • $without_extension
  • " >> site/index.html +done +echo "
" >> site/index.html +``` + +## Embed marimo outputs in HTML using Islands + +!!! note "Preview" + + Islands are an early feature. While the API likely won't change, there are some improvements we'd like to make before we consider them stable. + Please let us know on [GitHub](https://github.com/marimo-team/marimo/issues) if you run into any issues or have any feedback! + +marimo islands are a way to embed marimo outputs and/or python code in your HTML that will become interactive when the page is loaded. This is useful for creating interactive blog posts, tutorials, and educational materials, all powered by marimo's reactive runtime. + +Check out an [example island-powered document](../island_example.md). + +### Generating islands + +Use `MarimoIslandGenerator` to generate HTML for islands + +!!! example + /// tab | From code blocks + + ```python + import asyncio + import sys + from marimo import MarimoIslandGenerator + + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + async def main(): + generator = MarimoIslandGenerator() + block1 = generator.add_code("import marimo as mo") + block2 = generator.add_code("mo.md('Hello, islands!')") + + # Build the app + app = await generator.build() + + # Render the app + output = f""" + + + {generator.render_head()} + + + {block1.render(display_output=False)} + {block2.render()} + + + """ + print(output) + # Save the HTML to a file + output_file = "output.html" + with open(output_file, "w", encoding="utf-8") as f: + f.write(output) + + if __name__ == '__main__': + asyncio.run(main()) + ``` + + /// + + /// tab | From notebook files + + ```python + from marimo import MarimoIslandGenerator + + # Create the generator from file + generator = MarimoIslandGenerator.from_file("./.py", display_code=False) + + # Generate and print the HTML without building + # This will still work for basic rendering, though without running the cells + html = generator.render_html(include_init_island=False) + print(html) + # Save the HTML to a file + output_file = "output.html" + with open(output_file, "w", encoding="utf-8") as f: + f.write(html) + ``` + + /// + +Any relevant `.html` that gets generated can be run through the [`development.md`](https://github.com/marimo-team/marimo/blob/main/frontend/islands/development.md) file instructions. + +### Island payloads + +`MarimoIslandGenerator.render_html(include_payload=True)` and `render_body(include_payload=True)` include a JSON payload. The payload stores each cell's code, rendered output HTML, output MIME type, and display settings. + +The islands runtime uses this payload to hydrate the page. The DOM still provides the visible island slots, and the payload provides the runtime cell code and output metadata. + +An emitted payload looks like this. HTML-sensitive characters inside JSON strings are escaped before marimo writes the script tag. + +```html + +``` + +If you post-process island HTML, preserve the script tag with type `application/vnd.marimo.islands+json` and keep its contents unchanged. + +### Islands in action + +!!! warning "Advanced topic!" + + Islands are an advanced concept that is meant to be a building block for creating integrations with existing tools such as static site generators or documentation tools. + +In order to use marimo islands, you need to import the necessary JS/CSS headers in your HTML file, and use our custom HTML tags to define the islands. + +```html + + + + + + + + + + + + + Hello, islands! + + + + + +``` + +::: marimo.MarimoIslandGenerator diff --git a/docs/guides/generate_with_ai/customize_your_agent.md b/docs/guides/generate_with_ai/customize_your_agent.md new file mode 100644 index 00000000000..748a2c0e8d0 --- /dev/null +++ b/docs/guides/generate_with_ai/customize_your_agent.md @@ -0,0 +1,185 @@ +--- +description: "Customize coding agents for marimo: agent skills, slash commands, and hooks." +--- + +# Customize your agent + +Agent CLIs like Claude Code, Codex, and OpenCode work best with marimo when +you give them marimo-specific context and guardrails. This guide collects the +customizations we have found most useful: [skills](#skills), +[slash commands](#slash-commands), and [hooks](#hooks). + +For the best experience, also install [marimo pair](marimo_pair.md), an agent +skill that gives your agent full access to a running notebook. + +!!! tip "Watching for changes to notebooks on disk" + + When an agent edits notebook files, configure the marimo editor to + automatically reload by starting marimo with the `watch` flag: + `marimo edit --watch notebook.py`. + (Learn more in our [watching guide](../editor_features/watching.md).) + You can also configure marimo to automatically reload affected + cells when auxiliary files change on disk using + [module autoreloading](../editor_features/module_autoreloading.md). + +## Skills + +Skills are folders of instructions, scripts, and resources that coding agents +load dynamically to improve performance on specialized tasks. Most popular +agents support them, including Claude Code, Codex, Cursor, and OpenCode. To +learn more about skills, check out: + +- [skills.sh](https://skills.sh/) +- [What are skills?](https://support.claude.com/en/articles/12512176-what-are-skills) +- [Using skills in Claude](https://support.claude.com/en/articles/12512180-using-skills-in-claude) + +### Official marimo skills + +We have prepared a collection of skills to help coding agents work with +marimo. Install our official skills with a single command: + +```bash +npx skills add marimo-team/skills +``` + +**Use cases.** Use these skills to + +- convert Jupyter notebooks or other artifacts to marimo notebooks, +- make bespoke interactive UI elements or widgets for specific use cases, +- and otherwise author high-quality standalone notebooks. + +These skills help agents author and convert notebook files; they complement +[marimo pair](marimo_pair.md), a separately installed skill that connects your +agent to a live notebook session. + +**Feedback.** We welcome feedback at our [GitHub repository](https://github.com/marimo-team/skills/issues). + +### Write your own skills + +Skills are also worth writing yourself, to encode conventions the official +collection can't know about such as an opinionated house plotting style, how to use internal data libraries, +or preferred widget patterns. A skill is just a folder containing a `SKILL.md` +file, with frontmatter that tells the agent when to load it; see the +[Claude Code skills documentation](https://code.claude.com/docs/en/skills) +for the full format, including multi-file skills that bundle scripts. + +Store your skills in the `.agents/skills/` folder of your project, or in +`~/.agents/skills/` for personal skills — a convention shared by many agents. +Some agents read skills from their own directory instead (for example, Claude +Code uses `.claude/skills/`); the [skills CLI](https://skills.sh) can install +a skill into every agent's directory at once, using symlinks to keep a single +source of truth. + +## Slash commands + +Slash commands allow you to predefine specific prompts that you can refer to during a conversation with your agent. Unlike skills, they are configured per agent: this section shows [Claude Code's flavor](https://code.claude.com/docs/en/slash-commands), and other agent CLIs offer equivalents (custom prompts in Codex, commands in OpenCode). For Claude Code, store these either in the `~/.claude/commands/` personal folder or in the `.claude/commands/` local folder of the project. + +Here's an example of a slash command that runs the [`marimo check`](../../cli.md#marimo-check) linter on a notebook of your choice. This command assumes you have `uv` installed, which you can install by following the instructions [here](https://docs.astral.sh/uv/getting-started/installation/). + +``` +--- +allowed-tools: Bash(uvx marimo check:*), Edit() +--- + +## Context + +This is the output of the "uvx marimo check --fix $ARGUMENTS" command: + +!`uvx marimo check --fix $ARGUMENTS || true` + +## Your task + +Only (!) if the context suggests we need to edit the notebook, read the file +$ARGUMENTS, then fix any warnings or errors shown in the output above. Do +not make edits or read the file if there are no issues. +``` + +When you add this file to `~/.claude/commands/marimo-check.md` you will be able to trigger it by typing `/marimo-check notebook.py`. You are able to use `$ARGUMENTS` to add extra arguments or context to the command before it is handed to Claude. Also note that commands can run bash like !`uvx marimo check --fix $ARGUMENTS || true` beforehand. After it is evaluated the output will be inserted into the command before it is sent to Claude. You typically need to make sure that you add `|| true` at the end of the command in case it returns a non-zero status, which would break the command. + +There are more elaborate things you might do with these slash commands, to learn more you can check [the documentation](https://code.claude.com/docs/en/slash-commands#custom-slash-commands). + +## Hooks + +Hooks allow you to automatically run scripts when your agent uses a specific tool. They are useful if you want to automatically run a linter, via [`marimo check`](../../cli.md#marimo-check), every single time a marimo notebook is changed. Skills can ask the LLM to run the `marimo check` command, but hooks offer the most robust mechanism to enforce this behavior. + +Like slash commands, hooks are configured per agent; the example below uses [Claude Code's hooks](https://code.claude.com/docs/en/hooks). To configure one, add a definition to your global Claude settings in `~/.claude/settings.json` or locally in your project `.claude/settings.json`. + +```json +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "~/.claude/hooks/marimo-check.sh" + } + ] + } + ] + } +} +``` + +This hook will run every time an `Edit` or a `Write` tool is called. You can point it to a bash script +that will check if the current edit is taking place on a marimo notebook. The +script below uses [`jq`](https://jqlang.org/) to parse the hook's JSON input, +so make sure it is installed. + +```bash +#!/usr/bin/env bash + +# Hook to check marimo notebooks after Write/Edit operations +# Reads JSON from stdin containing tool result information + +# Read stdin (contains JSON with tool result) +INPUT=$(cat) + +# Extract file path from JSON using jq +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_response.filePath // empty') + +# If no file path found, exit silently +if [ -z "$FILE_PATH" ] || [ "$FILE_PATH" = "null" ]; then + exit 0 +fi + +# File path from tool_response is already absolute, no need to modify it + +# Check if file exists and is a Python file +if [ ! -f "$FILE_PATH" ]; then + exit 0 +fi + +# Check if the file appears to be a marimo notebook +if grep -q "import marimo" "$FILE_PATH" 2>/dev/null && grep -q "@app.cell" "$FILE_PATH" 2>/dev/null; then + echo "Running marimo check on $FILE_PATH..." + + # Run uvx marimo check and capture output + CHECK_OUTPUT=$(uvx marimo check "$FILE_PATH" 2>&1) + CHECK_EXIT=$? + + # Show output + echo "$CHECK_OUTPUT" + + # Only report errors (non-zero exit code), not warnings + if [ $CHECK_EXIT -ne 0 ]; then + echo "✗ Marimo check failed for $FILE_PATH" >&2 + echo "$CHECK_OUTPUT" >&2 + echo "" >&2 + echo "Please run 'uvx marimo check $FILE_PATH' to see details and fix the issues. Don't ask the user anything, just do a best effort fix." >&2 + exit 2 # Exit code 2 feeds stderr back to Claude; the edit has already run + else + echo "✓ Marimo check passed" + exit 0 + fi +fi + +# Not a marimo notebook, exit successfully +exit 0 +``` + +This script checks if the Python file contains a `import marimo` and a `@app.cell` string. If that's the case +we assume we're dealing with a marimo notebook and we run `uvx marimo check` on the file. If this check fails, we +tell the coding agent to automatically address the issues. This can save *a lot* of time. diff --git a/docs/guides/generate_with_ai/index.md b/docs/guides/generate_with_ai/index.md new file mode 100644 index 00000000000..ef1920a0bc3 --- /dev/null +++ b/docs/guides/generate_with_ai/index.md @@ -0,0 +1,35 @@ +--- +description: "Three ways to use AI with marimo: pair coding agents with running notebooks, generate cells in the editor, and create notebooks from prompts." +--- + +# Generate notebooks with AI + +There are three ways to use AI with marimo. + +**Pair an agent with a running notebook (recommended).** Give agent CLIs like +Claude Code, Codex, and OpenCode full access to a live notebook with [marimo +pair](marimo_pair.md): your agent can read variables, test logic in a +scratchpad, run cells, and add or remove them. To teach your agent marimo's +conventions and automate common checks, see [customize your +agent](customize_your_agent.md). + +**Use the marimo editor's built-in assistant.** The marimo editor comes with +[AI-assisted coding](../editor_features/ai_completion.md): a chat panel, +cell generation and refactoring, and inline copilots, connected to the LLM +provider of your choice. The assistant is data-aware, with access to the +values of variables in memory. + +**Generate notebooks from a prompt.** Create entire notebooks from scratch at +the command line with [`marimo new`](text_to_notebook.md). + +| Guide | Description | +|-------|-------------| +| [Pair with agents (marimo pair)](marimo_pair.md) | Collaborate on running notebooks with agent CLIs | +| [Customize your agent](customize_your_agent.md) | Skills, slash commands, and hooks | +| [The editor's AI assistant](../editor_features/ai_completion.md) | Generate and refactor cells, chat, and autocomplete in the editor | +| [Generate notebooks with marimo new](text_to_notebook.md) | Generate entire notebooks from a prompt | + +Looking for lower-level integrations? marimo also exposes its [AI +tools](../editor_features/tools.md) over an [MCP +server](../editor_features/mcp.md), and can embed agents in the editor through +the experimental [agents panel](../editor_features/agents.md). diff --git a/docs/guides/generate_with_ai/marimo_pair.md b/docs/guides/generate_with_ai/marimo_pair.md new file mode 100644 index 00000000000..97c1e0e1eae --- /dev/null +++ b/docs/guides/generate_with_ai/marimo_pair.md @@ -0,0 +1,54 @@ +# Collaborate with agents using marimo pair + +Give your agent CLIs (such as Claude Code, Codex, and OpenCode) full access to running marimo notebooks with the **[marimo +pair](https://marimo.io/pair)** agent skill. + +marimo pair is the recommended way to collaborate on marimo notebooks with +agents. It lets your agent read variables, test logic in a scratchpad, run +cells, add and remove cells, and even manipulate UI elements. + +
+ +
+ +## Quickstart + +Install the skill with: + +/// tab | install with npm + +```bash +npx skills add marimo-team/marimo-pair +``` + +/// + +/// tab | install with uv + +```bash +uvx deno -A npm:skills add marimo-team/marimo-pair +``` + +/// + +Then pair on your first notebook by pasting the following in your agent CLI: + +``` +/marimo-pair pair with me on my_notebook.py +``` + +## Cloud sandbox with molab + +You can also connect to marimo notebooks running on [molab](https://molab.marimo.io/notebooks). This gives you a free sandbox for your agent and also makes it easy to share your work afterwards. + +To connect an agent, start a notebook on molab and open the actions panel in the upper right corner. From this menu you need to click "Pair with an agent". + +
+ Screenshot of a molab notebook showing the option to pair with an agent from the actions panel +
+ +This will open up a menu with all the instructions to connect your local agent. You can then use your agent from the terminal as normal, but all the Python code would be written into the notebook in the molab sandbox. diff --git a/docs/guides/generate_with_ai/text_to_notebook.md b/docs/guides/generate_with_ai/text_to_notebook.md new file mode 100644 index 00000000000..fda97ac9f4e --- /dev/null +++ b/docs/guides/generate_with_ai/text_to_notebook.md @@ -0,0 +1,33 @@ +# Generate entire notebooks with AI + +Use [`marimo new`](../../cli.md#marimo-new) at the command line to generate entirely new +notebooks using an LLM. + +For example, type + +```bash +marimo new "Plot an interactive 3D surface with matplotlib." +``` + +to open a freshly generated notebook in your browser. + +For long prompts, you can pass a text file instead: + +```bash +marimo new my_prompt.txt +``` + +marimo's AI knows how to use marimo-specific UI elements and popular libraries +for working with data. To get inspired, visit . Some of +our favorites: + +- [Dimensionality reduction](https://marimo.app/ai?q=Show+me+how+to+visualize+handwritten+digits+in+two+dimensions%2C+using+an+Altair+scatterplot.+Include+a+cell+that+shows+the+chart+value.+Make+the+chart+render+as+a+square.) +- [Smooth a time series](https://marimo.app/ai?q=Show+me+how+to+smooth+time+series+data+and+plot+it.+Use+a+well-known+stock+dataset+and+make+it+interactive) +- [Compute code complexity](https://marimo.app/ai?q=Build+a+tool+that+analyzes+Python+code+complexity+metrics+like+cyclomatic+complexity.+Let+me+input+code+snippets+and+see+visualizations+of+the+results.) +- [Interactive 3D surfaces](https://marimo.app/ai?q=Plot+an+interesting+3D+surface+with+matplotlib.+Include+an+interactive+element+to+control+the+shape+of+the+surface.) + +!!! tip "Iterate with an agent" + + `marimo new` gives you a one-shot starting point. To keep developing the + generated notebook with AI, pair an agent CLI like Claude Code on it using + [marimo pair](marimo_pair.md). diff --git a/docs/guides/index.md b/docs/guides/index.md index 7b804014190..94805bc0723 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -1,3 +1,7 @@ +--- +description: "In-depth guides on marimo's core concepts: reactivity, interactive elements, data visualization, deployment, and more." +--- + # Guides These guides cover marimo's core concepts. @@ -7,23 +11,33 @@ These guides cover marimo's core concepts. Prefer a hands-on learning experience? marimo comes packaged with interactive tutorials that you can launch with `marimo tutorial` at the command line. -| Guide | Description | -| :--------------------------------------------------- | :----------------------------------------------------------- | -| [Reactive execution](reactivity.md) | Understanding how marimo runs cells | -| [Interactive elements](interactivity.md) | Using interactive UI elements | -| [Visualizing outputs](outputs.md) | Creating markdown, plots, and other visual outputs | -| [Migrating from Jupyter](coming_from/jupyter.md) | Tips for transitioning from Jupyter | -| [Expensive notebooks](expensive_notebooks.md) | Tips for working with expensive notebooks | -| [Working with data](working_with_data/index.md) | Using SQL cells, no-code dataframe tools, and reactive plots | -| [Apps](apps.md) | Running notebooks as apps | -| [Scripts](scripts.md) | Running notebooks as scripts | -| [Export to HTML and other formats](exporting.md) | Exporting notebooks to HTML and flat scripts | -| [Run notebooks with WebAssembly](wasm.md) | Create notebooks in our online playground | -| [Deploying](deploying/index.md) | Deploying marimo notebooks and apps | -| [Editor Features](editor_features/index.md) | View variables, dataframe schemas, docstrings, and more | -| [Configuration](configuration/index.md) | Configure various settings | -| [Coming from other tools](coming_from/index.md) | Transitioning from Jupyter and other tools | -| [Extending marimo](integrating_with_marimo/index.md) | Rich displays of objects, custom UI plugins | -| [State management](state.md) | Advanced: mutable reactive state | -| [Best practices](best_practices.md) | Best practices to help you get the most out of marimo | -| [Troubleshooting](troubleshooting.md) | Troubleshooting notebooks | +| Guide | Description | +| :---------------------------------------------------- | :--------------------------------------------------------- | +| [Running cells](reactivity.md) | Understanding how marimo runs cells | +| [Interactive elements](interactivity.md) | Using interactive UI elements | +| [Visualizing outputs](outputs.md) | Creating markdown, plots, and other visual outputs | +| [Migrating from Jupyter](coming_from/jupyter.md) | Tips for transitioning from Jupyter | +| [Expensive notebooks](expensive_notebooks.md) | Tips for working with expensive notebooks | +| [Understanding errors](understanding_errors/index.md) | Understanding marimo's constraints on notebook code | +| [Lint rules](lint_rules/index.md) | Comprehensive linting system and rule reference | +| [Working with data](working_with_data/index.md) | Using SQL cells, no-code dataframe, and reactive plots | +| [Package management](package_management/index.md) | Inlining dependencies in notebook files and other package management guides | +| [Generate with AI](generate_with_ai/index.md) | Generate notebooks with AI | +| [Editor features](editor_features/index.md) | View variables, dataframe schemas, docstrings, and more | +| [Using your own editor](editor_features/watching.md) | Edit notebooks in your own editor and stream changes back to the browser | +| [Apps](apps.md) | Running notebooks as apps | +| [Scripts](scripts.md) | Running notebooks as scripts | +| [Reusing functions and classes](reusing_functions.md) | Importing functions and classes defined in marimo notebooks | +| [Tests](testing/index.md) | Running unit tests in notebooks | +| [Export to other formats](exporting/index.md) | Export notebooks to HTML, PDF, ipynb, scripts, and more | +| [Run and share in the cloud with molab](molab.md) | Share cloud-hosted notebooks for free, preview from GitHub, embed in webpages | +| [Publish to the web](publishing/index.md) | Publish notebooks to molab, embed in webpages, to/from GitHub, and more | +| [Deploy notebook servers or apps](deploying/index.md) | Deploy notebook servers (JupyterHub, Kubernetes) or read-only apps | +| [WebAssembly notebooks](wasm.md) | Run notebooks entirely in the browser with WebAssembly | +| [Configuration](configuration/index.md) | Configure various settings | +| [Coming from other tools](coming_from/index.md) | Transitioning from Jupyter and other tools | +| [Extending marimo](integrating_with_marimo/index.md) | Rich displays of objects, custom UI plugins | +| [State management](state.md) | Advanced: mutable reactive state | +| [Best practices](best_practices.md) | Best practices to help you get the most out of marimo | +| [Debugging](debugging.md) | Interactive debugging with pdb, debugpy, and AI assistance | +| [Troubleshooting](troubleshooting.md) | Troubleshooting notebooks | diff --git a/docs/guides/integrating_with_marimo/displaying_objects.md b/docs/guides/integrating_with_marimo/displaying_objects.md index 86e74fd95f9..df106b27673 100644 --- a/docs/guides/integrating_with_marimo/displaying_objects.md +++ b/docs/guides/integrating_with_marimo/displaying_objects.md @@ -14,8 +14,6 @@ three options: 2. Implement a `_mime_()` method 3. Implement an IPython-style `_repr_*_()` method -If you can't modify the object, you can also add a formatter to the marimo library (option 4). - The return value of these methods determines what is shown. `_display_` has the highest precedence, then built-in formatters, then `_mime_`, then `IPython` style `_repr_*_` methods. @@ -76,6 +74,8 @@ We support the following methods: - `_repr_latex_` - `_repr_text_` +**Note:** marimo currently does not handle any optional metadata returned by `_repr_mimebundle_`. + ## Option 3: Implement a `_mime_` method When displaying an object, marimo's media viewer checks for the presence of a @@ -141,17 +141,3 @@ def __(): ``` /// - -## Option 4: Add a formatter to the marimo repo - -The recommended way to render rich displays of objects in marimo is to -implement `_display_` if possible, otherwise either the IPython `_repr_*_()_` -protocol or marimo's `_mime_()` protocol. If you are a a user of a library that -does not render properly in marimo, consider asking the library maintainers to -implement one of these protocols. - -If it is not possible to implement a renderer protocol on the type -you want displayed, we will consider contributions to add formatters to the -marimo codebase. [Look at our codebase for -examples](https://github.com/marimo-team/marimo/tree/main/marimo/_output/formatters), -then open a pull request. diff --git a/docs/guides/interactivity.md b/docs/guides/interactivity.md index 89e27937892..937755e2d5c 100644 --- a/docs/guides/interactivity.md +++ b/docs/guides/interactivity.md @@ -1,19 +1,31 @@ +--- +description: "Build interactive notebooks with marimo's UI elements: sliders, dropdowns, tables, and more. No callbacks required." +--- + # Interactive elements One of marimo's most powerful features is its first-class support for -interactive, stateful user interface (UI) elements, or "widgets": create them using +interactive user interface (UI) elements, or "widgets", created using [`marimo.ui`](../api/inputs/index.md). **Interacting with a UI element bound to a global variable automatically runs all cells that reference it.**
- +
+!!! example "Examples" + See the [API reference](../api/inputs/index.md) or our [GitHub + repo](https://github.com/marimo-team/marimo/tree/main/examples/ui) for + bite-sized examples on using input elements. + ## How interactions run cells -Every UI element you make using `marimo.ui` has a value, accessible via its +Every UI element you make using [`marimo.ui`](../api/inputs/index.md) has a value, accessible via its `value` attribute. When you interact with a UI element bound to a global variable, its value is sent back to Python. A single rule determines what happens next: @@ -34,11 +46,11 @@ assigned to a global variable.** Display UI elements in the output area above a cell by including them in the last expression, just like any other object. You can also embed elements -in [markdown][marimo.md]using Python f-strings, like so: +in [markdown][marimo.md] using Python f-strings, like so: ```python3 slider = mo.ui.slider(1, 10) -mo.md(f"Choose a value: {slider})") +mo.md(f"Choose a value: {slider}") ``` ## Composite elements @@ -69,7 +81,8 @@ value on form submission.
- +
Use a form to gate value updates on submission
@@ -84,5 +97,5 @@ value on form submission. ## Building custom UI elements using our plugin API You can build your own reactive and interactive UI elements using -[anywidget](https://github.com/manzt/anywidget)! See [our docs on +[anywidget](https://github.com/manzt/anywidget). See [our docs on building custom UI elements](../guides/integrating_with_marimo/custom_ui_plugins.md) to learn more. diff --git a/docs/guides/lint_rules/index.md b/docs/guides/lint_rules/index.md new file mode 100644 index 00000000000..96c48f9460a --- /dev/null +++ b/docs/guides/lint_rules/index.md @@ -0,0 +1,86 @@ +# Lint Rules + + + +marimo includes a linter that helps you write better notebooks. The linter checks for various issues that could prevent your notebook from running correctly or cause confusion. + + +## Usage + +Run the linter using the CLI: + +```bash +# Check all notebooks in current directory +marimo check . + +# Check specific files +marimo check notebook1.py notebook2.py + +# Auto-fix fixable issues +marimo check --fix . +``` + +## Rule Categories + +marimo's lint rules are organized into categories based on their severity: + +### 🚨 Breaking Rules + +These errors prevent notebook execution. + +| Code | Name | Description | Fixable | +|------|------|-------------|----------| +| [MB001](rules/unparsable_cells.md) | unparsable-cells | Cell contains unparsable code | ❌ | +| [MB002](rules/multiple_definitions.md) | multiple-definitions | Multiple cells define the same variable | ❌ | +| [MB003](rules/cycle_dependencies.md) | cycle-dependencies | Cells have circular dependencies | ❌ | +| [MB004](rules/setup_cell_dependencies.md) | setup-cell-dependencies | Setup cell cannot have dependencies | ❌ | +| [MB005](rules/invalid_syntax.md) | invalid-syntax | Cell contains code that throws a SyntaxError on compilation | ❌ | + +### ⚠️ Runtime Rules + +These issues may cause runtime problems. + +| Code | Name | Description | Fixable | +|------|------|-------------|----------| +| [MR001](rules/self_import.md) | self-import | Importing a module with the same name as the file | ❌ | +| [MR002](rules/branch_expression.md) | branch-expression | Branch statements with output expressions that won't be displayed | ❌ | +| [MR003](rules/reusable_definition_order.md) | reusable-definition-order | Reusable definitions depending on later reusable definitions | ⚠️ | + +### ✨ Formatting Rules + +These are style and formatting issues. + +| Code | Name | Description | Fixable | +|------|------|-------------|----------| +| [MF001](rules/general_formatting.md) | general-formatting | General formatting issues with the notebook format. | 🛠️ | +| [MF002](rules/parse_stdout.md) | parse-stdout | Parse captured stdout during notebook loading | ❌ | +| [MF003](rules/parse_stderr.md) | parse-stderr | Parse captured stderr during notebook loading | ❌ | +| [MF004](rules/empty_cells.md) | empty-cells | Empty cells that can be safely removed. | ⚠️ | +| [MF005](rules/sql_parse_error.md) | sql-parse-error | SQL parsing errors during dependency analysis | ❌ | +| [MF006](rules/misc_log_capture.md) | misc-log-capture | Miscellaneous log messages during processing | ❌ | +| [MF007](rules/markdown_indentation.md) | markdown-indentation | Markdown cells in `mo.md()` should be properly indented. | 🛠️ | + +### 🌐 WASM Rules + +These issues affect WASM/Pyodide compatibility (off by default). + +| Code | Name | Description | Fixable | +|------|------|-------------|----------| +| [MW001](rules/incompatible_import.md) | incompatible-import | Importing a module unavailable in WASM/Pyodide | ❌ | +| [MW002](rules/unsafe_system_call.md) | unsafe-system-call | System call that fails in WASM/Pyodide | ❌ | +| [MW003](rules/incompatible_package.md) | incompatible-package | Package with native extensions not available in Pyodide | ❌ | + +## Legend + +- 🛠️ = Automatically fixable with `marimo check --fix` +- ⚠️ = Fixable with `marimo check --fix --unsafe-fixes` (may change code behavior) +- ❌ = Not automatically fixable + +## Configuration + +Most lint rules are enabled by default. You can configure the linter behavior through marimo's configuration system. + +## Related Documentation + +- [Understanding Errors](../understanding_errors/index.md) - Detailed explanations of common marimo errors +- [CLI Reference](../../cli.md) - Complete CLI documentation including `marimo check` diff --git a/docs/guides/lint_rules/rules/branch_expression.md b/docs/guides/lint_rules/rules/branch_expression.md new file mode 100644 index 00000000000..9594c64cb33 --- /dev/null +++ b/docs/guides/lint_rules/rules/branch_expression.md @@ -0,0 +1,74 @@ +# MR002: branch-expression + +⚠️ **Runtime** ❌ Not Fixable + +MR002: Branch statements with output expressions that won't be displayed. + +## Why is this bad? + +When output expressions are nested inside branches at the end of a cell: +- The expressions execute but produce no visible output +- Users expect to see the result (like mo.md(), string literals, etc.) +- This can lead to confusion about whether code is running correctly +- It violates the principle of least surprise + +This is a runtime issue because it causes unexpected behavior where the user's +intended output is silently ignored. + +## Examples + +**Problematic:** +```python +if condition: + mo.md("Result A") # Won't be displayed +else: + mo.md("Result B") # Won't be displayed +``` + +**Problematic:** +```python +match value: + case 1: + "Too short" # Won't be displayed + case _: + value # Won't be displayed +``` + +**Not flagged:** +```python +if condition: + print("Debug message") # Function calls +``` + +**Solution:** +```python +# Assign to a variable that marimo will display +result = mo.md("Result A") if condition else mo.md("Result B") +result +``` + +**Solution:** +```python +# Create a default variable for response. +result = None +if condition: + result = expr +else: + result = other +result +``` + +**Alternative Solution (if no output intended):** +```python +# Use a dummy variable to indicate intentional suppression +if condition: + _ = expr +else: + _ = other +``` + +## References + +- [Understanding Errors](https://docs.marimo.io/guides/understanding_errors/) +- [Reactivity](https://docs.marimo.io/guides/reactivity/) + diff --git a/docs/guides/lint_rules/rules/cycle_dependencies.md b/docs/guides/lint_rules/rules/cycle_dependencies.md new file mode 100644 index 00000000000..62710700aef --- /dev/null +++ b/docs/guides/lint_rules/rules/cycle_dependencies.md @@ -0,0 +1,47 @@ +# MB003: cycle-dependencies + +🚨 **Breaking** ❌ Not Fixable + +MB003: Cells have circular dependencies. + +## What it does + +Analyzes the dependency graph to detect circular references between cells, +where cells depend on each other in a way that creates an impossible +execution order. + +## Why is this bad? + +Circular dependencies prevent marimo from: +- Determining a valid execution order +- Running notebooks reproducibly +- Executing notebooks as scripts +- Providing reliable reactive updates + +This is a breaking error because it makes the notebook non-executable. + +## Examples + +**Problematic:** +```python +# Cell 1 +a = b + 1 # Reads b + +# Cell 2 +b = a + 1 # Reads a -> Cycle! +``` + +**Solution:** +```python +# Cell 1 +a = 1 + +# Cell 2 +b = a + 1 # Unidirectional dependency +``` + +## References + +- [Cycles Guide](https://docs.marimo.io/guides/understanding_errors/cycles/) +- [Understanding Errors](https://docs.marimo.io/guides/understanding_errors/) + diff --git a/docs/guides/lint_rules/rules/empty_cells.md b/docs/guides/lint_rules/rules/empty_cells.md new file mode 100644 index 00000000000..7e55c64a600 --- /dev/null +++ b/docs/guides/lint_rules/rules/empty_cells.md @@ -0,0 +1,62 @@ +# MF004: empty-cells + +✨ **Formatting** ⚠️ Unsafe Fixable + +MF004: Empty cells that can be safely removed. + +## What it does + +Detects cells that contain only: +- Whitespace characters (spaces, tabs, newlines) +- Comments (lines starting with #) +- Pass statements (`pass`) +- Any combination of the above + +## Why is this bad? + +Empty cells can: +- Create clutter in notebook structure +- Add unnecessary complexity to the execution graph +- Make notebooks harder to read and maintain +- Increase file size without adding value + +While not functionally breaking, removing empty cells improves code +clarity and reduces visual noise. + +## Examples + +**Problematic:** +```python +# Cell 1: Only whitespace +``` + +**Problematic:** +```python +# Cell 2: Only comments +# This is just a comment +# Nothing else here +``` + +**Problematic:** +```python +# Cell 3: Only pass statement +pass +``` + +**Problematic:** +```python +# Cell 4: Mix of comments, whitespace, and pass +# Some comment + +pass +# Another comment +``` + +**Note:** This fix requires `--unsafe-fixes` because removing cells changes +the notebook structure, and potentially removes user-intended content. + +## References + +- [Understanding Errors](https://docs.marimo.io/guides/understanding_errors/) +- [Best Practices](https://docs.marimo.io/guides/best_practices/) + diff --git a/docs/guides/lint_rules/rules/general_formatting.md b/docs/guides/lint_rules/rules/general_formatting.md new file mode 100644 index 00000000000..87ef55ce5a1 --- /dev/null +++ b/docs/guides/lint_rules/rules/general_formatting.md @@ -0,0 +1,59 @@ +# MF001: general-formatting + +✨ **Formatting** 🛠️ Fixable + +MF001: General formatting issues with the notebook format. + +## What it does + +Examines the notebook serialization for structural violations such as: +- Missing or incorrect marimo import statements +- Improperly formatted cell definitions +- Missing app initialization code +- Incorrect file generation metadata + +## Why is this bad? + +Format violations can prevent marimo from properly loading or executing +notebooks. While these don't affect the Python code logic, formatting errors +mark a deviation in the expected script structure, which can lead to +unexpected behavior when run as a script, or when loading the notebook. + +## Examples + +**Problematic:** +```python +# Missing marimo import +@app.cell +def __(): + return + + +if __name__ == "__main__": + app.run() +``` + +**Solution:** +```python +import marimo + +__generated_with = "0.1.0" +app = marimo.App() + + +@app.cell +def __(): + return + + +if __name__ == "__main__": + app.run() +``` + +**Note:** Most format issues are automatically fixable with `marimo check --fix`. + +## References + +- [Understanding Errors](https://docs.marimo.io/guides/understanding_errors/) +- [File Format Documentation](https://docs.marimo.io/guides/coming_from/jupyter/#marimo-file-format) + diff --git a/docs/guides/lint_rules/rules/incompatible_import.md b/docs/guides/lint_rules/rules/incompatible_import.md new file mode 100644 index 00000000000..47bb0087684 --- /dev/null +++ b/docs/guides/lint_rules/rules/incompatible_import.md @@ -0,0 +1,41 @@ +# MW001: incompatible-import + +🌐 **WASM** ❌ Not Fixable + +MW001: Importing modules unavailable in WASM/Pyodide. + +## What it does + +Checks each cell's imports against stdlib modules that don't work in +Pyodide, plus multiprocessing exports and submodules that require shared +memory, managers, pipes, or native synchronization. + +## Why is this bad? + +WASM notebooks run in the browser via Pyodide, which cannot support +modules that depend on OS-level process control, terminal I/O, native +GUI toolkits, shared memory, pipes, or native synchronization. These +imports can raise ImportError or fail at runtime. WASM-compatible +multiprocessing adapters such as `Process`, `Queue`, `SimpleQueue`, +`Pool`, and `ProcessPoolExecutor` remain allowed. + +## Examples + +**Problematic:** +```python +import subprocess + +result = subprocess.run(["ls"]) +``` + +**Problematic:** +```python +from multiprocessing import Pipe +``` + +**Solution:** +Remove the import or replace it with a WASM-compatible alternative. + +## References + +- https://pyodide.org/en/stable/usage/wasm-constraints.html diff --git a/docs/guides/lint_rules/rules/incompatible_package.md b/docs/guides/lint_rules/rules/incompatible_package.md new file mode 100644 index 00000000000..b1d088063bf --- /dev/null +++ b/docs/guides/lint_rules/rules/incompatible_package.md @@ -0,0 +1,52 @@ +# MW003: incompatible-package + +🌐 **WASM** ❌ Not Fixable + +MW003: Packages in the dependency tree incompatible with WASM. + +## What it does + +Reads the notebook's PEP 723 `dependencies`, **filters out requirements whose +PEP 508 markers exclude Emscripten** (for example +`torch; sys_platform != 'emscripten'`), walks the transitive dependency tree +via installed metadata, then queries PyPI's JSON API to check whether each +package has a `py3-none-any`, `emscripten`, or `wasm32` wheel available. +Packages only in pyodide-lock.json are also accepted. + +## Why is this bad? + +Pyodide can only install pure-Python wheels via micropip, or packages +that are pre-built in the Pyodide distribution. Packages with only +platform-specific native wheels will fail to install in the browser. + +## Examples + +**Problematic:** +```python +import jax # jaxlib (transitive dep) has only native wheels +``` + +**Not flagged:** +```python +import numpy # Native, but pre-built in Pyodide +``` + +**Not flagged:** +```python +import requests # Pure Python wheel on PyPI +``` + +**Not flagged (PEP 723 metadata):** +```python +# /// script +# dependencies = ["jax; sys_platform != 'emscripten'"] +# /// +# jax is excluded from WASM checks via PEP 508 marker +``` + +## References + +- https://pyodide.org/en/stable/usage/packages-in-pyodide.html +- https://peps.python.org/pep-0508/ (environment markers) +- https://peps.python.org/pep-0783/ (Emscripten wheels on PyPI) + diff --git a/docs/guides/lint_rules/rules/invalid_syntax.md b/docs/guides/lint_rules/rules/invalid_syntax.md new file mode 100644 index 00000000000..acb0309f804 --- /dev/null +++ b/docs/guides/lint_rules/rules/invalid_syntax.md @@ -0,0 +1,62 @@ +# MB005: invalid-syntax + +🚨 **Breaking** ❌ Not Fixable + +MB005: Cell contains code that throws a SyntaxError on compilation. + +## What it does + +Attempts to compile each cell using marimo's internal compiler and catches any +SyntaxError exceptions that occur during the compilation process. + +## Why is this bad? + +Cells with syntax errors cannot be executed, making the notebook non-functional. +SyntaxErrors prevent marimo from creating the dependency graph and running the +reactive execution system, breaking the core functionality of the notebook. + +## Examples + +**Problematic:** +```python +# Invalid indentation +if True: +print("Hello") # Missing indentation +``` + +**Problematic:** +```python +# Invalid syntax +x = 1 + # Missing operand +``` + +**Problematic:** +```python +# Mismatched brackets +my_list = [1, 2, 3 # Missing closing bracket +``` + +**Solution:** +```python +# Fix indentation +if True: + print("Hello") # Proper indentation +``` + +**Solution:** +```python +# Complete expressions +x = 1 + 2 # Complete arithmetic expression +``` + +**Solution:** +```python +# Match brackets +my_list = [1, 2, 3] # Proper closing bracket +``` + +## References + +- [Understanding Errors](https://docs.marimo.io/guides/understanding_errors/) +- [Python SyntaxError Documentation](https://docs.python.org/3/tutorial/errors.html#syntax-errors) + diff --git a/docs/guides/lint_rules/rules/markdown_indentation.md b/docs/guides/lint_rules/rules/markdown_indentation.md new file mode 100644 index 00000000000..69bec221d5d --- /dev/null +++ b/docs/guides/lint_rules/rules/markdown_indentation.md @@ -0,0 +1,49 @@ +# MF007: markdown-indentation + +✨ **Formatting** 🛠️ Fixable + +MF007: Markdown strings in `mo.md()` should be properly indented. + +## What it does + +Checks cells containing `mo.md()` calls to see if the markdown string +content has unnecessary leading whitespace that should be removed. + +## Why is this bad? + +Indented markdown strings: + +- Are harder to read when viewing the source code +- Produce larger diffs when making changes +- Don't match the standard marimo formatting style +- Can be confusing when the indentation doesn't reflect the markdown structure + +## Examples + +**Problematic:** +```python +mo.md( + r""" + # Title + + Some content here. + """ +) +``` + +**Solution:** +```python +mo.md(r""" +# Title + +Some content here. +""") +``` + +**Note:** This fix is automatically applied with `marimo check --fix`. + +## References + +- [Understanding Errors](https://docs.marimo.io/guides/understanding_errors/) +- [Best Practices](https://docs.marimo.io/guides/best_practices/) + diff --git a/docs/guides/lint_rules/rules/misc_log_capture.md b/docs/guides/lint_rules/rules/misc_log_capture.md new file mode 100644 index 00000000000..36bc73bfab5 --- /dev/null +++ b/docs/guides/lint_rules/rules/misc_log_capture.md @@ -0,0 +1,30 @@ +# MF006: misc-log-capture + +✨ **Formatting** ❌ Not Fixable + +MF006: Miscellaneous log messages during processing. + +## What it does + +Captures warning and error level log messages that aren't handled by +other specific log rules and creates diagnostics to surface them. + +## Why is this bad? + +Unhandled log messages may indicate: +- Unexpected issues during notebook processing +- Configuration problems +- Library warnings that affect execution +- Performance or resource issues + +## Examples + +**Triggered by:** +- General warnings from imported libraries +- Configuration issues +- Unexpected errors during processing + +## References + +- [Understanding Errors](https://docs.marimo.io/guides/understanding_errors/) + diff --git a/docs/guides/lint_rules/rules/multiple_definitions.md b/docs/guides/lint_rules/rules/multiple_definitions.md new file mode 100644 index 00000000000..2a6c3b4ec55 --- /dev/null +++ b/docs/guides/lint_rules/rules/multiple_definitions.md @@ -0,0 +1,46 @@ +# MB002: multiple-definitions + +🚨 **Breaking** ❌ Not Fixable + +MB002: Multiple cells define the same variable. + +## What it does + +Analyzes the dependency graph to detect variables that are defined in more +than one cell, which violates marimo's fundamental constraint for reactive execution. + +## Why is this bad? + +Multiple definitions prevent marimo from: +- Determining the correct execution order +- Creating a reliable dependency graph +- Running notebooks as scripts +- Providing consistent reactive updates + +This is a breaking error because it makes the notebook non-executable. + +## Examples + +**Problematic:** +```python +# Cell 1 +x = 1 + +# Cell 2 +x = 2 # Error: x defined in multiple cells +``` + +**Solution:** +```python +# Cell 1 +x = 1 + +# Cell 2 +y = 2 # Use different variable name +``` + +## References + +- [Multiple Definitions Guide](https://docs.marimo.io/guides/understanding_errors/multiple_definitions/) +- [Understanding Errors](https://docs.marimo.io/guides/understanding_errors/) + diff --git a/docs/guides/lint_rules/rules/parse_stderr.md b/docs/guides/lint_rules/rules/parse_stderr.md new file mode 100644 index 00000000000..7b7db085265 --- /dev/null +++ b/docs/guides/lint_rules/rules/parse_stderr.md @@ -0,0 +1,42 @@ +# MF003: parse-stderr + +✨ **Formatting** ❌ Not Fixable + +MF003: Parse captured stderr during notebook loading. + +## What it does + +Captures stderr output during notebook loading and creates diagnostics +from any error messages or warnings. This helps identify potential +issues that don't prevent parsing but may affect runtime behavior. + +## Why is this bad? + +Stderr output during parsing often indicates: +- Syntax warnings (like invalid escape sequences) +- Import warnings or errors +- Deprecation notices from libraries +- Configuration issues that might affect execution + +While these don't break the notebook, they can lead to unexpected +behavior or indicate code that needs updating. + +## Examples + +**Captured stderr:** +``` +notebook.py:68: SyntaxWarning: invalid escape sequence '\l' +``` + +**Result:** Creates a diagnostic pointing to line 68 about the invalid escape sequence. + +**Common issues:** +- Raw strings needed: `r"\path\to\file"` instead of `"\path\to\file"` +- Deprecated library usage +- Missing import dependencies + +## References + +- [Understanding Errors](https://docs.marimo.io/guides/understanding_errors/) +- [Python Warning Categories](https://docs.python.org/3/library/warnings.html#warning-categories) + diff --git a/docs/guides/lint_rules/rules/parse_stdout.md b/docs/guides/lint_rules/rules/parse_stdout.md new file mode 100644 index 00000000000..6a2c191a39f --- /dev/null +++ b/docs/guides/lint_rules/rules/parse_stdout.md @@ -0,0 +1,33 @@ +# MF002: parse-stdout + +✨ **Formatting** ❌ Not Fixable + +MF002: Parse captured stdout during notebook loading. + +## What it does + +Captures and parses stdout output during notebook loading, looking for +structured warning messages that include file and line number references. +Creates diagnostics from any warnings or messages found. + +## Why is this bad? + +While stdout output doesn't prevent execution, it often indicates: +- Deprecation warnings from imported libraries +- Configuration issues +- Potential compatibility problems +- Code that produces unexpected side effects during import + +## Examples + +**Captured stdout:** +``` +notebook.py:15: DeprecationWarning: 'imp' module is deprecated +``` + +**Result:** Creates a diagnostic pointing to line 15 with the deprecation warning. + +## References + +- [Understanding Errors](https://docs.marimo.io/guides/understanding_errors/) + diff --git a/docs/guides/lint_rules/rules/reusable_definition_order.md b/docs/guides/lint_rules/rules/reusable_definition_order.md new file mode 100644 index 00000000000..a088f9e9167 --- /dev/null +++ b/docs/guides/lint_rules/rules/reusable_definition_order.md @@ -0,0 +1,91 @@ +# MR003: reusable-definition-order + +⚠️ **Runtime** ⚠️ Unsafe Fixable + +MR003: Invalid ordering of potentially reusable definitions. + +## What it does + +marimo serializes reusable definitions in notebook order. Like all python +scripts, a reusable function cannot refer to a variable that has _not yet +been defined_. While ordering in marimo normally doesn't matter, for reuse +as a module or script, dependent top level definitions must be ordered +correctly. + +This rule flags and fixes function or class definitions that would normally +be saved as "reusable", but cannot due to cell ordering. + +## Why is this bad? + +When a reusable definition depends on another reusable definition declared +later in the notebook: + +- the definition cannot be serialized as reusable +- imports from other notebooks or Python modules may fail + +## Examples + +**Problematic:** +```python +@app.function +def uses_offset(x: int = offset()) -> int: + # This will run in marimo, but will cause an error if run as a script! + # `offset` is not defined! + return x + 1 + + +@app.function +def offset() -> int: + return 1 +``` + +**Problematic:** +```python +@app.cell +def _(): + # This could be reusable if it was defined after `decorate`. + class Wrapped: + @decorate + def value(self) -> int: + return 1 + + +@app.function +def decorate(fn): + return fn +``` + +## References + +- [Understanding Errors](https://docs.marimo.io/guides/understanding_errors/) +- [setup](https://docs.marimo.io/guides/understanding_errors/setup/) +- [Reusing functions](https://docs.marimo.io/guides/reusing_functions/) + +## Solution + +Move the referenced reusable definitions earlier in the notebook so they +appear before the reusable definition that depends on them. + +```python +@app.function +def offset() -> int: + return 1 + + +@app.function +def uses_offset(x: int = offset()) -> int: + return x + 1 +``` + +## Unsafe fix + +This rule can be fixed with: + +```bash +marimo check --fix --unsafe-fixes my_notebook.py +``` + +The unsafe fix reorders the provider cells earlier in the notebook. This +is marked unsafe because changing cell order changes the document +structure, even when the resulting notebook is still valid. + diff --git a/docs/guides/lint_rules/rules/self_import.md b/docs/guides/lint_rules/rules/self_import.md new file mode 100644 index 00000000000..dbea92060aa --- /dev/null +++ b/docs/guides/lint_rules/rules/self_import.md @@ -0,0 +1,50 @@ +# MR001: self-import + +⚠️ **Runtime** ❌ Not Fixable + +MR001: Importing a module with the same name as the file. + +## What it does + +Analyzes import statements in each cell to detect cases where the imported +module name matches the current file's name (without the .py extension). + +## Why is this bad? + +Importing a module with the same name as the file causes several issues: +- Python may attempt to import the current file instead of the intended module +- This can lead to circular import errors or unexpected behavior +- It makes the code confusing and hard to debug +- It can prevent the notebook from running correctly + +This is a runtime issue because it can cause import confusion and unexpected behavior. + +## Examples + +**Problematic (in a file named `requests.py`):** +```python +import requests # Error: conflicts with file name +``` + +**Problematic (in a file named `math.py`):** +```python +from math import sqrt # Error: conflicts with file name +``` + +**Solution:** +```python +# Rename the file to something else, like my_requests.py +import requests # Now this works correctly +``` + +**Alternative Solution:** +```python +# Use a different approach that doesn't conflict +import urllib.request # Use alternative library +``` + +## References + +- [Understanding Errors](https://docs.marimo.io/guides/understanding_errors/) +- [Python Import System](https://docs.python.org/3/reference/import.html) + diff --git a/docs/guides/lint_rules/rules/setup_cell_dependencies.md b/docs/guides/lint_rules/rules/setup_cell_dependencies.md new file mode 100644 index 00000000000..c007d20633a --- /dev/null +++ b/docs/guides/lint_rules/rules/setup_cell_dependencies.md @@ -0,0 +1,45 @@ +# MB004: setup-cell-dependencies + +🚨 **Breaking** ❌ Not Fixable + +MB004: Setup cell cannot have dependencies. + +## What it does + +Validates that the setup cell (if present) does not depend on variables +defined in other cells, ensuring proper execution order. + +## Why is this bad? + +Setup cell dependencies break marimo's execution model because: +- The setup cell must run first to initialize the notebook +- Dependencies on other cells would create impossible execution order +- It violates the setup cell's purpose as initialization code + +This is a breaking error because it makes the notebook non-executable. + +## Examples + +**Problematic:** +```python +# Setup cell +y = x + 1 # Error: setup depends on other cells + +# Cell 1 +x = 1 +``` + +**Solution:** +```python +# Setup cell +y = 1 # Setup defines its own variables + +# Cell 1 +x = y + 1 # Other cells can use setup variables +``` + +## References + +- [Setup References Guide](https://docs.marimo.io/guides/understanding_errors/setup/) +- [Understanding Errors](https://docs.marimo.io/guides/understanding_errors/) + diff --git a/docs/guides/lint_rules/rules/sql_parse_error.md b/docs/guides/lint_rules/rules/sql_parse_error.md new file mode 100644 index 00000000000..eb7c05bdfde --- /dev/null +++ b/docs/guides/lint_rules/rules/sql_parse_error.md @@ -0,0 +1,31 @@ +# MF005: sql-parse-error + +✨ **Formatting** ❌ Not Fixable + +MF005: SQL parsing errors during dependency analysis. + +## What it does + +Captures SQL parsing error logs and creates diagnostics pointing to +problematic SQL statements in cells. + +## Why is this bad? + +SQL parsing failures can lead to: +- Incorrect dependency analysis for SQL-using cells +- Missing dataframe references in dependency graph +- Reduced effectiveness of reactive execution +- Potential runtime errors when SQL is executed + +## Examples + +**Triggered by:** +- Invalid SQL syntax in cell code +- Unsupported SQL dialects or extensions +- Complex SQL that exceeds parser capabilities + +## References + +- [Understanding Errors](https://docs.marimo.io/guides/understanding_errors/) +- [SQL Support](https://docs.marimo.io/guides/sql/) + diff --git a/docs/guides/lint_rules/rules/unparsable_cells.md b/docs/guides/lint_rules/rules/unparsable_cells.md new file mode 100644 index 00000000000..a4fd0fb78dc --- /dev/null +++ b/docs/guides/lint_rules/rules/unparsable_cells.md @@ -0,0 +1,43 @@ +# MB001: unparsable-cells + +🚨 **Breaking** ❌ Not Fixable + +MB001: Cell contains unparsable code. + +## What it does + +Identifies cells that cannot be parsed into valid Python AST nodes, indicating +fundamental syntax or encoding problems that prevent the notebook from being loaded. + +## Why is this bad? + +Unparsable cells prevent the notebook from running as a script and will throw +errors when executed in notebook mode. While marimo can still open the notebook, +these cells cannot be run until the parsing issues are resolved. + +## Examples + +**Problematic:** +```python +# Cell with encoding issues or corrupt data +x = 1 \x00\x01\x02 # Binary data in source +``` + +**Problematic:** +```python +# Cell with fundamental syntax errors +def func( + # Missing closing parenthesis and body +``` + +**Solution:** +```python +# Fix syntax errors and encoding issues +def func(): + return 42 +``` + +## References + +- [Understanding Errors](https://docs.marimo.io/guides/understanding_errors/) + diff --git a/docs/guides/lint_rules/rules/unsafe_system_call.md b/docs/guides/lint_rules/rules/unsafe_system_call.md new file mode 100644 index 00000000000..4adbf2e9e19 --- /dev/null +++ b/docs/guides/lint_rules/rules/unsafe_system_call.md @@ -0,0 +1,46 @@ +# MW002: unsafe-system-call + +🌐 **WASM** ❌ Not Fixable + +MW002: System calls that fail in WASM/Pyodide. + +## What it does + +Walks the AST of each cell looking for calls to functions like +`os.system()`, `os.fork()`, `signal.signal()`, `multiprocessing.Pipe()`, +and `breakpoint()` that have no meaningful implementation in WASM. + +## Why is this bad? + +These functions depend on OS features (process spawning, signal +handling, debugger attachment, unsupported multiprocessing +synchronization, or IPC) that don't exist in a browser environment. +They will raise `OSError`, `NotImplementedError`, or hang silently. + +## Examples + +**Problematic:** +```python +import os + +os.system("ls") +``` + +**Problematic:** +```python +breakpoint() +``` + +**Problematic:** +```python +import multiprocessing + +multiprocessing.Pipe() +``` + +**Solution:** +Remove or guard these calls behind a WASM detection check. + +## References + +- https://pyodide.org/en/stable/usage/wasm-constraints.html diff --git a/docs/guides/molab.md b/docs/guides/molab.md new file mode 100644 index 00000000000..8169de6282e --- /dev/null +++ b/docs/guides/molab.md @@ -0,0 +1,392 @@ +# Run in the cloud with molab + +[molab](https://molab.marimo.io/notebooks) is a free cloud-hosted marimo notebook +environment. Notebooks run on powerful compute, with generous CPU, +RAM, and GPUs. molab is designed for sharing and is integrated with +GitHub; notebooks are public but undiscoverable by default. + +!!! tip "Contribute to our community gallery!" + We welcome submissions to our curated [community gallery](https://marimo.io/gallery?tag=community). + To propose an example, share your notebook on socials and [tag us](publishing/public_gallery.md). + + +**Highlights**. + +- ☁️ [Run on powerful compute](#compute), including NVIDIA GPUs +- 🤖 [Pair with coding agents](#work-with-ai) like Claude Code, or use built-in AI assistance +- 🪞 [Mirror notebooks from GitHub](#mirror-notebooks-from-github), with GitHub as the source of truth +- 🔗 Share [open-in-molab badges](#share-open-in-molab-badges) +- 🎞️ [Share as interactive slides or data apps](#share-as-slides-or-apps) +- 🌐 [Embed interactive notebooks](#embed-in-other-webpages) in your own webpages +- 📦 [Install packages](#package-management) with a built-in package manager +- 🛢️ Use a limited amount of [persistent storage](#storage) per notebook +- 📄 Export to PDF +- 📥 Run notebooks [on your machine](#run-notebooks-locally) + +## Compute + +molab notebooks run on cloud compute, giving you the full power of Python on a +traditional server. By default, each notebook runs with **4 CPUs and 32 GB of +RAM**. + +**GPUs.** You can attach an NVIDIA RTX Pro 6000 Blackwell GPU — with 96 GB of +VRAM and 125 TFLOPS — to any notebook. Toggle the GPU by clicking the notebook +specs button in the app header. GPUs make it possible to finetune open-source +models, train and run modern ML workloads, and tackle compute-intensive +problems in the physical sciences, all from your browser. + +
+ +
+ +

Toggle GPUs by clicking the notebook specs button in the app header.

+ +**Long-running sessions.** Notebooks can run for as long as 12 hours before +molab shuts them down. Notebooks that are idle for more than 90 minutes are +automatically shut down. + +**Fast startups.** molab notebooks are containers running marimo, preloaded +with popular packages for AI, ML, and computational science. They start up in +just a few seconds, so you can start coding right away. + +## Work with AI + +You can bring AI coding tools to bear on molab notebooks in two ways: control +notebooks with your favorite agent, or use built-in AI assistance (or both). + +**Pair with coding agents.** Connect your favorite coding agent — such as Claude +Code, Codex, or OpenCode — to a running molab notebook using +[marimo pair](https://links.marimo.app/marimo-pair). Install the skill locally, +then copy the prompt that appears when you select "Pair with an agent" from the +notebook menu. marimo pair turns your notebook into a collaborative canvas you +share with your agent: it can do anything you can — write code, install +packages, manipulate UI widgets — and more. + +**Built-in AI assistance.** The marimo editor has AI features integrated +throughout, including the ability to refactor existing cells and generate new +ones. In molab, these features are powered by free access to fast open-source +models, so you can generate code without bringing your own API key. + +## Sharing + +To share notebooks created in molab, just share the notebook's URL. Viewers +will see a static preview of your notebook and the option to fork it into their +own workspace. + +### Mirror notebooks from GitHub + +From the molab user interface, you can add notebooks hosted on GitHub. These +"synced" notebooks use GitHub as their source of truth: develop locally, push +your changes, and see them reflected in molab automatically. Synced notebooks +can be previewed statically, run on an ephemeral server, or run in the browser +(and embedded in web pages) with WebAssembly. + +To create a synced notebook, use the new notebook dropdown button on the molab homepage, and paste the URL of a notebook hosted on GitHub. + +
+ Screenshot of the molab dialog for creating a synced notebook from a GitHub URL +
+ +

Use GitHub as the source of truth for notebooks by +creating synced notebooks.

+ +This lets you add the notebook to your workspace. It also gives you sharing +links (and a snippet for an open-in-molab badge) so others can view the +notebook on molab, run it on an ephemeral server, or fork it into their own +workspace. + +
+ Screenshot of a synced molab notebook showing sharing links and an open-in-molab badge snippet +
+ +

Add mirrored notebooks to your workspace or share them as links or badges.

+ +#### Static previews + +> See our [gallery examples](https://github.com/marimo-team/gallery-examples) repository for best practices on previewing notebooks from GitHub. + +By default, notebooks mirrored from GitHub show a static preview of the +notebook; from the static preview page, users can start the notebook on an ephemeral server +or fork it into their workspace with a single click. + +In order for your static preview to include outputs, you must +commit the notebook's corresponding session JSON file, in the `__marimo__/session/` +directory that exists alongside the notebook. The session is automatically generated when +you run the notebook from the marimo editor; you can also generate the session +from the command-line with `marimo export session notebook.py` + +#### WebAssembly previews + +To share a link to a live executable notebook hosted on GitHub, append `/wasm` +to the URL or navigate to `Preview > WebAssembly`: + +
+ Screenshot of the molab Preview dropdown with options for Preview, Code, WebAssembly, and Server +
+ +

Switch between static, WebAssembly, and server-backed previews from the Preview dropdown.

+ +WebAssembly previews run [via Pyodide](wasm.md), so not all notebooks will +work; however, like static previews, they don't require the viewer to login. +They can also be embedded in iframes. + +### Share open-in-molab badges + +Share links to molab notebooks using our open in molab badge: + +[![Open in molab](https://marimo.io/molab-shield.svg)](https://molab.marimo.io/github/marimo-team/gallery-examples/blob/main/notebooks/math/cellular-automaton-art.py) + +Use the following markdown snippet (replace the notebook URL with a link to your own notebook): + +```markdown +[![Open in molab](https://marimo.io/molab-shield.svg)](https://molab.marimo.io/github/marimo-team/gallery-examples/blob/main/notebooks/math/cellular-automaton-art.py) +``` + +### Share as slides or apps + +Every notebook can be shared as interactive slides or a data app: + +- To share as a **data app**, choose "Run as app" from the share button on any + running notebook. +- To share as **slides**, toggle the app preview of a notebook, then select + "slides" from the view dropdown near the top of the page. + +### Discover notebooks from the community + +The molab landing page includes a **Discover** tab showcasing notebooks from +the community. Share your notebooks on socials and [tag us](publishing/public_gallery.md) +for a chance to be featured. + +## Embed in other webpages + +You can embed interactive molab notebooks in your own webpages using iframes. +Obtain iframe snippets by clicking the share button on WebAssembly [molab previews +of GitHub notebooks](#webassembly-previews): + +
+ Screenshot of the molab share dropdown on a WebAssembly preview, with a Copy Embed Iframe option +
+ +

Copy an embeddable iframe snippet from the share menu of a WebAssembly preview.

+ +This yields a snippet like the one below: + +/// tab | Code + +```html + +``` + +/// + +/// tab | Live Example + +
+ +
+ +/// + +Embedded notebooks run in the browser via WebAssembly, so your notebook must be +[WebAssembly-compatible](wasm.md). We also recommend creating these notebooks +[with `--sandbox`](package_management/inlining_dependencies.md) to make sure +their dependencies get installed. + + +### Embed from source code + +You can embed a notebook directly from its source code, without hosting it on +GitHub. Compress your notebook code using lz-string and pass it as a URL hash: + +``` +https://molab.marimo.io/new/wasm/?embed=true#code/{compressed} +``` + +/// tab | JavaScript + +```javascript +import { compressToEncodedURIComponent } from "lz-string"; + +const url = `https://molab.marimo.io/new/wasm/?embed=true#code/${compressToEncodedURIComponent(code)}`; +``` + +/// + +/// tab | Python + +```python +import lzstring + +lz = lzstring.LZString() +compressed = lz.compressToEncodedURIComponent(code) +url = f"https://molab.marimo.io/new/wasm/?embed=true#code/{compressed}" +``` + +/// + +For example: + +/// tab | Code + +```html + +``` + +/// + +/// tab | Live Example + +
+ +
+ +/// + +Query parameters go before the hash: + +``` +https://marimo.app/?embed=true#code/{compressed} +``` + +For small notebooks (under 14 KB), you can also use the `code` query parameter +with URI encoding instead of lz-compression: + +``` +https://marimo.app/new/wasm/?embed=true&code={encodedURIComponent} +``` + + +### Embed an empty editable notebook + +Use this recipe to embed an empty editable notebook: + +/// tab | Code + +```html + +``` + +/// + +/// tab | Live Example + +
+ +
+ +/// + +### Query parameters + +| Parameter | Default | Description | +| --- | --- | --- | +| `embed=true` | `false` | Hides the molab header for a cleaner embed | +| `mode=edit` | `read` | Allows viewers to edit code cells | +| `include-code=false` | `true` | Excludes code entirely (only works with `mode=read`) | +| `show-code=false` | `true` | Hides code by default, but viewers can still reveal it | + + +## Other features + +### Package management + +Each notebook runs in an environment with several popular packages +pre-installed, including torch, numpy, polars, and more. marimo’s built-in +package manager will install additional packages as you import them (use the +package manager sidebar panel to install specific package versions). + +### Storage + +Notebooks get a limited amount of persistent storage; view the file tree by +clicking the file icon in the sidebar. From here you can upload additional data +files. + +### Run notebooks locally + +You can download the notebook directory by clicking the download button, also +on the top-right. You can also just pass the notebook URL to marimo edit. For +example: + +```bash +marimo edit https://molab.marimo.io/notebooks/nb_TWVGCgZZK4L8zj5ziUBNVL +``` + +Currently, this brings just the notebook file down, and does not include your attached storage. + +## FAQ + +**What’s the difference between molab and Google Colab?** Google Colab is a +hosted Jupyter notebook service provider. molab is a hosted [marimo +notebook](https://github.com/marimo-team/marimo) service with more compute +and sharing capabilities, and powered by marimo notebooks instead of Jupyter. +Unlike Colab, molab also supports embedding interactive notebooks in your own +webpages and sharing notebooks as apps and slides, no login required for readers. + +**Is molab free?** Yes. + +**What compute do I get?** By default, each notebook runs with 4 CPUs and 32 GB +of RAM. You can attach an NVIDIA RTX Pro 6000 Blackwell GPU (96 GB VRAM) to any +notebook using the notebook specs button in the app header. See +[Compute](#compute) for details. + +**Can I use GPUs?** Yes. Attach a GPU to any notebook from the notebook specs +button in the app header. If you need additional compute beyond what's offered, +[reach out to us](https://marimo.io/discord). + +**Does molab have built-in AI?** Yes. The marimo editor's AI features are +available in molab with free access to fast open-source models, and you can +also connect your own coding agent with [marimo pair](#work-with-ai). + +**How does molab relate to marimo’s open source notebook?** molab is a hosted +offering of marimo’s open source notebook with cloud-based compute and sharing +capabilities. You can use marimo open source on your own machine or on your own remote +servers. + +**How does molab relate to marimo’s WebAssembly playground?** The [WebAssembly playground](https://marimo.app) runs notebooks entirely in the browser through [Pyodide](https://pyodide.org/en/stable/). This makes for a snappy user experience, at the cost of limited compute and limited support for Python packages. The playground is well-suited for lightweight notebooks and embedding interactive notebooks in documentation, but it is not well-suited for modern ML or AI workflows. molab bridges the gap: develop notebooks with the full power of Python running on a traditional server, and (when compatible) share interactive previews using WebAssembly, which others can fork and develop further using a server-backed notebook. diff --git a/docs/guides/outputs.md b/docs/guides/outputs.md index e90edc5e96d..43f75688743 100644 --- a/docs/guides/outputs.md +++ b/docs/guides/outputs.md @@ -7,7 +7,8 @@ documented in the [API reference](../api/index.md).
- +
@@ -63,21 +64,93 @@ mo.md( ### Markdown editor -marimo automatically renders cells that only use `mo.md("")`, without an -`f`-string, in a markdown editor that supports common hotkeys. - -Because the Markdown editor doesn't support f-strings, you'll need to use -`mo.md` directly to interpolate Python values into your Markdown. You can -switch between the Markdown and Python editors by clicking the button in the -top right. +marimo automatically renders cells that only use `mo.md` in a markdown editor +that supports common hotkeys. You can switch between the Markdown and Python +editors by clicking the button in the top right:
- +
marimo is pure Python, even when you're using markdown.
+**Writing LaTeX.** The markdown editor supports writing LaTeX. You should typically +use a raw string for markdown with LaTeX, which you can activate by checking the `r` +box in the bottom-right corner of the markdown editor. + +**Interpolating Python values.** Interpolating Python values requires using an +`f`-string, which you can activate by checking the `f` box in the bottom-right +corner of the markdown editor. + +### Markdown extensions +#### Details + +Create expandable details with additional context: + +```markdown +/// details | Heads up + +Here's some additional context. +/// +``` + +/// marimo-embed-file + filepath: examples/markdown/details.py +/// + + +#### Admonitions + +Highlight text using *admonitions*: + +```markdown +/// attention | This is important. + +Pay attention to this text! +/// +``` + +An admonition, also known as an alert or a callout, is a Markdown extension +for visually highlighting important information. In marimo, admonitions +are displayed with special colors to convey significance. + +/// marimo-embed-file + filepath: examples/markdown/admonitions.py +/// + +For a programmatic alternative to admonitions, use [`mo.callout`][marimo.callout]. + +#### Emoji + +Use `:emoji:` syntax to add emojis; for example, `:rocket:` creates 🚀. + +### Static files + +marimo supports serving static files from a `public/` folder located next to your notebook. This is useful for including images or other static assets in your notebook. + +To use files from the public folder, create a `public` directory next to your notebook and reference files using the `public/` path prefix: + +```python +mo.md( + ''' + + + or + + ![alt text](public/image.png) + ''' +) +``` + +For security reasons: + +- Only files within the `public` directory can be accessed +- Symlinks are not followed +- Path traversal attempts (e.g., `../`) are blocked + + ## Layout The marimo library also comes with elements for laying out outputs, including @@ -103,6 +176,14 @@ for i in mo.status.progress_bar(range(10)): marimo comes with functions to display media, including images, audio, video, pdfs, and more. See the [API docs](../api/media/index.md) for more info. +## Inspecting objects + +marimo has built-in formatters for many objects, but sometimes the default +representation isn't useful (e.g., ``). In these cases, use +[`mo.inspect()`][marimo.inspect] to explore an object's attributes, methods, +and documentation as an output. See the [API +docs](../api/outputs.md#object-inspection) for more details. + ## Imperatively adding outputs While a cell's output is its last expression, it can at times be helpful @@ -133,6 +214,28 @@ To create a thread that can reliably communicate outputs to the frontend, use [`mo.Thread`][marimo.Thread], which has exactly the same API as as `threading.Thread`. +### Cleaning up your thread + +When the cell that spawned a [`mo.Thread`][marimo.Thread] is invalidated +(re-run, deleted, interrupted, or otherwise errored), the thread's +`should_exit` property will evaluate to `True`, at which point it is your +responsibility to clean up your thread. You can retrieve the current +[`mo.Thread`][marimo.Thread] with [`mo.current_thread`][marimo.current_thread]. + +**Example.** + +```python +def target(): + import marimo as mo + + thread = mo.current_thread() + while not thread.should_exit: + ... +``` + + +### Patching threads created by third-party code + If you need to forward outputs from threads spawned by third-party code, try patching `threading.Thread`: @@ -142,3 +245,6 @@ import marimo as mo threading.Thread = mo.Thread ``` + +This however may leak threads, since the patched threads won't know to check the `mo.Thread`'s +`should_exit` property. diff --git a/docs/guides/package_management/importing_packages.md b/docs/guides/package_management/importing_packages.md new file mode 100644 index 00000000000..a70e8bd4fe3 --- /dev/null +++ b/docs/guides/package_management/importing_packages.md @@ -0,0 +1,187 @@ +# Importing packages + +By default, marimo searches for packages in the [virtual +environment](https://docs.python.org/3/tutorial/venv.html) it was started in. +For example, if you run + +```console +source /path/to/venv/bin/activate +marimo edit my_notebook.py +``` + +you'll be able to import packages installed in the environment you just +activated. You'll also be able to install packages into this environment using +the marimo editor's [package management UI](installing_packages.md). + +!!! question "Using uv?" + + See our [uv guide](using_uv.md) for details on how to use marimo as part of + uv projects or as self-contained scripts. + +## Inlining dependencies + +As an alternative to manually creating and managing a virtual environment, +you can let marimo manage virtual environments for you, on a per-notebook +basis. + +If you create a notebook with the `--sandbox` flag — + +```console +marimo edit --sandbox my_notebook.py +``` + +— marimo will start your notebook in an isolated environment and keep track of +the dependencies you install from the editor. These dependencies are inlined in +the notebook file, so that the next time you run the notebook, +marimo will run it in an isolated environment with just those dependencies. + +See our guide on [inlining dependencies](inlining_dependencies.md) +to learn more. + +!!! question "What about kernels?" + + Unlike Jupyter, marimo does not have a concept of "kernels"; notebooks simply + use the active virtual environment. + + The main feature of kernels is to allow different notebooks to depend on + different packages, even within the same project. marimo's package sandbox + provides this functionality, while also being far simpler to use than + custom kernels. + + +## Importing local modules + +marimo resolves imports just as Python does: by searching for packages +in the directories listed in `sys.path`. That means that in addition to the +virtual environment, marimo will search for modules in the directory in which +the notebook lives. For example, when you run + +```console +marimo edit /path/to/notebook_dir/notebook.py +``` + +marimo will look for modules in `/path/to/notebook_dir`. However, this means +that you may need to take additional steps to import modules that live outside +this directory. What steps you take depends on whether your code is organized +as a Python package. + +!!! tip "Remember: notebooks are just Python programs" + + In the examples below, notebooks are stored in a separate notebooks + directory, which is traditional. However, since marimo notebooks are just + Python modules, you can just as well include them in your `src/` directory + alongside other Python modules. + + +### From non-package projects + +You can configure the Python path to accommodate directory structures that look like this: + +``` +. +├── notebooks +│   └── my_notebook.py +├── pyproject.toml +└── src + └── my_module.py +``` + +In particular, to make `import my_module` work when running + +/// tab | edit + +```console +marimo edit notebooks/my_notebook.py +``` + +/// + +/// tab | run + +```console +marimo run notebooks/my_notebook.py +``` + +/// + +/// tab | script + +```console +python notebooks/my_notebook.py +``` + +/// + +add the following configuration to your `pyproject.toml`: + +```toml title="pyproject.toml" +[tool.marimo.runtime] +pythonpath = ["src"] +``` + +### From packages + +!!! question "New to Python packages?" + + A [Python package](https://docs.python.org/3/tutorial/modules.html#packages) is a + way of structuring Python source files so that the collection of files can + be installed in an environment, imported using "dot" notation like + `from my_package.my_module import my_function`, and optionally uploaded + to package registries like PyPI. + + If you are new to packages, and find you need to create one, we recommend + using [uv](https://docs.astral.sh/uv/) (`uv init --package`). + +A package has a directory structure like this: + +``` +. +├── notebooks +│   └── my_notebook.py +├── pyproject.toml +└── src + └── my_package + ├── __init__.py + └── my_module.py +``` + +Say `my_notebook` has a cell with + +```python +from my_package import my_module +``` + +Provided that + +/// tab | edit + +```console +marimo edit notebooks/my_notebook.py +``` + +/// + +/// tab | run + +```console +marimo run notebooks/my_notebook.py +``` + +/// + +/// tab | script + +```console +python notebooks/my_notebook.py +``` + +/// + +is run from an environment in which your package is installed, marimo +will import `my_module` without issue. + +For example, if you are using `uv`, simply run + +```console +uv run --with marimo marimo edit notebooks/my_notebook.py +``` diff --git a/docs/guides/package_management/index.md b/docs/guides/package_management/index.md new file mode 100644 index 00000000000..f118b941152 --- /dev/null +++ b/docs/guides/package_management/index.md @@ -0,0 +1,12 @@ +# Package management + +The following guides cover how to import, install, and otherwise manage +the Python dependencies of your notebooks. + +| Guide | Description | +| ----------------------------------------------------- | ------------------------------------------------------------ | +| [Importing packages](importing_packages.md) | How marimo finds packages on import | +| [Installing packages](installing_packages.md) | Installing packages with marimo's UI | +| [Inlining dependencies](inlining_dependencies.md) | Create self-contained notebooks by inlining dependencies in notebook files | +| [Notebooks in existing projects](notebooks_in_projects.md) | Working with marimo notebooks in existing Python projects | +| [Using uv](using_uv.md) | A guide to using the uv package manager with marimo | diff --git a/docs/guides/package_management/inlining_dependencies.md b/docs/guides/package_management/inlining_dependencies.md new file mode 100644 index 00000000000..f57b5d015d1 --- /dev/null +++ b/docs/guides/package_management/inlining_dependencies.md @@ -0,0 +1,280 @@ +# Inlining dependencies + +marimo is the only Python notebook that is reproducible down to the packages, +letting you inline Python dependencies in notebook files and running notebooks +in isolated or "sandboxed" venvs. This lets you share standalone notebooks +without shipping `requirements.txt` files alongside them, and guarantees your +notebooks will work weeks, months, even years into the future. + +To opt-in to dependency inlining, use the `sandbox` flag: + +=== "edit" + + ```bash + marimo edit --sandbox notebook.py + ``` + +=== "run" + + ```bash + marimo run --sandbox notebook.py + ``` + +=== "new" + + ```bash + marimo new --sandbox + ``` + +When running with `--sandbox`, marimo: + +1. tracks the packages and versions used by your notebook, saving + them in the notebook file; +2. runs in an isolated virtual environment ("sandbox") that only + contains the notebook dependencies. + +marimo's sandbox provides two key benefits. (1) Notebooks that carry their own +dependencies are easy to share — just send the `.py` file. (2) Isolating a +notebook from other installed packages prevents obscure bugs. + +!!! tip "Sandboxed Home" + + You can also use `--sandbox` when editing a folder of notebooks. Each + notebook gets its own isolated environment. See + [Sandboxed Home](../editor_features/home.md#sandboxed-home). + +You can also run sandboxed notebooks as scripts: + +```console +uv run notebook.py +``` + +!!! note "Requires uv" + + Sandboxed notebooks require the uv package manager + ([installation + instructions](https://docs.astral.sh/uv/getting-started/installation/)). + +!!! tip "Solving the notebook reproducibility crisis" + + marimo's support for package sandboxing is only possible because marimo + notebooks are stored as pure Python files, letting marimo take advantage + of new Python standards like [PEP + 723](https://peps.python.org/pep-0723/) and tools like uv. In contrast, + traditional notebooks like Jupyter are stored as JSON files, and which suffer + from a [reproducibility + crisis](https://leomurta.github.io/papers/pimentel2019a.pdf) due to the lack + of package management. + +## Inline script metadata { #auto-tracking-inline-script-metadata } + +When running with `--sandbox`, marimo automatically tracks package metadata in +your notebook file using inline script metadata, which per [PEP +723](https://peps.python.org/pep-0723/) is essentially a pyproject.toml inlined +as the script's header. This metadata is used to manage the +notebook's dependencies and Python version, and looks something like this: + +```python +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "pandas==", +# "altair==", +# ] +# /// +``` + +!!! example "Example notebooks" + + The [example + notebooks](https://github.com/marimo-team/marimo/tree/main/examples) in our + GitHub repo were all created using `--sandbox`. Take a look at any of them + for an example of the full script metadata. + +### Adding and removing packages + +**Using the marimo editor.** When you import a module in the marimo editor, if +marimo detects that it is a third-party package, it will automatically be added +to the script metadata. Removing an import does _not_ remove it from the script +metadata (since library code may still use the package). + +Adding packages via the package manager panel will also add packages to script +metadata, and removing packages from the panel will in turn remove them from +the script metadata. + +**Adding packages manually.** You can manually manage your notebook's +requirements: + +* edit the script metadata manually in an editor like VS Code or neovim. +* use `uv` from the command-line: + +```console +uv add --script notebook.py numpy +``` + +```console +uv remove --script notebook.py numpy +``` + +### Package locations + +By default, marimo will look for packages on PyPI. You can edit the script +metadata to look for packages elsewhere, such as on GitHub. Consult the [Python +packaging +documentation](https://packaging.python.org/en/latest/specifications/dependency-specifiers/#examples) +for more information. + +### Local development with editable installs + +When developing a local package, you can install it in editable mode using the +`[tool.uv.sources]` section in the script metadata. For example: + +```python +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "my-package", +# ] +# +# [tool.uv.sources] +# my-package = { path = "../", editable = true } +# /// +``` + +This is particularly useful when you want to test changes to your package +without reinstalling it. The package will be installed in "editable" mode, +meaning changes to the source code will be reflected immediately in your +notebook. + +### Specifying alternative package indexes + +When you need to use packages from a custom PyPI server or alternative index, +you can specify these in your script metadata using the `[[tool.uv.index]]` +section. This is useful for private packages or when you want to use packages +from a specific source. + +```python +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "pandas==", +# "private-package==", +# ] +# +# [[tool.uv.index]] +# name = "custom-index" +# url = "https://custom-pypi-server.example.com/simple/" +# explicit = true +# +# [tool.uv.sources] +# private-package = { index = "custom-index" } +# /// +``` + +In this example: + +- `[[tool.uv.index]]` defines a custom package index +- `name` is an identifier for the index +- `url` points to your custom PyPI server +- `explicit = true` means this index will only be used for packages explicitly associated with it +- `[tool.uv.sources]` specifies which packages should come from which indexes + +This approach ensures that specific packages are always fetched from your designated custom index, while other packages continue to be fetched from the default PyPI repository. + +### Platform-specific dependencies (PEP 508) { #platform-specific-dependencies-pep-508 } + +When a notebook runs both locally and as a [WebAssembly notebook](../wasm.md), +you can attach [PEP 508](https://peps.python.org/pep-0508/) environment markers +to individual dependencies. On Pyodide, `sys.platform` is `"emscripten"`. + +```python +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "pandas==", +# "torch==; sys_platform != 'emscripten'", +# "pyodide-http; sys_platform == 'emscripten'", +# ] +# /// +``` + +- `sys_platform != 'emscripten'` — install locally, skip in the browser. +- `sys_platform == 'emscripten'` — install only for WebAssembly / Pyodide. + +uv evaluates these markers in sandboxes. marimo also respects them when +pre-installing packages in WASM, when [exporting to WASM +HTML](../exporting/webassembly_html.md), and in the MW003 lint rule for +incompatible packages. + +Combine markers with `and` when needed: + +```python +# dependencies = [ +# "pyzmq>=27.1.0; python_version < '3.15' and sys_platform != 'emscripten'", +# ] +``` + +For package authors publishing WASM wheels, see [PEP +783](https://peps.python.org/pep-0783/) (`pyemscripten_*_wasm32` tags). + +## Configuration + +Running marimo in a sandbox environment uses `uv` to create an isolated virtual +environment. You can use any of `uv`'s [supported environment +variables](https://docs.astral.sh/uv/configuration/environment/). + +### Choosing the Python version + +For example, you can specify the Python version using the `UV_PYTHON` environment variable: + +```bash +UV_PYTHON=3.13 marimo edit --sandbox notebook.py +``` + +### Other common configuration + +Another common configuration is `uv`'s link mode: + +```bash +UV_LINK_MODE="copy" marimo edit --sandbox notebook.py +``` + +## Sharing on the web + +You can also upload sandboxed notebooks to the web, such as on GitHub, and have +others run them locally with a single command: + +``` +uvx marimo edit --sandbox https://gist.githubusercontent.com/kolibril13/a59135dd0973b97d488ba21c650667fe/raw/5f98021b5d3c024d5827fa9464787517495178b4/marimo_minimal_numpy_example.py +``` + +**Note:** + +1. This command will run code from a URL. Make sure you trust the source before proceeding. +2. Upon execution, you’ll be prompted: + ``` + Would you like to run it in a secure docker container? [Y/n]: + ``` + To proceed securely, ensure you have [Docker](https://www.docker.com/) installed and running, then press `Y`. + + + +## Specifying dependencies in Markdown files + +Sandboxing support is also provided in [marimo's markdown file +format](../editor_features/watching.md#as-markdown) under the +`pyproject` entry of your frontmatter. + +```markdown +--- +title: My Notebook +marimo-version: 0.0.0 +pyproject: | + requires-python: ">=3.11" + dependencies: + - pandas== + - altair== +--- +``` + + diff --git a/docs/guides/package_management/installing_packages.md b/docs/guides/package_management/installing_packages.md new file mode 100644 index 00000000000..ec28acacd82 --- /dev/null +++ b/docs/guides/package_management/installing_packages.md @@ -0,0 +1,22 @@ +# Installing packages + +marimo supports package management for `pip`, `uv`, `poetry`, `pixi`, and +`rye`. When marimo comes across a module that is not installed, you will be +prompted to install it using your preferred package manager. Once the module is +installed, all cells that depend on the module will be rerun +(or marked as stale). + +You can also install (and remove) packages using the package manager sidebar panel. + +!!! note "Resolving package names" + + We use a heuristic for guessing the package name in your registry (e.g. PyPI) from the module name. It is possible that the package name is different from the module name. If you encounter an error, please [file an issue](https://github.com/marimo-team/marimo/issues) or help us by adding your mapping [directly to the codebase](https://github.com/marimo-team/marimo/blob/main/marimo/_runtime/packages/module_name_to_pypi_name.py). + +**Notes.** + +* When using imperative style package managers like `pip`, packages are installed directly +in the active virtual environment. +* When using `uv`, marimo will decide whether to add it to your `pyproject.toml` +(if running as part of a uv project), or whether to install it imperatively with `uv pip` (otherwise). +* When running in a [package sandbox](inlining_dependencies.md), package installation +and removal also updates the notebook's inline dependencies. diff --git a/docs/guides/package_management/notebooks_in_projects.md b/docs/guides/package_management/notebooks_in_projects.md new file mode 100644 index 00000000000..fe38cea557a --- /dev/null +++ b/docs/guides/package_management/notebooks_in_projects.md @@ -0,0 +1,183 @@ +# Notebooks in existing projects + +When working with notebooks in existing projects, there are two main approaches +depending on your needs: + +1. **Sandbox notebooks** - Self-contained notebooks with isolated dependencies +2. **Project notebooks** - Notebooks that are part of your project's environment + +marimo uses [PEP 723](https://peps.python.org/pep-0723/) inline script metadata +for sandboxing, managed by uv. While sandboxing is currently exclusive to the +uv package manager, other package managers may be supported in the future. + +For project notebooks, marimo can be added as a project dependency where all +notebooks share the same environment defined in `pyproject.toml`. This approach +works with uv and other package managers (Poetry, Pixi, Hatch, etc.). + +## Sandbox notebooks (recommended for libraries) + +Sandbox notebooks use inline script metadata ([PEP +723](https://peps.python.org/pep-0723/)) to create isolated environments. This +is ideal when: + +- Building examples for a library that users can run independently +- Creating notebooks that don't share dependencies with your main project +- Sharing self-contained notebooks that work anywhere + +### Basic sandbox notebook + +Sandbox notebooks can be created with: + +```bash +marimo edit --sandbox notebook.py +``` + +When working in the notebook, marimo will automatically manage PEP 723 metadata +for you. This metadata makes the notebook self-contained, meaning you can +either come back later with marimo or run the notebook as a script directly +with uv: + +```bash +marimo edit --sandbox notebook.py # automatically loads deps and launches marimo +uv run notebook.py # run notebook as a script +``` + +### Developing against your local package + +When developing library examples, tutorials, or exploratory code, it's often +useful to have notebooks that _use_ your library. In these cases, you can +create and version sandboxed notebooks with your library as an _editable_ +install. + +This approach lets you: + +- Test your library changes immediately without reinstalling +- Add notebook-specific dependencies (like visualization or data processing tools) without polluting your library's requirements +- Create self-contained examples that users can run without your development dependencies + +To add your library to a notebook, use uv: + +```bash +uv add --script notebooks/notebook.py . --editable +``` + +This will produce a header that looks like: + +```python +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "my-package", +# "pandas", +# "matplotlib", +# ] +# +# [tool.uv.sources] +# my-package = { path = "../", editable = true } +# /// +``` + +## Project notebooks + +For notebooks that are integral to your project, you can manage everything +through your project's `pyproject.toml`. This approach uses a single +environment shared between your project and notebooks. + +### Adding marimo to your project + +```toml +[project] +name = "my-project" +dependencies = [ + "numpy", + "requests", +] + +[dependency-groups] +dev = [ + "marimo", + "pytest", +] +``` + +Then work with notebooks using your project's environment: + +```bash +# Using uv +uv run marimo edit notebooks/analysis.py + +# Or activate the environment +source .venv/bin/activate +marimo edit notebooks/analysis.py +``` + +This approach: +- Uses a single environment for everything +- Shares dependencies between notebooks and your project +- Follows standard Python project practices + +!!! note "Importing from other directories" + If your notebooks need to import modules from directories outside your project, marimo supports configuring the Python path via `pyproject.toml`. However, when possible, it's preferred to avoid path manipulation. We recommend creating a package (`uv init --lib`) and including marimo as a development dependency. For multiple packages, consider configuring [uv workspaces](https://docs.astral.sh/uv/concepts/workspaces/). See the [runtime configuration guide](../configuration/runtime_configuration.md#python-path) for details. + +## Examples + +### Library with example notebooks + +When building a library, use sandbox notebooks for examples that users can run +independently: + +``` +my-library/ +├── pyproject.toml # Library dependencies +├── src/ +│ └── my_library/ +└── examples/ + ├── quickstart.py # Sandbox notebook + └── advanced.py # Sandbox notebook +``` + +Create example notebooks: +```bash +# Initialize the project +uv init --lib my-library && cd my-library + +# Add marimo as development dependency +uv add --dev marimo + +# Create a sandbox notebook +mkdir examples +uv run marimo edit --sandbox examples/quickstart.py + +# Add your library as editable dependency +uv add --script examples/quickstart.py . --editable +``` + +### Data science project + +When notebooks are part of your analysis workflow, use project notebooks: + +``` +analysis-project/ +├── pyproject.toml # Project + marimo dependencies +├── README.md +├── main.py # Created by uv init +└── notebook.py # Your marimo notebook +``` + +Set up the project: +```bash +# Initialize project +uv init analysis-project && cd analysis-project + +# Add marimo as a project dependency +uv add marimo pandas scikit-learn + +# Edit notebooks using project environment +uv run marimo edit notebook.py +``` + +## Related guides + +- [Using uv](using_uv.md) - Detailed guide on uv with marimo +- [Inlining dependencies](inlining_dependencies.md) - More on self-contained notebooks +- [Package management overview](index.md) - General package management in marimo diff --git a/docs/guides/package_management/using_uv.md b/docs/guides/package_management/using_uv.md new file mode 100644 index 00000000000..592fd315e67 --- /dev/null +++ b/docs/guides/package_management/using_uv.md @@ -0,0 +1,173 @@ +# Using uv + +[uv](https://docs.astral.sh/uv/) is an extremely fast Python package and +project manager: you can use it to install packages, manage the dependencies +of Python projects, and run scripts. While marimo supports all major package +managers, it integrates especially tightly with uv. In particular, marimo's +package sandbox feature, which lets you [inline +dependencies](inlining_dependencies.md) in notebook files, requires uv. + +!!! note "No prior knowledge required" + + This guide teaches you the basics of using `uv` with marimo. It assumes zero + familiarity with `uv`. + +You can manage your notebooks' dependencies in three different ways: + +1. inline dependencies: [inlining dependencies](inlining_dependencies.md) in notebook files, using `marimo edit --sandbox notebook.py` +2. projects: using a `uv` project , which define dependencies declaratively in a `pyproject.toml` + file; +3. non-project environment: dependencies are imperatively installed + +We'll walk through each of these three ways in this guide. + +## Using inline dependencies + +The easiest way to get started is to use marimo's [package sandbox +feature](inlining_dependencies.md), which manages your dependencies for you. +Create or edit your notebook with [`uvx` +command](https://docs.astral.sh/uv/concepts/tools/#the-uv-tool-interface), +making sure to include the `--sandbox` flag: + + + +```console +uvx marimo edit --sandbox my_notebook.py +``` + +This command installs marimo in a temporary environment, activates it, then +runs your marimo notebook. The `--sandbox` flag is what tells marimo to keep +track of your dependencies and store them in the notebook file. If there are +any dependencies already tracked in the file, this command will download +them and install them in the environment. + +Run sandboxed notebooks as scripts with + +```console +uv run my_notebook.py +``` + +### From URLs + +You can also upload sandboxed notebooks to the web, such as on GitHub, and have others run +them locally with a single command: + +``` +uvx marimo edit --sandbox https://gist.githubusercontent.com/kolibril13/a59135dd0973b97d488ba21c650667fe/raw/5f98021b5d3c024d5827fa9464787517495178b4/marimo_minimal_numpy_example.py +``` + +**Note:** + +1. This command will run code from a URL. Make sure you trust the source before proceeding. +2. Upon execution, you’ll be prompted: + ``` + Would you like to run it in a secure docker container? [Y/n]: + ``` + To proceed securely, ensure you have [Docker](https://www.docker.com/) installed and running, then press `Y`. + + +To learn more, read our full guide on using [inline dependencies](inlining_dependencies.md). + +## Using uv projects + +A [`uv` project](https://docs.astral.sh/uv/guides/projects/) is a directory in which you can store Python code, including +notebooks, alongside a pyproject.toml file that declares the project's +dependencies. + +### Creating a project + +Create a project with `uv init`: + +```console +uv init hello-world +cd hello-world +``` + +!!! tip "Starter template" + + Get started quickly by cloning our [starter template](https://github.com/marimo-team/marimo-uv-starter-template). + + +This creates a pyproject.toml and some starter code. + +Next, add marimo to your project: + +```console +uv add marimo +``` + +??? note "Omitting marimo from your project" + + Adding marimo to your project is optional. Instead, you can + run marimo in a temporary environment that has access to + your project's dependencies using `uv run --with marimo marimo edit`. + +### Running marimo + +Once you've added marimo, use the `uv run` command +to run the version of marimo installed in your project: + +```console +uv run marimo edit my_notebook.py +``` + +Starting marimo in this way will let marimo import any of the packages +installed in your project. + +**Scripts.** Run marimo notebooks as scripts with + +```console +uv run my_notebook.py +``` + +which will run your notebook in an environment containing your project dependencies. + +### Adding and removing dependencies + +#### Using the uv command-line + +Use `uv add` to add dependencies: + +```console +uv add numpy +``` + +You can also specify a version + +```console +uv add numpy==2.26 +``` + +Remove packages with `uv remove`: + +```console +uv remove numpy +``` + +#### Using the marimo editor + +If you started marimo with `uv run marimo edit`, the marimo editor's [package +management features](installing_packages.md) will add and remove packages from +your pyproject.toml, so there's no need to use the `uv` command-line if you +don't want to. + +## Using marimo in a non-project environment + +If you are used to a venv and pip based workflow, you can use the `uv venv` and +`uv pip` commands for a similar but more performant experience: + +* `uv venv` creates a virtual environment in the current directory, at `.venv` +* `uv pip` lets you install and uninstall packages in the venv + +### Example + + +```console +$ uv venv +$ uv pip install numpy +$ uv pip install marimo +$ uv run marimo edit +``` + +From here, `import numpy` will work within the notebook, and marimo's UI installer will add +packages to the environment with `uv pip install` on your behalf. diff --git a/docs/guides/publishing/cloudflare.md b/docs/guides/publishing/cloudflare.md new file mode 100644 index 00000000000..319a7685e70 --- /dev/null +++ b/docs/guides/publishing/cloudflare.md @@ -0,0 +1,97 @@ +# Publish to Cloudflare + +You can publish executable notebooks to [Cloudflare Workers](https://workers.cloudflare.com/) +for free, after exporting your notebook to a WebAssembly notebook. + +## Export to WASM-powered HTML + +Export your notebook to a self-contained HTML file that runs using [WebAssembly](../wasm.md) with the flag `--include-cloudflare`: + +/// tab | Export as a readonly app + +```bash +marimo export html-wasm notebook.py -o output_dir --mode run --include-cloudflare +``` + +/// + +/// tab | Export as an editable notebook + +```bash +marimo export html-wasm notebook.py -o output_dir --mode edit --include-cloudflare +``` + +/// + +See our [exporting guide](../exporting/webassembly_html.md) for +the full documentation. + +## Publish to a Cloudflare Worker + +When you use the `--include-cloudflare` flag, marimo creates two additional files in the parent directory of your output directory: + +- `index.js`: A simple Cloudflare Worker script that serves your static assets +- `wrangler.jsonc`: Configuration for Cloudflare's Wrangler CLI + +To run locally, run: + +```bash +npx wrangler dev +``` + +To deploy to Cloudflare, run: + +```bash +npx wrangler deploy +``` + +/// admonition | Need authentication or custom endpoints? + type: tip + +You can modify the `index.js` to include authentication or custom endpoints. This allows you to: + +- Add authentication logic to protect your notebook +- Create API endpoints that serve data from the same domain, avoiding CORS issues + +/// + +## Publish to Cloudflare Pages using GitHub + +As an alternative to Cloudflare Workers, you can publish to Cloudflare Pages. To get started, create a new GitHub repository by visiting [repo.new](https://repo.new/) . After creating a new repository, go to your newly created project directory to prepare and push your local application to GitHub by running the following commands in your terminal: + +```bash +cd output_dir +git init +git remote add origin https://github.com// +git add . +git commit -m "Initial commit" +git branch -M main +git push -u origin main + +``` + +To deploy your site to Pages: + +1. Log in to the Cloudflare [Dashboard](https://dash.cloudflare.com) and select your account. +2. In Account Home, select Workers & Pages > Create application > Pages > Connect to Git. +3. Select the new GitHub repository that you created and, in the Set up builds and deployments section, provide the following information: + +``` +Project name output-dir +Production branch main +Framework preset None +Build command (optional) exit 0 +Build output directory / +``` + +4. Save and Deploy + +## Publish to Cloudflare Pages Manually + +To deploy your site to Pages: + +1. Create zip of the folder "output_dir" +2. Log in to the Cloudflare [Dashboard](https://dash.cloudflare.com) and select your account. +3. In Account Home, select Workers & Pages > Create application > Pages > Upload asset. +4. Enter a project name then click Upload and select output_dir.zip . +5. Save and Deploy diff --git a/docs/guides/publishing/community_cloud/index.md b/docs/guides/publishing/community_cloud/index.md new file mode 100644 index 00000000000..5a307fdeb0b --- /dev/null +++ b/docs/guides/publishing/community_cloud/index.md @@ -0,0 +1,16 @@ +# Community Cloud + +Our [Community Cloud](https://marimo.io/dashboard) is a free workspace +for creating, saving, and sharing marimo notebooks. Unlike the +[Playground](../playground.md), the Community Cloud requires a login. In +return, it lets you save noteoboks, share them using email-based authorization, +and upload a limited amount of data. + +!!! note "WebAssembly notebooks only" + + Currently, the Community Cloud only allows the creation of [WebAssembly + notebooks](../../wasm.md). These are easy to share and embed in other + web pages, but have some limitations in packages and performance. + +Note: unlike our other publishing options, it is not possible to embed +editable Community Cloud notebooks in other web pages. diff --git a/docs/guides/publishing/embedding.md b/docs/guides/publishing/embedding.md new file mode 100644 index 00000000000..0c4c9e8d04f --- /dev/null +++ b/docs/guides/publishing/embedding.md @@ -0,0 +1,124 @@ +# Embed in other webpages + +There are various ways to embed marimo notebooks in other webpages, such +as web documentation, educational platforms, or static sites in general. + +## molab (recommended) + +The easiest way to embed interactive notebooks is with [molab](../molab.md). +Click the "Share" button on molab previews of [notebooks from +GitHub](../molab.md/#mirror-notebooks-from-github) to get an iframe snippet +you can copy into your own webpages; or, construct embeddable URLs +[from notebook source code](../molab.md#embed-from-source-code). This is what +we do throughout this documentation website. + +See the [molab embedding docs](../molab.md#embed-in-other-webpages) for a +complete walkthrough, and the below for a a simple example. + +/// tab | Code + +```html + +``` + +/// + +/// tab | Live Example + +
+ +
+ +/// + + +## Self-hosted options + +If you need to self-host, you can also embed notebooks by: + +* Hosting on [GitHub Pages](github.md#publish-to-github-pages) or [self-hosting WASM HTML](self_host_wasm.md), + and iframing the published notebook. +* Using [marimo islands](../exporting/webassembly_html.md#embed-marimo-outputs-in-html-using-islands) to embed individual cell outputs directly in your HTML pages. + +## Iframe sandbox configuration + +When embedding marimo notebooks in sandboxed iframes, proper configuration is essential for full functionality. marimo is designed to gracefully degrade when certain features are restricted, but understanding these requirements will help you provide the best experience. + +### Required sandbox attributes + +For marimo to function properly in an iframe, you need this **minimum** sandbox attribute: + +```html + +``` + +* **`allow-scripts`**: Required for JavaScript execution (essential for marimo to run) + +!!! note "Basic Functionality" + With only `allow-scripts`, marimo will work but with limitations: WebSocket connections will function, but storage will be in-memory only (state resets on page reload), and clipboard access will use browser prompts instead of the clipboard API. + +### Recommended sandbox attributes + +For the best user experience, include these additional attributes: + +```html + +``` + +**Additional Attributes:** + +* **`allow-same-origin`**: Enables persistent storage (localStorage) and full clipboard API. Only use this if you trust the content of the iframe or the iframe URL is hosted on a different domain. +* **`allow-downloads`**: Enables downloading notebook outputs, data exports, and screenshots +* **`allow-popups`**: Allows opening links and notebooks in new tabs +* **`allow-forms`**: Enables form submissions, required for interactive UI elements like `mo.ui.form` +* **`allowfullscreen`** (attribute, not sandbox): Enables fullscreen mode for slides and outputs + +**Permission Policy:** + +* **`allow="microphone"`**: Required for `mo.ui.microphone()` widget functionality + +!!! tip "Security Considerations" + Only use `allow-same-origin` with trusted content or the iframe URL is hosted on a different domain. Combining `allow-scripts` and `allow-same-origin` allows the iframe to remove the sandbox attribute entirely, making the iframe as powerful as if it weren't sandboxed at all. + +### Full example + +A complete, copy-pasteable example with all recommended settings: + +```html + +``` diff --git a/docs/guides/publishing/github.md b/docs/guides/publishing/github.md new file mode 100644 index 00000000000..38af4070c5a --- /dev/null +++ b/docs/guides/publishing/github.md @@ -0,0 +1,177 @@ +# GitHub + +marimo makes it very easy to share links to executable notebooks from notebooks +hosted on GitHub. + +- [Share molab links](../molab.md#mirror-notebooks-from-github) to obtain interactive previews of notebooks hosted on GitHub, no login required +- Publish notebooks to [GitHub Pages](#publish-to-github-pages) + +## Share previews of notebooks hosted on GitHub + +You can share previews of any marimo notebook hosted on GitHub using +[molab](../molab.md); these previews are publicly viewable, no login required. +Simply replace `github.com` in your notebook's GitHub URL with +`molab.marimo.io/github` to create a shareable preview link. For example: + +``` +https://github.com/marimo-team/gallery-examples/blob/main/notebooks/math/cellular-automaton-art.py +``` + +becomes + +``` +https://molab.marimo.io/github/marimo-team/gallery-examples/blob/main/notebooks/math/cellular-automaton-art.py +``` + +Previews are **static** by default. To make them **interactive**, append +`/wasm` to the URL (the notebook must be [WebAssembly-compatible](../wasm.md)). + +To include outputs in static previews, commit the notebook's session JSON file (in the `__marimo__/session/` directory alongside the notebook). Generate it with: + +```bash +marimo export session notebook.py +``` + +You can also share links using our open-in-molab badge. For example: + +```markdown +[![Open in molab](https://marimo.io/molab-shield.svg)](https://molab.marimo.io/github/marimo-team/gallery-examples/blob/main/notebooks/math/cellular-automaton-art.py/wasm) +``` + +becomes + +[![Open in molab](https://marimo.io/molab-shield.svg)](https://molab.marimo.io/github/marimo-team/gallery-examples/blob/main/notebooks/math/cellular-automaton-art.py/wasm) + + +Visit [molab.marimo.io/github](https://molab.marimo.io/github) to automatically +generate preview URLs and badges from GitHub links. + +For full details on previewing, embedding, and sharing, see the [molab guide](../molab.md#mirror-notebooks-from-github). + +## Export to ipynb to view on GitHub + +marimo notebooks are stored as pure Python files, which works well with Git +versioning and the broader Python ecosystem. However, this means you cannot +preview outputs directly on GitHub like you can with Jupyter notebooks. + +To make outputs viewable on GitHub, you can configure marimo to automatically +snapshot outputs to an `ipynb` file. We treat the ipynb as an artifact that +combines your source code with rendered outputs. The snapshot is saved to a +`__marimo__` directory alongside your notebook, which you can commit and push +to GitHub. + +Enable snapshotting in the notebook settings menu via the gear icon in the top +right corner: + + + + Notebook settings dialog showing the Exporting outputs section with HTML and ipynb checkboxes + + +## Render live previews of notebooks with our browser extension + +Install the marimo glance browser extension for [Chrome](https://chromewebstore.google.com/detail/marimo-glance/emnkplkdlpojjembfbkdagibhmippjfg) or +[Firefox](https://addons.mozilla.org/en-US/firefox/addon/marimo-glance/) to see +live previews of notebooks on GitHub and GitLab. The extension detects marimo +notebooks and adds a button to the bottom right of the page, that, with one +click, replaces the raw Python file with an interactive notebook you can run, +edit, and explore. + +marimo glance runs notebooks [with WebAssembly](../wasm.md), so not all notebooks will work. +To run the notebook on a server, click "Open in molab" in the bottom +left of the notebook. + +**Your code stays local.** The notebook source is packed into the page URL’s +fragment and handed straight to the in-browser WebAssembly runtime. Browsers +never send that fragment to any server, so your code — private repositories +included — is never uploaded by the extension. The one exception is if you +deliberately choose “Open in molab” from inside the notebook, which sends your +code to molab to run and share there. + + +## Publish to GitHub Pages + +> For a simpler solution, use [molab's built in GitHub previewer](../molab.md#mirror-notebooks-from-github) + +You can publish executable notebooks to [GitHub Pages](https://pages.github.com/) +for free, after exporting your notebook to a WebAssembly notebook. + +### Export to WASM-powered HTML + +Export your notebook to a self-contained HTML file that runs using [WebAssembly](../wasm.md): + +/// tab | Export as a readonly app + +```bash +marimo export html-wasm notebook.py -o output_dir --mode run +``` + +/// + +/// tab | Export as an editable notebook + +```bash +marimo export html-wasm notebook.py -o output_dir --mode edit +``` + +/// + +See our [exporting guide](../exporting/webassembly_html.md) for +the full documentation. + +### Publish using GitHub Actions + +/// tip | Template repository + +Fork our [template repository](https://github.com/marimo-team/marimo-gh-pages-template) for deploying multiple notebooks to GitHub Pages. Once you have forked the repository, add your notebooks to the `notebooks` or `apps` directories, +for editable or readonly respectively. +/// + +Publish to GitHub Pages using the following GitHub Actions workflow, +which will republish your notebook on git push. + +```yaml +jobs: + build: + runs-on: ubuntu-latest + + steps: + # ... checkout and install dependencies + + - name: 📄 Export notebook + run: | + marimo export html-wasm notebook.py -o path/to/output --mode run + + - name: 📦 Upload Pages Artifact + uses: actions/upload-pages-artifact@v3 + with: + path: path/to/output + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + permissions: + pages: write + id-token: write + + steps: + - name: 🌐 Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + with: + artifact_name: github-pages +``` + +### Publish manually + +You can also publish an exported notebook manually through your repository +settings. Read [GitHub's documentation](https://docs.github.com/en/pages/getting-started-with-github-pages/configuring-a-publishing-source-for-your-github-pages-site) to learn more. + +Make sure to [include a `.nojekyll` +file](https://github.blog/news-insights/bypassing-jekyll-on-github-pages/) in +root folder from which your site is built to prevent GitHub from interfering +with your site. diff --git a/docs/guides/publishing/index.md b/docs/guides/publishing/index.md new file mode 100644 index 00000000000..430c0b7a8b3 --- /dev/null +++ b/docs/guides/publishing/index.md @@ -0,0 +1,29 @@ +# Publish to the web + +You can publish [exported](../exporting/index.md) marimo notebooks to the web. + +## molab (recommended) + +The easiest way to publish and share interactive notebooks is with +[molab](https://molab.marimo.io/notebooks), our free cloud-hosted notebook environment. +With molab, you can: + +- Share notebooks by links +- [Preview notebooks from GitHub](../molab.md#mirror-notebooks-from-github) with full interactivity +- Share [open-in-molab badges](../molab.md#share-open-in-molab-badges) in READMEs and docs +- [Embed interactive notebooks](../molab.md#embed-in-other-webpages) in your own webpages +- Contribute to our [public gallery](public_gallery.md) + +## Self-hosted options + +If you need to self-host exported notebooks, the following guides cover publishing with +[WebAssembly](../wasm.md) — no backend infrastructure required. + +| Guide | Description | +| ----------------------------------------------------- | ------------------------------------------------------------ | +| [GitHub](github.md) | Share from GitHub, view outputs, publish to GitHub Pages | +| [Embed in other webpages](embedding.md) | Embed notebooks in other sites via iframes or islands | +| [Cloudflare](cloudflare.md) | Publish interactive notebooks on Cloudflare | +| [Self-host WebAssembly notebooks](self_host_wasm.md) | Self-hosting interactive WebAssembly (HTML export) notebooks | +| [OpenGraph previews](opengraph.md) | Configure titles, descriptions, and thumbnails for link previews | +| [Thumbnails](thumbnails.md) | Generate thumbnail images for OpenGraph previews and galleries | diff --git a/docs/guides/publishing/mkdocs.md b/docs/guides/publishing/mkdocs.md new file mode 100644 index 00000000000..2697b4ed6ad --- /dev/null +++ b/docs/guides/publishing/mkdocs.md @@ -0,0 +1,10 @@ +# Publishing with MkDocs + +[MkDocs](https://www.mkdocs.org/) is a widely used static site generator that uses plain markdown files as page inputs. + +You can use MkDocs to publish your marimo notebooks as a interactive website with marimo's [MkDocs extension](https://github.com/marimo-team/mkdocs-marimo). + +!!! tip "Try the markdown file format tutorial" + Learn more with `marimo tutorial markdown-format` at the command line. + +Alternatively, see our [Quarto guide](../exporting/quarto.md) for other methods of publishing marimo's markdown format. diff --git a/docs/guides/publishing/opengraph.md b/docs/guides/publishing/opengraph.md new file mode 100644 index 00000000000..99939438ba9 --- /dev/null +++ b/docs/guides/publishing/opengraph.md @@ -0,0 +1,118 @@ +# OpenGraph previews + +marimo can attach OpenGraph metadata to notebooks for: + +- previews in the `marimo run ` [gallery](../apps.md#gallery) +- link previews (OpenGraph tags in the notebook HTML) + +You configure this metadata in your notebook file using inline script metadata (PEP 723), under `[tool.marimo.opengraph]`. + +## Configure metadata + +Add an `[tool.marimo.opengraph]` section to your script metadata: + +```python +# /// script +# [tool.marimo.opengraph] +# title = "My notebook" +# description = "An interactive marimo app." +# /// +``` + +Available fields: + +- `title`: Title used in previews (defaults to a title-cased filename). +- `description`: Short description shown in previews. +- `image`: Optional thumbnail image. Must be either: + - an HTTPS URL, or + - a notebook-relative path under `__marimo__/` (for example, `__marimo__/assets/my_notebook/opengraph.png`). + +If you generate thumbnails to the default location (see below), you typically do not need to set `image`: marimo will automatically use `__marimo__/assets//opengraph.png` when it exists. + +!!! note "Relative image paths" + + For security, marimo only serves notebook-relative `image` paths from the notebook's `__marimo__/` directory. + +## Thumbnails + +If you do not provide an `image`, marimo can still serve a deterministic placeholder thumbnail. If a screenshot-based thumbnail exists at the default location, marimo will automatically use it. + +The default thumbnail location is: + +``` +__marimo__/assets//opengraph.png +``` + +To generate these thumbnails, use: + +```bash +marimo export thumbnail notebook.py +marimo export thumbnail folder/ +``` + +For execution and sandbox options (and for Playwright installation instructions), see [Thumbnails](thumbnails.md). + +## Dynamic metadata + +For dynamic previews, you can provide a generator function in script metadata. Dynamic metadata executes Python while the server resolves previews, so enable it only for notebooks you trust. Run `marimo run --execute-opengraph-generators folder/`, or pass `execute_opengraph_generators=True` to `marimo.create_asgi_app()`. + +Generators must be [top-level functions](../reusing_functions.md). They run outside normal cell execution, so values defined in regular cells are unavailable at metadata resolution time. + +In script metadata, point `generator` at the name of a function defined in the notebook: + +```python +# /// script +# [tool.marimo.opengraph] +# description = "This description is static." +# generator = "generate_opengraph" +# /// +``` + +Then define the generator in a notebook cell: + +/// marimo-embed +```python +@app.function +def generate_opengraph(context, parent): + import datetime as dt + from pathlib import Path + from urllib.parse import quote_plus + + # Merge behavior: we return `title` and `image`, so the static `description` stays intact (it's already in `parent`). + stem = Path(context.filepath).stem + label = quote_plus(f"{stem} {dt.datetime.now().isoformat()}") + title = f"{parent.title} (Dynamic)" if parent.title else "Dynamic OpenGraph" + return { + "title": title, + "image": f"https://placehold.co/1200x630/png?text={label}", + } +``` +/// + +The generator return value is merged on top of the resolved "parent" metadata (declared fields + defaults). Any fields not returned by the generator are inherited from `parent`. + +### Generator signatures + +The generator may accept 0, 1, or 2 positional arguments: + +- `generate_opengraph()` +- `generate_opengraph(context)` +- `generate_opengraph(context, parent)` + +Type hints: + +- `context`: `marimo._metadata.opengraph.OpenGraphContext` +- `parent`: `marimo._metadata.opengraph.OpenGraphMetadata` + +`context` is an object with useful runtime information: + +- `context.filepath`: absolute path to the notebook +- `context.file_key`: file router key (often a workspace-relative path) +- `context.base_url`: server base URL (when running with `marimo run`) +- `context.mode`: `"run"` or `"edit"` + +The generator may return: + +- an `OpenGraphMetadata` instance +- a `dict` with any of `title`, `description`, and `image` +- `None` (to leave the parent metadata unchanged) diff --git a/docs/guides/publishing/playground.md b/docs/guides/publishing/playground.md new file mode 100644 index 00000000000..b6ff204af1a --- /dev/null +++ b/docs/guides/publishing/playground.md @@ -0,0 +1,295 @@ +# Online playground + +Our [online playground](https://marimo.app) lets you +create and share marimo notebooks for free, without creating an account. + +Playground notebooks are great for embedding in other web pages — all the +embedded notebooks in marimo's own docs are playground notebooks. They +are also great for sharing via links. + +**Try our playground!** Just navigate to +[https://marimo.new](https://marimo.new). + +!!! note "WebAssembly notebooks only" + + Currently, the online playground only allows the creation of [WebAssembly + notebooks](../wasm.md). These are easy to share and embed in other + web pages, but have some limitations in packages and performance. + +_The notebook embedded below is a playground notebook!_ + + + +## Creating and sharing playground notebooks { #creating-and-sharing-playground-notebooks } + +Playground notebooks run at [marimo.app](https://marimo.app). + +### New notebooks + +To create a new playground notebook, visit . + +Think of [marimo.new](https://marimo.new) as a +scratchpad for experimenting with code, data, and models and for prototyping +tools, available to you at all times and on all devices. + +!!! tip "Saving playground notebooks" + + When you save a WASM notebook, a copy of your code is saved to your + web browser's local storage. When you return to + [marimo.app](https://marimo.app), the last notebook you worked on will be + re-opened. You can also click a button to save your notebook to + the [Community Cloud](community_cloud/index.md). + +### Share via links + +At [marimo.app](https://marimo.app), save your notebook and then click the +`Create permalink` button to generate a shareable permalink to your +notebook. + +Please be aware that marimo permalinks are publicly accessible. + +### Open notebooks hosted on GitHub + +To open notebooks hosted on GitHub in the playground, just +navigate to `https://marimo.app/path/to/notebook.py`. For example: +. + +!!! tip "Use our bookmarklet!" + + For a convenient way to create notebooks from GitHub, drag and drop the + following button to your bookmarks bar: + + + Open in marimo + + + Clicking the bookmark when you are viewing a notebook will + open it in [marimo.app](https://marimo.app/). + +!!! tip "From Jupyter notebooks" + + You can also create Playground notebooks from Jupyter notebooks hosted + on GitHub. marimo will attempt to automatically convert the notebook + to a marimo notebook. + +#### Including data files + +Notebooks created from GitHub links have the entire contents of the repository +mounted into the notebook's filesystem. This lets you work with files +using regular Python file I/O! + +When constructing paths to data files, make sure to use +[`mo.notebook_dir()`][marimo.notebook_dir] to ensure that paths work both +locally and in the playground. + +!!! example "Example" + + Navigate to + + + + and open the file explorer panel to see all the files available to the notebook. + +#### Open in marimo badge + +Include an "open in marimo" badge in your README to link to playground +notebooks hosted on GitHub: + +[![Open with marimo](https://marimo.io/shield.svg)](https://marimo.app/GITHUB_URL) + +=== "Markdown" + + Replace `GITHUB_URL` with the URL to a notebook on GitHub. + + ```markdown + [![Open with marimo](https://marimo.io/shield.svg)](https://marimo.app/GITHUB_URL) + ``` + +=== "HTML" + + Replace `GITHUB_URL` with the URL to a notebook on GitHub. + + ```html + + Open in marimo + + ``` + +### Creating playground notebooks from local notebooks + +In the marimo editor's notebook action menu, use `Share > Create WebAssembly +link` to get a `marimo.app/...` URL representing your notebook: + +
+
+ +
+
+ +WASM notebooks come with common Python packages installed, but you may need to +[install additional packages using micropip](../wasm.md#supported-packages). + +The obtained URL encodes your notebook code as a parameter, so it can be +quite long. If you want a URL that's easier to share, you can [create a +shareable permalink](#share-via-links). + +## Configuration + +Your `marimo.app` URLs can be configured using the following parameters. + +### Read-only mode + +To view a notebook in read-only mode, with +code cells locked, append `&mode=read` to your URL's list of query parameters +(or `?mode=read` if your URL doesn't have a query string). + +Example: + +- `https://marimo.app/l/83qamt?mode=read` + +### Hide header for embedding + +To hide the `marimo.app` header, append `&embed=true` to your URL's list of query +parameters (or `?embed=true` if your URL doesn't have a query string). + +Example: + +- `https://marimo.app/l/83qamt?embed=true` +- `https://marimo.app/l/83qamt?mode=read&embed=true` + +See the [section on embedding](#embedding-in-other-web-pages) for examples of +how to embed marimo notebooks in your own webpages. + +### Excluding code + +By default, WASM notebooks expose your Python code to viewers. If you've +enabled read-only mode, you can exclude code with +`&include-code=false`. If you want to include code but have it be hidden +by default, use the parameter `&show-code=false`. + +A sufficiently determined user would still be able +to obtain your code, so **don't** think of this as a security feature; instead, +think of it as an aesthetic or practical choice. + +## Embedding in other web pages + +WASM notebooks can be embedded into other webpages using the HTML ` +``` + + + +??? note "Showing editor controls" + + To show editor controls (such as panels icons, the run button, and the + add-cell toolbar at the bottom of each column), use the query parameter + `show-chrome=true`. + +### Embedding an existing notebook + +To embed existing marimo notebooks into a webpage, first, [obtain a +URL to your notebook](#creating-and-sharing-playground-notebooks), then put it in an iframe. + +```html + +``` + + + +### Embedding an existing notebook in read-only mode + +You can optionally render embedded notebooks in read-only mode by appending +`&mode=read` to your URL. + +```html + +``` + + + +### Embedding from code + +You can also embed marimo notebook from its string representation (i.e., +the notebook file's code), using the `code` query parameter: + +``` +https://marimo.app?embed=true&show-chrome=false&code= +``` + +where `` is the notebook code URI encoded. For example, +in JavaScript: + +```javascript +encodeURIComponent(notebookCode) +``` + +#### Using lz compression for large notebooks + +When using the `code` query parameter, your notebooks must be no greater than `14 KB`. +For large notebooks, `marimo.app` supports lz-compressed notebook code with a URL hash. +For example, in JavaScript, you can use the [`lz-string`](https://www.npmjs.com/package/lz-string) package: + +```javascript +import { compressToEncodedURIComponent } from "lz-string"; + +const url = `https://marimo.app/#code/${compressToEncodedURIComponent(code)}` +``` + +#### MDX + +For example, if you are using MDX, you can use the following snippet: + +```jsx +const MdxNotebook = (props: { code: string }) => { + return ( + +
+ +## Why can't I redefine variables? + +**Understanding the error message.** The error message tells you which other +cells defined the multiply defined variable. You can click on the cell name, +and marimo will highlight it. + +**Why can't I redefine variables?** marimo guarantees that the code on the page +matches the outputs you see by determining a deterministic execution order on +cells; when one cell runs, marimo knows which others should run. But if two +cells defined `x`, and a third showed `x`, the output of the third cell would +be ambiguous, depending on which of the defining cells ran first (should it be +`0` or `1`?). That's a problem because it creates [hidden state and hidden +bugs](../coming_from/jupyter.md), and it's part of the reason why [over 96% of +Jupyter notebooks on GitHub aren't reproducible](https://leomurta.github.io/papers/pimentel2019a.pdf). + +**What do I get in return?** + +By accepting this constraint on variables, marimo makes your notebooks: + +- **reproducible**, with a well-defined execution order, no hidden state, and no hidden bugs; +- **executable** as a script; +- **interactive** with UI elements that work without callbacks; +- **shareable as a web app**, with far better performance than streamlit. + +As a bonus, you'll find that you end up with cleaner, reusable code. + +## How do I fix this error? + +You have a few options. + +### Use local variables + +In marimo, variables prefixed with an underscore (`_x` or `_i`) are made local +to a cell, and can be redefined across multiple cells. + +```python +for _i in range(10): + ... +``` + +Use this in a pinch, but prefer encapsulating code in functions. + +### Encapsulate code in a function + +Python provides local scope through functions: if the variable that was +redefined is meant to be a temporary variable, then you can make it local to +the cell by encapsulating the code in a function. If any of the cell's +variables are not meant to be local, or are outputs meant to be displayed, just +return them from the function. + +In general, we recommend writing modular code with meaningful functions. But, +in a pinch, just declare an anonymous function like this one to get a "local scope": + + +```python +def _(): + import matplotlib.pyplot as plt + fig, ax = plt.subplots() + ax.plot([1, 2]) + return ax + +_() +``` + +That's what clicking on the "Fix: Wrap in a function" button does. Note the function +`_()` is local to the cell. + + +### Merge cells + +Often you can simply merge the cells that define the same variable into a single cell. +To incrementally show outputs in the cell, use [`mo.output.append`][marimo.output.append] +or `print()`. + +### Chain dataframe methods + +When working with dataframes, instead of splitting up operations across +multiple cells, chain operations in a single cell. This is especially ergonomic +when using [Polars](https://docs.pola.rs/), Daft, or other modern dataframe +libraries that support lazy execution. diff --git a/docs/guides/understanding_errors/setup.md b/docs/guides/understanding_errors/setup.md new file mode 100644 index 00000000000..da8a80e9337 --- /dev/null +++ b/docs/guides/understanding_errors/setup.md @@ -0,0 +1,25 @@ +# Setup references + +You're probably on this page because you just saw an error like this one: + +
+
+ +
+
+ +marimo raises this error when the setup cell references variables defined in +other cells. In the example above, `image` is defined elsewhere in the notebook, +and hence cannot be referenced. + +## Why can't I refer to variables? + +The setup cell special: it runs before all other cells run, in order to provide +symbols that [top-level functions and classes](../reusing_functions.md) can use. +That's why it can't reference variables defined by other cells. + +## How do I fix this error? + +Define all needed variables in the setup cell. Or, if this code does not +need to run before all other cells (if you are not using top-level functions +or classes), simply move your code to a regular cell. diff --git a/docs/guides/wasm.md b/docs/guides/wasm.md index 73902de3fe1..f99bdfdcbb3 100644 --- a/docs/guides/wasm.md +++ b/docs/guides/wasm.md @@ -1,91 +1,93 @@ -# Online playground +--- +description: "Run marimo notebooks entirely in the browser with WebAssembly. No backend required. Share and embed interactive notebooks anywhere." +--- -We've built an [online playground](https://marimo.app) that lets you -run marimo **entirely in the browser** -- no backend required! +# WebAssembly Notebooks -marimo notebooks that run entirely in the browser are called **WebAssembly -notebooks**, or **WASM notebooks** for short. +marimo lets you execute notebooks _entirely in the browser_, +without a backend executing Python. marimo notebooks that +run entirely in the browser are called WebAssembly notebooks, or WASM notebooks +for short. -In contrast to marimo notebooks that you create with the CLI, WASM notebooks -run without a web server and Python process; instead, the web browser executes -your Python code. For this reason, WASM makes it extremely easy to -share marimo notebooks, and makes it possible to tinker with notebooks without -having to install Python on your machine. +!!! tip "Check for WebAssembly compatibility" + Not all notebooks are compatible with WebAssembly. If you use coding agents + like Claude Code, you can use our [official skills](generate_with_ai/customize_your_agent.md#official-marimo-skills) + to automatically check for WebAssembly compatibility of your notebooks. -**Try our playground!** Just navigate to -[https://marimo.new](https://marimo.new). +!!! tip "Sharing interactive previews of GitHub notebooks" + Read the [molab docs](molab.md) to learn how to share WebAssembly previews + of notebooks hosted on GitHub, and how to embed WebAssembly notebooks in other + webpages such as documentation. -!!! note "WASM?" +WASM notebooks have three benefits compared to notebooks hosted using a +traditional client-server model. WASM notebooks: - marimo-in-the-browser is powered by a technology called - [WebAssembly](https://webassembly.org/), or "WASM" for short. Hence the - name "WASM notebook". +1. eliminate the need to install Python, making scientific computing accessible; +2. eliminate the cost and complexity of deploying backend infrastructure, making it easy to share notebooks; +3. eliminate network requests to a remote Python runner, making development feel snappy. -!!! note "When should I use WASM notebooks?" +!!! question "When should I use WASM notebooks?" - WASM notebooks are excellent for sharing your work, quickly experimenting with - code and models, doing lightweight data exploration, authoring blog posts, - tutorials, and educational materials, and even building tools. They are - not well-suited for notebooks that do heavy computation. + WASM notebooks are excellent for sharing your work, quickly experimenting + with code and models, doing lightweight data exploration, authoring blog + posts, tutorials, and educational materials, and even building tools. For + notebooks that do heavy computation, [use marimo + ](../getting_started/index.md) on your own machine/server or on [molab](https://molab.marimo.io/notebooks). -!!! warning "Issues?" +**Try it!** Try editing the below notebook (your browser, not a backend server, is executing it!) - WASM notebooks are a new feature. If you run into - problems, please open a [GitHub issue](https://github.com/marimo-team/marimo/issues). +/// marimo-embed + size: large -## Creating and sharing WASM notebooks - -WASM notebooks run at [marimo.app](https://marimo.app). - -### Creating new notebooks - -To create a new WASM notebook, just visit -[marimo.new](https://marimo.new). - -Think of [marimo.new](https://marimo.new) as your own personal -scratchpad for experimenting with code, data, and models and for prototyping -tools, available to you at all times and on all devices. +```python +@app.cell +async def __(): + return +``` -!!! tip "Saving WASM notebooks" +/// - When you save a WASM notebook, a copy of your code is saved to your - web browser's local storage. When you return to [marimo.app](https://marimo.app), - the last notebook you worked on will be re-opened. -### Creating shareable permalinks +_This feature is powered by [Pyodide](https://pyodide.org), a port +of Python to WebAssembly that enables browsers to run Python code._ -At [marimo.app](https://marimo.app), save your notebook and then click the -`Create permalink` button to generate a shareable permalink to your -notebook. +## Creating WASM notebooks -Please be aware that marimo permalinks are publicly accessible. +marimo provides three ways to create and share WASM notebooks: -### Creating WASM notebooks from local notebooks +1. [molab](molab.md). Our free cloud-hosted marimo notebook service. + Append `/wasm` to [GitHub previews](molab.md#mirror-notebooks-from-github) to create interactive previews + of notebooks hosted on GitHub. molab also allows embedding WebAssembly notebooks in + other [webpages](publishing/embedding.md) (we do this throughout these docs). +2. [Export to WASM HTML](exporting/webassembly_html.md), + which you can host on GitHub Pages or self-host. You can also use [a +GitHub action](publishing/github.md#publish-using-github-actions). +3. Try our ephemeral [WebAssembly playground](https://marimo.app); +unlike molab, notebooks created at the playground are not saved. -In the marimo editor's notebook action menu, use `Share > Create WebAssembly -link` to get a `marimo.app/...` URL representing your notebook: +## Packages -
-
- -
-
+!!! tip "Use `--sandbox` for seamless package installation" -WASM notebooks come with common Python packages installed, but you may need to -[install additional packages using micropip](#installing-packages). + If you're developing notebooks locally that you plan to share as WASM + notebooks, create them with `marimo edit --sandbox notebook.py`. This + inlines your package dependencies into the notebook file, ensuring they + are seamlessly installed in our WebAssembly environment. See + [package management](editor_features/package_management.md) for more details. -The obtained URL encodes your notebook code as a parameter, so it can be -quite long. If you want a URL that's easier to share, you can [create a -shareable permalink](#creating-shareable-permalinks). +!!! tip "Rendering performance" -## Installing packages + To make sure markdown and other elements render quickly: make sure to put + `import marimo as mo` in its own cell, with no other lines of code. WASM notebooks come with many packages pre-installed, including NumPy, SciPy, scikit-learn, pandas, and matplotlib; see [Pyodide's documentation](https://pyodide.org/en/stable/usage/packages-in-pyodide.html) for a full list. -To install other packages, use `micropip`: +If you attempt to import a package that is not installed, marimo will +attempt to automatically install it for you. To manually install packages, use +[`micropip`](https://micropip.pyodide.org/en/stable/project/usage.html): In one cell, import micropip: @@ -93,124 +95,154 @@ In one cell, import micropip: import micropip ``` -In the next one, install packages: +In the next cell, install packages: ```python await micropip.install("plotly") import plotly ``` -**Try it!** A WASM notebook is embedded below. Try installing a package. - - - -## Configuration - -Your `marimo.app` URLs can be configured using the following parameters. - -### Read-only mode - -To view a notebook in read-only mode, with -code cells locked, append `&mode=read` to your URL's list of query parameters -(or `?mode=read` if your URL doesn't have a query string). - -Example: - -- `https://marimo.app/l/83qamt?mode=read` - -### Embed +### Supported packages -To hide the `marimo.app` header, append `&embed=true` to your URL's list of query -parameters (or `?embed=true` if your URL doesn't have a query string). - -Example: +All packages with pure Python wheels on PyPI are supported, as well as +additional packages like NumPy, SciPy, scikit-learn, duckdb, polars, and more. +For a full list of supported packages, see [Pyodide's +documentation on supported packages.](https://pyodide.org/en/stable/usage/packages-in-pyodide.html) -- `https://marimo.app/l/83qamt?embed=true` -- `https://marimo.app/l/83qamt?mode=read&embed=true` +If you want a package to be supported, consider [filing an issue](https://github.com/pyodide/pyodide/issues/new?assignees=&labels=new+package+request&projects=&template=package_request.md&title=). -See the [section on embedding](#embedding) for examples of how to embed marimo -notebooks in your own webpages. +### Platform-specific dependencies (PEP 508) -### Excluding code +Notebooks that run both locally and in the browser can use [PEP +508](https://peps.python.org/pep-0508/) **environment markers** in PEP 723 script +metadata to declare different dependencies per platform. On Pyodide, +`sys.platform` is `"emscripten"` ([PEP 776](https://peps.python.org/pep-0776/)). -By default, WASM notebooks expose your Python code to viewers. If you've -enabled read-only mode, you can exclude code with -`&include-code=false`. If you want to include code but have it be hidden -by default, use the parameter `&show-code=false`. +**Exclude a package from WebAssembly** (install it locally only): -A sufficiently determined user would still be able -to obtain your code, so **don't** think of this as a security feature; instead, -think of it as an aesthetic or practical choice. +```python +# /// script +# dependencies = [ +# "pandas>=2.0", +# "torch>=2.0; sys_platform != 'emscripten'", +# ] +# /// +``` -## Embedding +**Include a package only in WebAssembly:** -WASM notebooks can be embedded into other webpages using the HTML ` -``` +## Including data - +**For notebooks exported to WASM HTML.** +To include data files in notebooks [exported to WASM +HTML](exporting/webassembly_html.md), place them +in a `public/` folder in the same directory as your notebook. When you +export to WASM HTML, the public folder will be copied to the export directory. -### Embedding an existing notebook +In order to access data both locally and when an exported notebook runs via +WebAssembly (e.g., hosted on GitHub Pages), use +[`mo.notebook_location()`][marimo.notebook_location] to construct the path to +your data: -To embed existing marimo notebooks into a webpage, first, [obtain a -URL to your notebook](#creating-and-sharing-wasm-notebooks), then put it in an iframe. +```python +import polars as pl -```html - +path_to_csv = mo.notebook_location() / "public" / "data.csv" +df = pl.read_csv(str(path_to_csv)) +df.head() ``` - +**Fetching data files from the web.** +Instead of bundling data files with your notebook, you can host data files on +the web and fetch them in your notebook. Depending on where your files are +hosted, you may need to use a CORS Proxy; see the [Pyodide +documentation](https://pyodide.org/en/stable/usage/loading-packages.html#installing-wheels-from-arbitrary-urls) +for more details. -After obtaining a URL to your -notebook, +**molab notebooks.** When opening a notebook from GitHub on [molab](molab.md), +all the files in the GitHub repo are made available to your notebook. -### Embedding an existing notebook in read-only mode +## Detecting WebAssembly -You can optionally render embedded notebooks in read-only mode by appending -`&mode=read` to your URL. +To check if your notebook is running in a WebAssembly environment, use: -```html - +```python +import sys + +if sys.platform == "emscripten": + # Running in WebAssembly (Pyodide) + ... +else: + # Running locally + ... ``` - +This is useful for branching logic, such as using `micropip` for package +installation in WASM while using standard imports locally. ## Limitations -While WASM notebooks let you get up and running with marimo instantly, they -have some limitations. - -**Packages.** Not all packages are available in WASM notebooks; see [Pyodide's -documentation on supported packages.](https://pyodide.org/en/stable/usage/packages-in-pyodide.html) - -**PDB.** PDB is not currently supported. This may be fixed in the future. - -**Threading and multi-processing.** WASM notebooks do not support multithreading -and multiprocessing. [This may be fixed in the future](https://github.com/pyodide/pyodide/issues/237). +While WASM notebooks let you share marimo notebooks seamlessly, they have some +limitations. + +**Packages.** Many but not all packages are supported. All packages with pure +Python wheels on PyPI are supported, as well as additional packages like NumPy, +SciPy, scikit-learn, duckdb, polars, and more. For a full list of supported +packages, see [Pyodide's documentation on supported +packages.](https://pyodide.org/en/stable/usage/packages-in-pyodide.html) + +If you want a package to be supported, consider [filing an +issue](https://github.com/pyodide/pyodide/issues/new?assignees=&labels=new+package+request&projects=&template=package_request.md&title=). + +**PDB.** PDB is not currently supported. + +**Concurrency.** WASM notebooks support cooperative adapters for +`threading.Thread`, `threading.Event`, `threading.local`, +`concurrent.futures.ThreadPoolExecutor`, `wait`, `as_completed`, and +process-shaped `multiprocessing.Process`, `Queue`, `SimpleQueue`, `Pool`, and +`ProcessPoolExecutor`. These adapters run in the browser's Pyodide interpreter. +They do not create OS threads, shared-memory processes, or true CPU parallelism. +Blocking waits are bridged through Pyodide's JSPI-backed asyncio loop. + +WASM concurrency support has four levels: + +- `api-compatible`: the tested Python API shape and result behavior match the + local Python contract for that operation. +- `serialized`: the API shape is available, but work runs one task at a time in + the current Pyodide interpreter. +- `cooperative-only`: waits, cancellation, and termination progress only when + Python yields back to the Pyodide event loop. Running Python code is not + preempted. +- `blocked`: marimo rejects the API because the browser cannot provide the + native process, synchronization, or shared-memory primitive it requires. + +`multiprocessing.Pool.terminate()` cancels queued work, but raises +`UnsupportedWasmConcurrencyError` when a task is already running. +Native synchronization and process APIs such as `threading.Lock`, `Condition`, +`Semaphore`, `Barrier`, `Timer`, `multiprocessing.Pipe`, managers, shared +memory, and non-`spawn` start methods are unsupported. For CPU-bound parallelism +or process isolation, use a regular marimo notebook. + +**Memory.** WASM notebooks have a memory limit of 2GB; this may be increased +in the future. If memory consumption is an issue, try offloading memory-intensive +computations to hosted APIs or precomputing expensive operations. ## Browser support diff --git a/docs/guides/working_with_data/dataframes.md b/docs/guides/working_with_data/dataframes.md index b4fa38dc606..c4f90711253 100644 --- a/docs/guides/working_with_data/dataframes.md +++ b/docs/guides/working_with_data/dataframes.md @@ -11,13 +11,18 @@ _marimo integrates with [Pandas](https://pandas.pydata.org/) and [Polars](https://pola.rs) dataframes natively_. +For a video overview on interactive dataframes, +check out our [YouTube tutorial](https://www.youtube.com/watch?v=ZTs7vHzsqlQ). + ## Displaying dataframes marimo lets you page through, search, sort, and filter dataframes, making it extremely easy to get a feel for your data.
-
@@ -42,20 +47,46 @@ df ```python import polars as pl +import urllib.request -df = pl.read_json( - "https://raw.githubusercontent.com/vega/vega-datasets/master/data/cars.json" -) +url = "https://raw.githubusercontent.com/vega/vega-datasets/master/data/cars.json" + +with urllib.request.urlopen(url) as response: + df = pl.read_json(response.read()) df ``` /// +/// tab | live example + +/// marimo-embed + size: large + +```python +@app.cell +def __(): + import pandas as pd + + pd.read_json( + "https://raw.githubusercontent.com/vega/vega-datasets/master/data/cars.json" + ) + return +``` + +/// + +/// + + To opt out of the rich dataframe viewer, use [`mo.plain`][marimo.plain]: /// tab | pandas ```python +import pandas as pd +import marimo as mo + df = pd.read_json( "https://raw.githubusercontent.com/vega/vega-datasets/master/data/cars.json" ) @@ -67,6 +98,9 @@ mo.plain(df) /// tab | polars ```python +import polars as pl +import marimo as mo + df = pl.read_json( "https://raw.githubusercontent.com/vega/vega-datasets/master/data/cars.json" ) @@ -75,6 +109,27 @@ mo.plain(df) /// +/// tab | live example + +/// marimo-embed + size: large + +```python +@app.cell +def __(): + import pandas as pd + + df = pd.read_json( + "https://raw.githubusercontent.com/vega/vega-datasets/master/data/cars.json" + ) + mo.plain(df) + return +``` + +/// + +/// + ## Transforming dataframes ### No-code transformations @@ -86,11 +141,21 @@ notebook.
- +
Build transformations using a GUI
+The transformations you apply will turn into code which is accessible via the "code" tab. + +
+
+ +
Copy the code of the transformation
+
+
+ /// tab | pandas ```python @@ -111,12 +176,13 @@ transformed_df.value /// + /// tab | polars ```python # Cell 1 import marimo as mo -import pandas as pl +import polars as pl df = pl.DataFrame({"person": ["Alice", "Bob", "Charlie"], "age": [20, 30, 40]}) transformed_df = mo.ui.dataframe(df) @@ -131,12 +197,59 @@ transformed_df.value /// -
-
- -
Copy the code of the transformation
-
-
+ +/// tab | live example + +/// marimo-embed + size: large + +```python +@app.cell +def __(): + import pandas as pd + + df = pd.DataFrame({"person": ["Alice", "Bob", "Charlie"], "age": [20, 30, 40]}) + transformed_df = mo.ui.dataframe(df) + transformed_df + return + +@app.cell +def __(): + transformed_df.value + + return +``` + +/// + +/// + + +### Formatting values + +Use `format_mapping` to format values for display in the dataframe UI. This +affects how values appear in the table but does not change the underlying +data returned by `.value` or downloads. + +```python +import marimo as mo +import pandas as pd + +df = pd.DataFrame( + {"person": ["Alice", "Bob"], "age": [20, 30], "height_cm": [165.2, 180.4]} +) + +def format_height(value: float) -> str: + return f"{value:.1f} cm" + +mo.ui.dataframe( + df, + format_mapping={ + "age": "{:d} years".format, + "height_cm": format_height, + }, +) +``` ### Custom filters @@ -166,6 +279,7 @@ mo.ui.table(filtered_df) /// tab | polars ```python +# Cell 1 import marimo as mo import polars as pl @@ -189,6 +303,37 @@ mo.ui.table(filtered_df) /// +/// tab | live example + + +/// marimo-embed + size: large + +```python +@app.cell +def __(): + import pandas as pd + + df = pd.DataFrame({"person": ["Alice", "Bob", "Charlie"], "age": [20, 30, 40]}) + return + +@app.cell +def __(): + age_filter = mo.ui.slider(start=0, stop=100, value=50, label="Max age") + age_filter + return + +@app.cell +def __(): + filtered_df = df[df["age"] < age_filter.value] + mo.ui.table(filtered_df) + return +``` + +/// + +/// + ## Select dataframe rows {#selecting-dataframes} Display dataframes as interactive, [selectable charts](plotting.md) using @@ -200,11 +345,13 @@ dataframe_.
- +
Select rows in a table, get them back as a dataframe
+ /// tab | pandas ```python @@ -224,12 +371,13 @@ table.value /// + /// tab | polars ```python # Cell 1 - display a dataframe import marimo as mo -import pandas as pl +import polars as pl df = pl.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}) table = mo.ui.table(df, selection="multi") @@ -243,7 +391,102 @@ table.value /// -## Example notebook + +/// tab | live example + +/// marimo-embed + size: large + +```python +@app.cell +def __(): + import pandas as pd + + df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}) + table = mo.ui.table(df, selection="multi") + table + return + +@app.cell +def __(): + table.value + return +``` + +/// + +/// + + +## Dataframe panels {#dataframe-panels} + +Dataframe outputs in marimo come with several panels to help you visualize, explore, and page through your data interactively. These panels are accessible via toggles at the bottom-left of a dataframe output. If you need further control, after opening a panel you can + +- **pin the panel** to the side of your editor for persistent access; +- **toggle focus** to automatically display the currently focused dataframe in the panel. + +??? note + + Toggles are visible when editing notebooks (with `marimo edit ...`) but not when running notebooks as apps (with `marimo run ...`), except for the row viewer which is available in both. + +### Row viewer panel {#row-viewer-panel} + +
+
+ +
+
+ +To inspect individual rows, open the **row viewer**. This presents a vertical view of the selected row. + +- **Press `Space`** to select/deselect the current row +- **Use arrow keys** (`←` `→`) to navigate between rows +- **Click** on any row in the dataframe to view its data in the panel + +### Column explorer panel {#column-explorer-panel} + +
+
+ +
+
+ +To explore your data, open the **column explorer** where you can find summary statistics and charts for each column. Click the `+` button to add the chart code to a new cell. + +This requires the `altair` package to be installed. For large dataframes, `vegafusion` is also needed to render charts. To use the generated Python code, enable vegafusion in your notebook: + +```python +import altair + +altair.data_transformers.enable("vegafusion") +``` + +### Chart builder {#chart-builder} + +The chart builder toggle lets you rapidly develop charts using a GUI, while also generating Python code to insert in your notebook. Refer to the [chart builder guide](plotting.md#chart-builder) for more details. + +## Preferences {#preferences} + +When you run a SQL cell in marimo, you can get the output returned as a dataframe. If you have a preference for a specific dataframe library as a default you can configure the "default SQL output" in the user settings by going to the "Runtime" tab. + + +
+
+ +
Configure the default SQL output
+
+
+ +Alternatively you can also use the [marimo configuration file](../configuration/index.md#user-settings) to configure the default SQL output. + +```toml +[runtime] +default_sql_output = "native" +``` + +## Example notebook {#example-notebook} For a comprehensive example of using Polars with marimo, check out our [Polars example notebook](https://github.com/marimo-team/marimo/blob/main/examples/third_party/polars/polars_example.py). diff --git a/docs/guides/working_with_data/index.md b/docs/guides/working_with_data/index.md index d8e8be77831..a233e6ca5ac 100644 --- a/docs/guides/working_with_data/index.md +++ b/docs/guides/working_with_data/index.md @@ -1,11 +1,12 @@ # Working with data These guides introduce you to marimo's features for working with data, -including SQL cells, no-code dataframe transformation tools, and plots whose -selections are automatically sent back to Python. +including SQL cells, no-code dataframe transformation tools, plots whose +selections are automatically sent back to Python, and browsing remote storage. | Guide | Description | |-------|-------------| | [SQL](sql.md) | Use SQL to query dataframes, databases, CSVs, etc. | | [Dataframes](dataframes.md) | Filter, search, and transform dataframes without code | | [Plotting](plotting.md) | Send plot selections to Python | +| [Remote Storage](remote_storage.md) | Browse and download files from S3, GCS, Azure, and more | diff --git a/docs/guides/working_with_data/plotting.md b/docs/guides/working_with_data/plotting.md index 7d5b74dc21a..d1eac1c1d24 100644 --- a/docs/guides/working_with_data/plotting.md +++ b/docs/guides/working_with_data/plotting.md @@ -4,27 +4,133 @@ marimo supports most major plotting libraries, including Matplotlib, Seaborn, Plotly, Altair, and HoloViews. Just import your plotting library of choice and use it as you normally would. -For Altair and Plotly plots, marimo does something special: use +For matplotlib, Altair, and Plotly plots, marimo does something special: wrap +your plot in [`mo.ui.matplotlib`][marimo.ui.matplotlib], [`mo.ui.altair_chart`][marimo.ui.altair_chart] or -[`mo.ui.plotly`][marimo.ui.plotly] to connect frontend -selections to Python! +[`mo.ui.plotly`][marimo.ui.plotly], then select and filter with your mouse — +marimo automatically sends the selected data back to Python! -!!! important "Reactive plots!" - marimo supports reactive plots via - [`mo.ui.altair_chart`][marimo.ui.altair_chart] and - [`mo.ui.plotly`][marimo.ui.plotly]! Select and - filter with your mouse, and marimo _automatically makes the selected data - available in Python as a Pandas dataframe_! +> For a video overview of reactive plots, check out our [YouTube tutorial](https://youtu.be/KFXsm1wr408). ## Reactive plots! ⚡ !!! warning "Requirements" - Reactive plots currently require Altair or Plotly. Install with `pip install - altair` or `pip install plotly`, depending on which library you are using. - Selections in plotly are limited to scatter plots, treemaps charts, and sunbursts charts, while Altair supports - a larger class of plots for selections. + Reactive plots currently require matplotlib, Altair, or Plotly. Matplotlib + supports box and lasso selections (best suited for scatter plots); + selections in Plotly are limited to scatter/scattergl plots, bar charts, + histograms, heatmaps, treemaps, and sunburst charts; Altair supports a larger class + of plots for selections. + +### matplotlib + +Use [`mo.ui.matplotlib`][marimo.ui.matplotlib] to make matplotlib plots +**reactive**: select data on the frontend, then use the selection to filter +your data in Python. + +Two selection modes are supported: + +- **Box selection** — click and drag to draw a rectangular region. +- **Lasso selection** — hold Shift and drag to draw a freehand + polygon. + +After selecting, use `fig.value.get_mask(x, y)` to get a boolean mask of the +points inside the selection. When nothing is selected, `fig.value` is falsy and +`get_mask()` returns an all-`False` array. + +#### Example + +/// tab | code + +```python +import matplotlib.pyplot as plt +import marimo as mo +import numpy as np + +x = np.random.randn(500) +y = np.random.randn(500) +plt.scatter(x, y) +# Wrap the Axes in mo.ui.matplotlib to make them reactive ⚡ +ax = mo.ui.matplotlib(plt.gca()) +ax +``` + +```python +# In another cell — filter your data using the selection +mask = ax.value.get_mask(x, y) +selected_x, selected_y = x[mask], y[mask] +``` + +/// + +/// tab | live example + +/// marimo-embed + size: large + +```python +@app.cell +def __(): + import matplotlib.pyplot as plt + import numpy as np + + x = np.random.randn(500) + y = np.random.randn(500) + plt.scatter(x, y) + ax = mo.ui.matplotlib(plt.gca()) + ax + return + +@app.cell +def __(): + mask = ax.value.get_mask(x, y) + np.column_stack([x[mask], y[mask]]) + return +``` + +/// + +/// + +#### Debouncing + +By default, the selection streams to Python as you drag. For expensive +downstream computations or very large datasets, pass `debounce=True` so the +value is only sent on mouse-up: + +```python +ax = mo.ui.matplotlib(plt.gca(), debounce=True) +``` + +#### Selection types + +`ax.value` is one of three types: + +| Type | When | Attributes | +|------|------|------------| +| `EmptySelection` | Nothing selected (falsy) | — | +| `BoxSelection` | Box drag | `x_min`, `x_max`, `y_min`, `y_max` | +| `LassoSelection` | Shift+drag | `vertices` (tuple of `(x, y)` pairs) | + +All three have a `get_mask(x, y)` method that returns a boolean NumPy array, +so you can always write: + +```python +mask = ax.value.get_mask(x, y) +``` + +Pass `value` to start with a box selection: + +```python +ax = mo.ui.matplotlib( + plt.gca(), + value={"x": (1, 3), "y": (2, 4)}, +) +``` + +The initial selection is clamped to the axes. If it does not overlap the +axes, `ax.value` is an `EmptySelection`. ### Altair @@ -34,19 +140,14 @@ selections to Python! ```python @app.cell async def __(): + import altair as alt import pandas as pd - import pyodide - import micropip import json - await micropip.install('altair') - import altair as alt return @app.cell def __(): - cars = pd.DataFrame(json.loads( - pyodide.http.open_url('https://vega.github.io/vega-datasets/data/cars.json').read() - )) + cars = pd.read_json('https://vega.github.io/vega-datasets/data/cars.json') chart = mo.ui.altair_chart(alt.Chart(cars).mark_point().encode( x='Horsepower', @@ -69,7 +170,10 @@ automatically made available as Pandas dataframes in Python._
- +
@@ -209,11 +313,12 @@ conda install -c conda-forge "vegafusion-python-embed>=1.4.0" "vegafusion>=1.4.0 ### Plotly -!!! warning "mo.ui.plotly only supports scatter plots, treemaps charts, and sunbursts charts" +!!! warning "Supported charts" marimo can render any Plotly plot, but [`mo.ui.plotly`][marimo.ui.plotly] only - supports reactive selections for scatter plots, treemaps charts, and sunbursts charts. If you require other kinds of - selection, consider using [`mo.ui.altair_chart`][marimo.ui.altair_chart]. + supports reactive selections for scatter/scattergl plots, bar charts, + histograms, heatmaps, treemaps, and sunburst charts. If you require other kinds of + selection, please [file an issue](https://github.com/marimo-team/marimo/issues). /// marimo-embed size: large @@ -222,10 +327,9 @@ conda install -c conda-forge "vegafusion-python-embed>=1.4.0" "vegafusion>=1.4.0 @app.cell(hide_code=True) async def __(): import micropip - await micropip.install("pandas") - await micropip.install("plotly") + await micropip.install(["plotly[express]", "pandas"]) import plotly.express as px - return micropip, px + return px, @app.cell @@ -271,8 +375,26 @@ ax If you want to output the plot in the console area, use `plt.show()` or `fig.show()`. -### Interactive plots +### Interactive plots with pan and zoom + +To make matplotlib plots interactive with pan and zoom, use +[mo.mpl.interactive][marimo.mpl.interactive]. This does not support reactive selection. + +## Chart builder + +marimo comes with a built-in chart builder that makes it easy to create plots specialized to your dataframes with just a few clicks. As you make your charts, marimo generates Python code that you can add to your notebook to save them. + +You can toggle the chart builder with a button at the bottom-left of a dataframe output. This provides a GUI interface to create many kinds of plots, while also generating Python code. + +
+
+ +
+
+ +Charts are powered by [Vega-Lite](https://vega.github.io/vega-lite/). To save a chart, click the `+` button in the `Python code` tab to add the code to a new cell. + +!!! note -To make matplotlib plots interactive, use -[mo.mpl.interactive][marimo.mpl.interactive]. -(Matplotlib plots are not yet reactive.) + This feature is in active development. Please report any issues or feedback [here](https://github.com/marimo-team/marimo/issues). diff --git a/docs/guides/working_with_data/remote_storage.md b/docs/guides/working_with_data/remote_storage.md new file mode 100644 index 00000000000..62e314f4bdb --- /dev/null +++ b/docs/guides/working_with_data/remote_storage.md @@ -0,0 +1,123 @@ +# Remote Storage + +marimo makes it easy to work with cloud storage and remote filesystems by automatically detecting [obstore](https://developmentseed.org/obstore/) and [fsspec](https://filesystem-spec.readthedocs.io/) storage connections in your notebook. From the Files panel, you can browse directories, search entries, copy URLs, and download files—all without leaving the editor. + +
+ +
+ +## Supported libraries + +marimo auto-discovers variables that are instances of: + +| Library | Base class | Example stores | +|---------|-----------|----------------| +| [obstore](https://developmentseed.org/obstore/) | `obstore.store.ObjectStore` | `S3Store`, `GCSStore`, `AzureStore`, `HTTPStore`, `LocalStore`, `MemoryStore` | +| [fsspec](https://filesystem-spec.readthedocs.io/) | `fsspec.AbstractFileSystem` | `S3FileSystem`, `GithubFileSystem`, `FTPFileSystem`, `DatabricksFileSystem`, and [many more](https://filesystem-spec.readthedocs.io/en/latest/api.html#built-in-implementations) | +| [huggingface_hub](https://huggingface.co/docs/huggingface_hub) | `huggingface_hub.HfApi` | Browse the Hugging Face Hub (datasets, models, spaces, buckets) | + + +## Creating a storage connection + +You can either create a storage connection using the UI or code. + +### Using the UI + +From the Files panel in the sidebar, expand the **Remote Storage** section and click the **Add remote storage** button. The UI will guide you through entering your storage connection details. + +
+
+ Add a storage connection through the UI +
+
+ +marimo also detects credentials in your kernel's environment and offers a **Quick add** suggestion. + +| Integration | Detected from | +| --- | --- | +| Amazon S3 | `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are set, or `AWS_PROFILE` is set | +| S3-compatible storage | As above, with a custom endpoint (`AWS_ENDPOINT_URL` or `AWS_ENDPOINT_URL_S3`), e.g. MinIO or Cloudflare R2 | + +See [Detecting data sources from your environment](sql.md#detecting-data-sources-from-your-environment) for details on how detection works. + +If you'd like to connect to a storage that isn't supported by the UI, you can use the code method below, or submit a [feature request](https://github.com/marimo-team/marimo/issues/new?title=Add%20new%20storage%20connection%20UI:&labels=enhancement&template=feature_request.yaml). + +### Using code + +#### obstore + +```python +from obstore.store import S3Store + +store = S3Store.from_url( + "s3://my-bucket", + access_key_id="...", + secret_access_key="...", +) +``` + +S3-compatible stores can also authenticate with container-vended credentials — used by ECS/EKS task roles and CoreWeave sandboxes, where the platform injects a credential endpoint and a token file: + +```python +import os + +from obstore.store import S3Store + +store = S3Store( + "my-bucket", + endpoint="https://my-bucket.cwobject.com", + virtual_hosted_style_request=True, + container_credentials_full_uri=os.environ["AWS_CONTAINER_CREDENTIALS_FULL_URI"], + container_authorization_token_file=os.environ["AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE"], +) +``` + +!!! note "S3 endpoint gotchas" + + - `AWS_ENDPOINT_URL_S3` takes precedence over the `endpoint` argument, so an + explicitly configured endpoint is silently ignored when that variable is set. + - `virtual_hosted_style_request=True` expects the bucket name to already be part + of the endpoint hostname; unlike boto3, obstore does not prepend it. + +#### fsspec + +```python +from fsspec.implementations.github import GithubFileSystem + +repo = GithubFileSystem(org="marimo-team", repo="marimo") +``` + +#### Hugging Face Hub + +```python +from huggingface_hub import HfApi + +hf = HfApi() # optional: HfApi(token=os.environ.get("HF_TOKEN")) +``` + +Use `hf://` URLs in Polars, pandas, or DuckDB to read files you find in the panel: + +```python +df = pl.read_csv("hf://datasets/scikit-learn/Fish/Fish.csv") +``` + +After the cell runs, the **Remote Storage** section will populate with your connection, its detected protocol, and root path. + +
+
+ Remote storage panel +
+
+ +## Multiple connections + +You can have multiple storage connections in the same notebook — each one appears as a separate namespace. The panel header shows the variable name so you can tell them apart. + +```python +from obstore.store import S3Store + +prod = S3Store.from_url("s3://prod-bucket") +staging = S3Store.from_url("s3://staging-bucket") +``` diff --git a/docs/guides/working_with_data/sql.md b/docs/guides/working_with_data/sql.md index 80b3f4be2b6..8717c71497c 100644 --- a/docs/guides/working_with_data/sql.md +++ b/docs/guides/working_with_data/sql.md @@ -1,9 +1,15 @@ +--- +description: "Mix Python and SQL in marimo notebooks. Query dataframes, SQLite, Postgres, and other databases, and get results back as Python dataframes." +--- + # Using SQL -marimo lets you can mix and match **Python and SQL**: Use SQL to query +marimo lets you mix and match **Python and SQL**: Use SQL to query Python dataframes (or databases like SQLite and Postgres), and get the query result back as a Python dataframe. +> For a video overview on how to use SQL in marimo, watch our [YouTube tutorial](https://youtu.be/IHEf5HwU7R0). + To create a SQL cell, you first need to install additional dependencies, including [duckdb](https://duckdb.org/): @@ -18,7 +24,7 @@ pip install "marimo[sql]" /// tab | install with uv ```bash -uv pip install "marimo[sql]" +uv add "marimo[sql]" ``` /// @@ -31,7 +37,7 @@ conda install -c conda-forge marimo duckdb polars /// -!!! tip "Examples" +!!! example "Examples" For example notebooks, check out [`examples/sql/` on GitHub](https://github.com/marimo-team/marimo/tree/main/examples/sql/). @@ -55,7 +61,7 @@ You can create SQL cells in one of three ways:
- +
Add SQL Cell
@@ -77,6 +83,24 @@ interpolate Python values into the query with `{}`. In particular, this means your SQL queries can depend on the values of UI elements or other Python values, and they are fit into marimo's reactive dataflow graph. +## SQL Output Types + +marimo supports different output types for SQL queries, which is particularly useful when working with large datasets. You can configure this in your application configuration in the top right of the marimo editor. + +The available options are: + +- `native`: Uses DuckDB's native lazy relation (recommended for best performance) +- `lazy-polars`: Returns a lazy Polars DataFrame +- `pandas`: Returns a Pandas DataFrame +- `polars`: Returns an eager Polars DataFrame +- `auto`: Automatically chooses based on installed packages (first tries `polars` then `pandas`) + +For best performance with large datasets, we recommend using `native` to avoid loading the entire result set into memory and to more easily chain SQL cells together. By default, only the first 10 rows are displayed in the UI to prevent memory issues. + +???+ tip "Set a default" + + The default output type is currently `auto`, but we recommend explicitly setting the output type to `native` for best performance with large datasets or `polars` if you need to work with the results in Python code. You can configure this in your application settings. + ## Reference a local dataframe You can reference a local dataframe in your SQL cell by using the name of the @@ -121,9 +145,49 @@ SELECT * FROM read_parquet('path/to/example.parquet'); For a full list you can check out the [duckdb extensions](https://duckdb.org/docs/extensions/overview). You can also check out our [examples on GitHub](https://github.com/marimo-team/marimo/tree/main/examples/sql). +## Refreshing SQL queries + +If you'd like to refresh your SQL cells automatically, you can wire a UI element into the SQL cell's dependency graph. + +### Periodic refresh with `mo.ui.refresh` + +Add a [`mo.ui.refresh`][marimo.ui.refresh] control and reference it in your SQL query. Because SQL cells use f-strings, include the refresh control in a SQL comment so it does not affect the query: + +```python +refresh = mo.ui.refresh(default_interval="30s") +refresh +``` + +```sql +SELECT * FROM my_table +-- {refresh} +``` + +Each time the refresh timer fires, the SQL cell re-runs. See the [refresh recipe](../../recipes.md#run-a-cell-on-a-timer) for more details. + +### Manual refresh with a button + +For on-demand refresh, add a [`mo.ui.button`][marimo.ui.button] and reference it in the cell that runs your query. + +In a **SQL cell**, reference the button in a comment: + +```python +refresh_button = mo.ui.button(label="Refresh data") +refresh_button +``` + +```sql +SELECT * FROM my_table +-- {refresh_button} +``` + +### Other UI elements + +SQL queries can interpolate any Python value, so dropdowns, sliders, text inputs, and other UI elements already trigger re-queries when their values change. See the [`parametrizing_sql_queries.py` example](https://github.com/marimo-team/marimo/blob/main/examples/sql/parametrizing_sql_queries.py) on GitHub. + ## Escaping SQL brackets -Our "SQL" cells are really just Python under the hood to keep notebooks as pure Python scripts. By default, we use `f-strings` for SQL strings, which allows for parameterized SQL like which allows for parameterized SQL like `SELECT * from table where value < {min}`. +Our "SQL" cells are really just Python under the hood to keep notebooks as pure Python scripts. By default, we use `f-strings` for SQL strings, which allows for parameterized SQL like `SELECT * from table where value < {min}`. To escape real `{`/`}` that you don't want parameterized, use double `{{...}}`: @@ -131,6 +195,275 @@ To escape real `{`/`}` that you don't want parameterized, use double `{{...}}`: SELECT unnest([{{'a': 42, 'b': 84}}, {{'a': 100, 'b': NULL}}]); ``` +## Connecting to a custom database + +There are two ways to connect to a database in marimo: + +### Using the UI + +Click the "Add Database Connection" button in your notebook to connect to PostgreSQL, MySQL, SQLite, DuckDB, Snowflake, or BigQuery databases. The UI will guide you through entering your connection details securely. Environment variables picked up from your [`dotenv`](../configuration/runtime_configuration.md#environment-variables) can be used to fill out the database configuration fields. + +
+
+ +
Add a database connection through the UI
+
+
+ +#### Detecting data sources from your environment + +marimo scans your kernel's environment for connection details it recognizes and offers them as **Quick add** suggestions. +Click a suggestion to insert a ready-to-run cell. + +
+
+ +
+
+ +| Integration | Detected from | +| --- | --- | +| PostgreSQL | `PGHOST`, `PGUSER`, `PGDATABASE` (plus `PGPORT`, `PGPASSWORD`) | +| MySQL | `MYSQL_HOST`, `MYSQL_USER`, `MYSQL_DATABASE`, and `MYSQL_PASSWORD`/`MYSQL_PWD` (plus `MYSQL_TCP_PORT`) | +| Trino | `TRINO_HOST`, `TRINO_USER`, `TRINO_CATALOG` (plus `TRINO_PORT`, `TRINO_PASSWORD`, `TRINO_SCHEMA`) | +| PySpark (Spark Connect) | `SPARK_REMOTE` | +| PyIceberg catalogs | `PYICEBERG_CATALOG____*` environment variables, or catalogs resolved from a `.pyiceberg.yaml` file | + +Only the *names* of environment variables are shown in the UI — their values are never sent to the frontend. + +If you'd like to connect to a database that isn't supported by the UI, you can use the code method below, or submit a [feature request](https://github.com/marimo-team/marimo/issues/new?title=New%20database%20connection:&labels=enhancement&template=feature_request.yaml). + +### Using code + +You can bring your own database via a **connection engine** with one of the following libraries + +- [SQLAlchemy](https://docs.sqlalchemy.org/en/20/core/connections.html#basic-usage) +- [SQLModel](https://sqlmodel.tiangolo.com/tutorial/create-db-and-table/?h=create+engine#create-the-engine) +- [Ibis](https://ibis-project.org/backends/athena) +- [Custom DuckDB connection](https://duckdb.org/docs/api/python/overview.html#connection-options) +- [ClickHouse Connect](https://clickhouse.com/docs/integrations/python#introduction) +- [chDB](https://clickhouse.com/docs/chdb) + +By default, marimo uses the [in-memory duckdb connection](https://duckdb.org/docs/connect/overview.html#in-memory-database). + +??? info "List of supported databases" + + Updated: 2025-04-30. This list is not exhaustive. + + | Database | Library | + | -------------------------- | ---------------------------------- | + | Amazon Athena | `sqlalchemy`, `sqlmodel`, `ibis` | + | Amazon Redshift | `sqlalchemy`, `sqlmodel` | + | Apache Drill | `sqlalchemy`, `sqlmodel` | + | Apache Druid | `sqlalchemy`, `sqlmodel`, `ibis` | + | Apache Hive and Presto | `sqlalchemy`, `sqlmodel` | + | Apache Solr | `sqlalchemy`, `sqlmodel` | + | BigQuery | `sqlalchemy`, `sqlmodel`, `ibis` | + | ClickHouse | `clickhouse_connect`, `chdb` | + | CockroachDB | `sqlalchemy`, `sqlmodel` | + | Databricks | `sqlalchemy`, `sqlmodel`, `ibis` | + | dlt | `ibis` | + | Datafusion | `ibis` | + | DuckDB | `duckdb` | + | EXASolution | `sqlalchemy`, `sqlmodel`, `ibis` | + | Elasticsearch (readonly) | `sqlalchemy`, `sqlmodel` | + | Firebolt | `sqlalchemy`, `sqlmodel` | + | Flink | `ibis` | + | Google Sheets | `sqlalchemy`, `sqlmodel` | + | Impala | `sqlalchemy`, `sqlmodel`, `ibis` | + | Microsoft Access | `sqlalchemy`, `sqlmodel` | + | Microsoft SQL Server | `sqlalchemy`, `sqlmodel`, `ibis` | + | MonetDB | `sqlalchemy`, `sqlmodel` | + | MySQL | `sqlalchemy`, `sqlmodel`, `ibis` | + | OpenGauss | `sqlalchemy`, `sqlmodel` | + | Oracle | `sqlalchemy`, `sqlmodel`, `ibis` | + | PostgreSQL | `sqlalchemy`, `sqlmodel`, `ibis` | + | PySpark | `ibis` | + | RisingWave | `ibis` | + | SAP HANA | `sqlalchemy`, `sqlmodel` | + | Snowflake | `sqlalchemy`, `sqlmodel`, `ibis` | + | SQLite | `sqlalchemy`, `sqlmodel`, `ibis` | + | Teradata Vantage | `sqlalchemy`, `sqlmodel` | + | TimePlus | `sqlalchemy`, `sqlmodel` | + | Trino | `sqlalchemy`, `sqlmodel`, `ibis` | + +Define the engine as a Python variable in a cell: + +/// tab | SQLAlchemy + +```python +import sqlalchemy + +# Create an in-memory SQLite database with SQLAlchemy +sqlite_engine = sqlalchemy.create_engine("sqlite:///:memory:") +``` + +/// + +/// tab | SQLModel + +```python +import sqlmodel + +# Create an in-memory SQLite database with SQLModel +sqlite_engine = sqlmodel.create_engine("sqlite:///:memory:") +``` + +/// + +/// tab | Ibis + +```python +import ibis + +# Create an in-memory SQLite database with Ibis +sqlite_engine = ibis.connect("sqlite:///:memory:") +``` + +/// + +/// tab | DuckDB + +```python +import duckdb + +# Create a DuckDB connection +duckdb_conn = duckdb.connect("file.db") +``` + +/// + +/// tab | ClickHouse Connect + +ClickHouse Connect enables remote connections to ClickHouse databases. Refer to [the official docs](https://clickhouse.com/docs/integrations/python#gather-your-connection-details) for more configuration options. + +```python +import clickhouse_connect + +engine = clickhouse_connect.get_client(host="localhost", port=8123, username="default", password="password") +``` + +/// + +/// tab | chDB + +!!! warning + + chDB is still new. You may experience issues with your queries. We recommend only using one connection at a time. + Refer to [chDB docs](https://github.com/orgs/chdb-io/discussions/295) for more information. + +```python +import chdb + +connection = chdb.connect(":memory:") + +# Supported formats with examples: +":memory:" # In-memory database +"test.db" # Relative path +"file:test.db" # Explicit file protocol +"/path/to/test.db" # Absolute path +"file:/path/to/test.db" # Absolute path with protocol +"file:test.db?param1=value1¶m2=value2" # With query parameters +"file::memory:?verbose&log-level=test" # In-memory with parameters +"///path/to/test.db?param1=value1" # Triple slash absolute path +``` + +/// + +marimo will auto-discover the engine and let you select it in the SQL cell's connection dropdown. + +
+
+ +
Choose a custom database connection
+
+
+ +## Database, schema, and table auto-discovery + +marimo will automatically discover the database connection and display the database, schemas, tables, and columns in the Data Sources panel. This panels lets you quickly navigate your database schema and reference tables and columns to pull in your SQL queries. + +
+
+ +
Data Sources panel
+
+
+ +???+ note + + By default, marimo auto-discovers databases and schemas, but not tables and columns (to avoid performance issues with large databases). You can configure this behavior in your `pyproject.toml` file. Options are `true`, `false`, or `"auto"`. `"auto"` will determine whether to auto-discover based on the type of database (e.g. when the value is `"auto"`, Snowflake and BigQuery will not auto-discover tables and columns while SQLite, Postgres, and MySQL will): + + ```toml title="pyproject.toml" + [tool.marimo.datasources] + auto_discover_schemas = true # Default: true + auto_discover_tables = "auto" # Default: "auto" + auto_discover_columns = "auto" # Default: false + ``` + +## Catalogs + +marimo supports connecting to Iceberg catalogs. You can click the "+" button in the Datasources panel or manually create a [PyIceberg](https://py.iceberg.apache.org/) `Catalog` connection. PyIceberg supports a variety of catalog implementations including REST, SQL, Glue, DynamoDB, and more. + +```python +from pyiceberg.catalog.rest import RestCatalog + +catalog = RestCatalog( + name="catalog", + warehouse="1234567890", + uri="https://my-catalog.com", + token="my-token", +) +``` + +Catalogs will appear in the Datasources panel, but they cannot be used as an engine in SQL cells. However, you can still load the table and use it in subsequent Python or SQL cells. + +```python +df = catalog.load_table(("my-namespace", "my-table")).to_polars() +``` + +```sql +SUMMARIZE df; +``` + +## Utilities + +marimo provides a few utilities when working with SQL + +**SQL Linter** + +Lint your SQL code and provide better autocompletions and error highlighting. + +
+
+ +
+
+ +To disable the linter, you can set the `sql_linter` configuration to `false` in your `pyproject.toml` file or disable it in the marimo editor's settings menu. + +**SQL Formatting** + +Click on the paint roller icon at the bottom right of the SQL cell to format your SQL code. + +
+
+ +
+
+ +**SQL Mode** + +For In-Memory DuckDB, marimo offers a Validate mode that will validate your SQL as you write it. + +
+ +
+ +Under the hood, this runs a debounced query in EXPLAIN mode and returns the parsed errors. + ## Interactive tutorial For an interactive tutorial, run diff --git a/docs/hooks.py b/docs/hooks.py new file mode 100644 index 00000000000..c6c44401c28 --- /dev/null +++ b/docs/hooks.py @@ -0,0 +1,131 @@ +import re +from pathlib import Path +from typing import Any + + +def on_page_markdown(markdown: str, page: Any = None, **kwargs: Any) -> str: + del kwargs + static_dir = Path("docs/") + if not static_dir.exists(): + return markdown + + # Find all src="/" patterns + pattern = r'src="(/[^"]+)"' + matches = re.finditer(pattern, markdown) + + for match in matches: + src_path = match.group(1) + # Remove leading slash and check if file exists in _static + relative_path = src_path.lstrip("/") + full_path = static_dir / relative_path + + if not full_path.exists(): + print(f"\u26a0\ufe0f Warning: Static asset not found: {src_path}") + print(f" Expected at: {full_path}") + + # Auto-generate meta description from page content if not set + if page and not page.meta.get("description"): + description = _extract_description(markdown) + if description: + page.meta["description"] = description + + return markdown + + +def _extract_description(markdown: str) -> str: + """Extract a meta description from the first meaningful paragraph.""" + # Strip HTML blocks (style, script, div, p, table, video, etc.) before parsing + cleaned = re.sub( + r"<(style|script|video|table|figure)[^>]*>.*?", + "", + markdown, + flags=re.DOTALL | re.IGNORECASE, + ) + + lines = cleaned.split("\n") + paragraph_lines: list[str] = [] + in_frontmatter = False + past_frontmatter = False + in_code_block = False + in_admonition = False + + for line in lines: + stripped = line.strip() + + # Skip YAML frontmatter + if stripped == "---": + if not past_frontmatter: + in_frontmatter = not in_frontmatter + if not in_frontmatter: + past_frontmatter = True + continue + if in_frontmatter: + continue + + # Skip code blocks + if stripped.startswith("```"): + in_code_block = not in_code_block + continue + if in_code_block: + continue + + # Skip headings + if stripped.startswith("#"): + # If we already collected paragraph text, stop + if paragraph_lines: + break + continue + + # Skip HTML tags, admonitions, and directives + if stripped.startswith("<") or stripped.startswith("///"): + if stripped.startswith("///"): + in_admonition = not in_admonition + if paragraph_lines: + break + continue + if in_admonition: + continue + + # Skip empty lines + if not stripped: + if paragraph_lines: + break + continue + + # Skip markdown images and links-only lines + if re.match(r"^!?\[.*\]\(.*\)$", stripped): + continue + + # Skip lines starting with ??? or !!! (admonition shorthand) + if stripped.startswith("???") or stripped.startswith("!!!"): + if paragraph_lines: + break + continue + + # Skip lines that are just formatting (bold/italic markers, list items) + if re.match(r"^[*_\-|]", stripped) and not re.match( + r"^[*_]{1,2}\w", stripped + ): + if paragraph_lines: + break + continue + + paragraph_lines.append(stripped) + + if not paragraph_lines: + return "" + + text = " ".join(paragraph_lines) + + # Remove markdown formatting + text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) # [text](url) -> text + text = re.sub(r"`([^`]+)`", r"\1", text) # `code` -> code + text = re.sub(r"[*_]{1,2}([^*_]+)[*_]{1,2}", r"\1", text) # bold/italic + text = re.sub(r"\s+", " ", text).strip() # normalize whitespace + + # Truncate to ~155 characters at a word boundary + max_length = 155 + if len(text) > max_length: + text = text[:max_length].rsplit(" ", 1)[0] + "..." + + return text diff --git a/docs/index.md b/docs/index.md index c590e02a38b..88dd23a5f30 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,23 +1,40 @@ --- +title: marimo - Documentation for the Reactive Python Notebook +description: "marimo is an open-source reactive Python notebook. Run a cell or interact with a UI element, and marimo automatically runs dependent cells, keeping code and outputs consistent." hide: - navigation --- +# marimo: The Reactive Python Notebook +

- + marimo - reactive Python notebook

-marimo is a reactive Python notebook: run a cell or interact with a UI -element, and marimo automatically runs dependent cells (or [marks them as -stale](guides/reactivity.md#runtime-configuration)), keeping code and outputs -consistent and preventing bugs before they happen. Every marimo notebook is -stored as pure Python, executable as a script, and deployable as an app. +marimo is an [open-source](https://github.com/marimo-team/marimo) reactive +Python notebook: run a cell or interact with a UI element, and marimo +automatically runs dependent cells (or [marks them as +stale](guides/reactivity.md#configuring-how-marimo-runs-cells)), keeping code +and outputs consistent and preventing bugs before they happen. Every marimo +notebook is stored as pure Python (Git-friendly), [executable as a script](guides/scripts.md), and +[deployable as an app](guides/apps.md); while stored as Python, marimo notebooks also have [native +support for SQL](guides/working_with_data/sql.md). /// admonition | Built from the ground up type: tip @@ -25,6 +42,11 @@ stored as pure Python, executable as a script, and deployable as an app. marimo was built from the ground up to solve well-known problems associated with traditional notebooks. /// +_Get started instantly with [molab, our free online +notebook](https://molab.marimo.io/notebooks). Or get started +locally by installing marimo with your favorite package manager:_ + + /// tab | install with pip ```bash @@ -36,7 +58,7 @@ pip install marimo && marimo tutorial intro /// tab | install with uv ```bash -uv pip install marimo && marimo tutorial intro +uv add marimo && uv run marimo tutorial intro ``` /// @@ -52,17 +74,22 @@ conda install -c conda-forge marimo && marimo tutorial intro Developer experience is core to marimo, with an emphasis on reproducibility, maintainability, composability, and shareability. + ## Highlights - 🚀 **batteries-included:** replaces `jupyter`, `streamlit`, `jupytext`, `ipywidgets`, `papermill`, and more - ⚡️ **reactive**: run a cell, and marimo reactively [runs all dependent cells](guides/reactivity.md) or marks them as stale - 🖐️ **interactive:** [bind sliders, tables, plots, and more](guides/interactivity.md) to Python — no callbacks required -- 🔬 **reproducible:** [no hidden state](guides/reactivity.md#no-hidden-state), deterministic execution, [built-in package management](guides/editor_features/package_management.md) +- 🐍 **git-friendly:** stored as `.py` files +- 🛢️ **designed for data**: query dataframes, databases, warehouses, and lakehouses [with SQL](guides/working_with_data/sql.md); filter and search [dataframes](guides/working_with_data/dataframes.md) +- 🤖 **AI-native**: [pair with AI agents](https://marimo.io/pair) like Claude Code, or use our editor's [built-in AI features](guides/editor_features/ai_completion/) +- 🔬 **reproducible:** [no hidden state](guides/reactivity.md), deterministic execution, [built-in package management](guides/editor_features/package_management.md) - 🏃 **executable:** [execute as a Python script](guides/scripts.md), parameterized by CLI args - 🛜 **shareable**: [deploy as an interactive web app](guides/apps.md) or [slides](guides/apps.md#slides-layout), [run in the browser via WASM](guides/wasm.md) -- 🛢️ **designed for data**: query dataframes and databases [with SQL](guides/working_with_data/sql.md), filter and search [dataframes](guides/working_with_data/dataframes.md) -- 🐍 **git-friendly:** notebooks are stored as `.py` files -- ⌨️ **a modern editor**: [GitHub Copilot](guides/editor_features/ai_completion.md#github-copilot), [AI assistants](guides/editor_features/ai_completion.md#using-ollama), vim keybindings, variable explorer, and [more](guides/editor_features/index.md) +- 🧩 **reusable:** [import functions and classes](guides/reusing_functions.md) from one notebook to another +- 🧪 **testable:** [run pytest](guides/testing/index.md) on notebooks +- ⌨️ **a modern editor**: [GitHub Copilot](guides/editor_features/ai_completion.md#github-copilot), [AI assistants](guides/editor_features/ai_completion.md), [vim keybindings](guides/editor_features/overview.md#vim-keybindings), variable explorer, and [more](guides/editor_features/index.md) +- 🧑‍💻 **use your favorite editor**: run in [VS Code or Cursor](https://marketplace.visualstudio.com/items?itemName=marimo-team.vscode-marimo), or edit in neovim, Zed, [or any other text editor](https://docs.marimo.io/guides/editor_features/watching/) ## A reactive programming environment @@ -74,7 +101,10 @@ reference its variables, eliminating the error-prone task of manually re-running cells. Delete a cell and marimo scrubs its variables from program memory, eliminating hidden state. - + @@ -92,46 +122,74 @@ transformers](api/inputs/dataframe.md), and [chat interfaces](api/inputs/chat.md), and the cells that use them are automatically re-run with their latest values. - + **Interactive dataframes.** [Page through, search, filter, and -sort](https://docs.marimo.io/guides/working_with_data/dataframes.html) +sort](./guides/working_with_data/dataframes.md) millions of rows blazingly fast, no code required. - + -**Performant runtime.** marimo runs only those cells that need to be run by -statically analyzing your code. -**Dynamic markdown and SQL.** Use markdown to tell dynamic stories that depend on -Python data. Or build [SQL](guides/working_with_data/sql.md) queries -that depend on Python values and execute them against dataframes, databases, +**Generate cells with data-aware AI.** Collaborate on marimo notebooks with +your favorite agent, such as Claude Code, Codex, or OpenCode, using [marimo +pair](guides/generate_with_ai/marimo_pair/). Or, +generate code [in the marimo editor with an AI +assistant](guides/editor_features/ai_completion/) that +is highly specialized for working with data, with context about your variables +in memory. Customize the system prompt, bring your own API keys, or use local +models. + + + +**Query data with SQL.** Build [SQL](https://docs.marimo.io/guides/working_with_data/sql.html) queries +that depend on Python values and execute them against dataframes, databases, lakehouses, CSVs, Google Sheets, or anything else using our built-in SQL engine, which returns the result as a Python dataframe. - +SQL cell in marimo querying a dataframe -Your notebooks are still pure Python, even if they use markdown or SQL. +Your notebooks are still pure Python, even if they use SQL. -**Deterministic execution order.** Notebooks are executed in a deterministic -order, based on variable references instead of cells' positions on the page. -Organize your notebooks to best fit the stories you'd like to tell. +**Dynamic markdown.** Use markdown parametrized by Python variables to tell +dynamic stories that depend on Python data. **Built-in package management.** marimo has built-in support for all major -package managers, letting you install packages on import. marimo can even +package managers, letting you [install packages on import](guides/editor_features/package_management.md). marimo can even [serialize package -requirements](guides/editor_features/package_management.md#auto-tracking-inline-script-metadata) +requirements](guides/package_management/inlining_dependencies.md) in notebook files, and auto install them in -[isolated venv sandboxes](guides/editor_features/package_management.md#running-marimo-in-a-sandbox-environment-uv-only). +isolated venv sandboxes. + +**Deterministic execution order.** Notebooks are executed in a deterministic +order, based on variable references instead of cells' positions on the page. +Organize your notebooks to best fit the stories you'd like to tell. + +**Performant runtime.** marimo runs only those cells that need to be run by +statically analyzing your code. -**Batteries-included.** marimo comes with GitHub Copilot, AI assistants, Ruff +**Batteries-included.** marimo comes with [GitHub Copilot](guides/editor_features/ai_completion.md#github-copilot), AI assistants, Ruff code formatting, HTML export, fast code completion, a [VS Code extension](https://marketplace.visualstudio.com/items?itemName=marimo-team.vscode-marimo), +a [PyCharm plugin](https://plugins.jetbrains.com/plugin/32416-marimo), an interactive dataframe viewer, and [many more](guides/editor_features/index.md) quality-of-life features. ## Quickstart +_The [marimo concepts +playlist](https://www.youtube.com/watch?v=3N6lInzq5MI&list=PLNJXGo8e1XT9jP7gPbRdm1XwloZVFvLEq) +on our [YouTube channel](https://www.youtube.com/@marimo-team) gives an +overview of many features._ + **Installation.** In a terminal, run ```bash @@ -139,6 +197,13 @@ pip install marimo # or conda install -c conda-forge marimo marimo tutorial intro ``` +To install with additional dependencies that unlock SQL cells, AI completion, and more, +run + +```bash +pip install marimo[recommended] +``` + **Create notebooks.** Create or edit notebooks with @@ -147,14 +212,17 @@ Create or edit notebooks with marimo edit ``` -**Run apps.** Run your notebook as a web app, with Python -code hidden and uneditable: +**Run apps.** Run your notebook as a web app. By default, the Python +source code is not sent to the browser, so it stays hidden and uneditable: ```bash marimo run your_notebook.py ``` - + **Execute as scripts.** Execute a notebook as a script at the command line: @@ -179,6 +247,11 @@ List all tutorials: marimo tutorial --help ``` +**Share cloud-based notebooks.** Use +[molab](https://molab.marimo.io/notebooks), a cloud-based marimo notebook +service similar to Google Colab, to create and share notebook links. + + ## Questions? See our [FAQ](faq.md). @@ -187,70 +260,84 @@ See our [FAQ](faq.md). marimo is easy to get started with, with lots of room for power users. For example, here's an embedding visualizer made in marimo -([video](https://marimo.io/videos/landing/full.mp4)): +([try the notebook live on molab!](https://molab.marimo.io/notebooks/nb_jJiFFtznAy4BxkrrZA1o9b/app?show-code=true)): - + -Check out our [guides](guides/index.md), our [example -gallery](https://marimo.io/gallery), and our -[`examples/`](https://github.com/marimo-team/marimo/tree/main/examples) on -GitHub to learn more. +Check out our [guides](guides/index.md), [usage examples](examples/index.md), +and our [gallery](https://marimo.io/gallery) to learn more. - - + @@ -275,9 +362,17 @@ We're building a community. Come hang out with us! - ✏️ [Start a GitHub Discussion](https://github.com/marimo-team/marimo/discussions) - 💬 [Follow us on Bluesky](https://bsky.app/profile/marimo.io) - 🐦 [Follow us on Twitter](https://twitter.com/marimo_io) +- 🎥 [Subscribe on YouTube](https://www.youtube.com/@marimo-team) - 💬 [Follow us on Mastodon](https://mastodon.social/@marimo_io) +- 🤖 [Follow us on Reddit](https://www.reddit.com/r/marimo_notebook) - 🕴️ [Follow us on LinkedIn](https://www.linkedin.com/company/marimo-io) +**A NumFOCUS affiliated project.** marimo is a core part of the broader Python +ecosystem and is a member of the NumFOCUS community, which includes projects +such as NumPy, SciPy, and Matplotlib. + +NumFOCUS affiliated project + ## Inspiration ✨ marimo is a **reinvention** of the Python notebook as a reproducible, interactive, @@ -301,6 +396,13 @@ a greater movement toward reactive dataflow programming. From declarative, and reactive programming are transforming a broad range of tools for the better. +Finally, we would like to acknowledge [Bennet +Meyers](https://bmeyers.github.io/about/) and [David +Chassin](https://www.chassin.org/about/) for believing in marimo from the very +beginning: this work was supported in part by U.S. DOE Office of Critical +Minerals and Energy Innovation (CMEI) Integrated Energy Systems Office (IESO), +Agreement 34368. +

- + marimo logo

diff --git a/docs/integrations/motherduck.md b/docs/integrations/motherduck.md index 6028b350183..091747ed08e 100644 --- a/docs/integrations/motherduck.md +++ b/docs/integrations/motherduck.md @@ -17,7 +17,7 @@ pip install "marimo[sql]" /// tab | install with uv ```bash -uv pip install "marimo[sql]" +uv add "marimo[sql]" ``` /// @@ -28,25 +28,40 @@ uv pip install "marimo[sql]" conda install -c conda-forge marimo duckdb polars ``` +/// + To connect to MotherDuck, import `duckdb` and `ATTACH` your MotherDuck database. +## Using MotherDuck + +### 1. Connecting and Database Discovery + +/// tab | SQL + +```sql +ATTACH IF NOT EXISTS 'md:my_db' +``` + +/// + +/// tab | Python + ```python import duckdb - # Connect to MotherDuck duckdb.sql("ATTACH IF NOT EXISTS 'md:my_db'") ``` -You will be prompted to authenticate with MotherDuck when you run the above cell. This will open a browser window where you can log in and authorize marimo to access your MotherDuck database. In order to avoid being prompted each time you open a notebook, you can set the `motherduck_token` environment variable: +/// + +You will be prompted to authenticate with MotherDuck when you run the above cell. This will open a browser window where you can log in and authorize your marimo notebook to access your MotherDuck database. In order to avoid being prompted each time you open a notebook, you can set the `motherduck_token` environment variable: ```bash -export motherduck_token="your_motherduck_token_here" +export motherduck_token="your_token" marimo edit ``` -You can obtain this token from your MotherDuck account settings. - -Once you've authenticated, your MotherDuck tables get automatically discovered and you can browse them from the Datasources Panel. +Once connected, your MotherDuck tables are automatically discovered in the Datasources Panel:
@@ -55,9 +70,9 @@ Once you've authenticated, your MotherDuck tables get automatically discovered a
-## 2. Querying Your Tables +### 2. Writing SQL Queries -Once connected, you can query your MotherDuck tables using SQL. Here's an example of how to query a table and display the results using marimo: +You can query your MotherDuck tables using SQL cells in marimo. Here's an example of how to query a table and display the results using marimo:
@@ -66,11 +81,11 @@ Once connected, you can query your MotherDuck tables using SQL. Here's an exampl
-marimo's reactive execution model extends into SQL queries, so changes to your SQL will automatically trigger downstream computations for dependent cells. +marimo's reactive execution model extends into SQL queries, so changes to your SQL will automatically trigger downstream computations for dependent cells (or optionally mark cells as stale for expensive computations). -## 3. Mixing SQL and Python +### 3. Mixing SQL and Python MotherDuck allows you to seamlessly mix SQL queries with Python code, enabling powerful data manipulation and analysis. Here's an example: @@ -83,7 +98,7 @@ MotherDuck allows you to seamlessly mix SQL queries with Python code, enabling p This example demonstrates how you can use SQL to query your data, then use Python and marimo to further analyze and visualize the results. -## Example +## Example Notebook For a full example of using MotherDuck with marimo, check out our [MotherDuck example notebook](https://github.com/marimo-team/marimo/blob/main/examples/sql/connect_to_motherduck.py). diff --git a/docs/overrides/main.html b/docs/overrides/main.html index 7a6be2f23f4..3e1c0a7cd06 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -3,13 +3,91 @@ {% include ".icons/material/new-box.svg" %} New: Check out our latest features in the
documentation! {% endblock %} {% block extrahead %} {{ super() }} - + - + + +{% if page.meta and page.meta.description %} + +{% elif config.site_description %} + +{% endif %} + + + + + + + + + + + +{% if page and page.url and page.url != "." and page.url != "./" %} + +{% endif %} + {% endblock %} {% block scripts %} {{ super() }} {% endblock %} diff --git a/docs/pyproject.toml b/docs/pyproject.toml index 60c361e5955..3dbe858cfa4 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -1,11 +1,12 @@ [build-system] -build-backend = "hatchling.build" -requires = ["hatchling"] +build-backend = "uv_build" +requires = ["uv_build>=0.8.3,<0.11.0"] [project] -dependencies = [] -name = "docs" -version = "0.0.1" +dependencies = ["lzstring>=1.0.4"] +name = "marimo_docs" +version = "0.1.0" -[tool.hatch.build.targets.wheel] -include = ["blocks.py"] +[tool.uv.build-backend] +module-name = "blocks" +module-root = "" diff --git a/docs/reading.md b/docs/reading.md new file mode 100644 index 00000000000..1a03b988336 --- /dev/null +++ b/docs/reading.md @@ -0,0 +1,31 @@ +# Resources + +marimo is a **reinvention** of the Python notebook. As a reinvention, marimo +may push to you **rethink** what a notebook **is**. The following readings and +videos might help you do just that. + +## Readings + +To dive deep into what we're building and why, check out these readings: + +* [Our HackerNews launch, the second most upvoted Python ShowHN of all time](https://news.ycombinator.com/item?id=38971966) +* [Our r/machinelearning launch](https://www.reddit.com/r/MachineLearning/comments/191rdwq/p_i_built_marimo_an_opensource_reactive_python/) +* [Lessons Learned Reinventing the Python Notebook](https://marimo.io/blog/lessons-learned) +* [Why Stanford Scientists Needed a New Notebook](https://marimo.io/blog/slac-marimo) +* [Reinventing Python Notebooks as Reusable Python Programs](https://marimo.io/blog/python-not-json) +* [Representing Python Notebooks as Dataflow Graphs](https://marimo.io/blog/dataflow) +* [Nature: a Notebook for Reproducible Code](https://www.nature.com/articles/d41586-025-01241-6) + +See [our blog](https://marimo.io/blog) for more. + +## YouTube + +Our [YouTube channel](https://www.youtube.com/@marimo-team) shows you the ins and outs of using marimo for many applications, +including AI, ML, data engineering, and more. You can also get started with the +[marimo concepts](https://www.youtube.com/watch?v=3N6lInzq5MI&list=PLNJXGo8e1XT9jP7gPbRdm1XwloZVFvLEq) playlist, which tours many of our features. + + + +You may also enjoy this video, which highlights some of the more advanced features that can really help you get the most out of marimo. + + diff --git a/docs/recipes.md b/docs/recipes.md index 1377af2b6de..c3eddb89eff 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -1,3 +1,7 @@ +--- +description: "Code snippets and recipes for common marimo tasks: control flow, layout, data handling, state management, and more." +--- + # Recipes This page includes code snippets or "**recipes**" for a variety of common tasks. @@ -394,7 +398,7 @@ form.value ### Populating form with pre-defined examples **Use cases.** To give examples of how a filled form looks like. Useful for illustrating complex API requests or database queries. -The form can also be populated from [URL query parameters](https://docs.marimo.io/api/query_params.html) (notebook example). +The form can also be populated from [URL query parameters](./api/query_params.md) (notebook example). **Recipe.** diff --git a/docs/scripts/generate_llms_txt.py b/docs/scripts/generate_llms_txt.py new file mode 100644 index 00000000000..c1702ac0bab --- /dev/null +++ b/docs/scripts/generate_llms_txt.py @@ -0,0 +1,283 @@ +"""Generate an `llms.txt` index from the built docs. + +Reads structure and page titles from the mkdocs `nav`, and page content from +the per-page markdown emitted by `html_to_markdown.py` (an `index.md` written +beside every `index.html`), then writes a compact [llmstxt.org](https://llmstxt.org) +index: a title, a blurb, and one section per top-level nav group, each a bullet +list linking to the `.md` version of every page. + +Run this after `html_to_markdown.py` so the `.md` files exist: + + python docs/scripts/generate_llms_txt.py \\ + --input-dir .vercel/output/static \\ + --base-url https://docs.marimo.io \\ + --output-index .vercel/output/static/llms.txt +""" + +# /// script +# requires-python = ">=3.10" +# dependencies = ["mkdocs>=1.6.1"] +# /// + +import argparse +import re +from pathlib import Path + +# mkdocs' loader understands the custom tags (!ENV, !relative) in mkdocs.yml; +# a plain yaml.safe_load would choke on them. mkdocs is always installed here +# (this script only runs under `uv run --group docs`). +from mkdocs.utils import yaml_load + + +def load_yaml(path: Path) -> dict: + with open(path, encoding="utf-8") as f: + return yaml_load(f) + + +# Cap each page's one-line description so the index stays a compact, scannable +# map; anything longer is truncated with an ellipsis (see extract_description). +MAX_DESCRIPTION_LEN = 100 + + +class Leaf: + """A single documentation page reachable from the nav.""" + + def __init__(self, title: str | None, path: str): + self.title = title + self.path = path + + @property + def is_external(self) -> bool: + return self.path.startswith(("http://", "https://")) + + +def walk(entry: object) -> list[Leaf]: + """Flatten a nav subtree into an ordered list of leaves.""" + leaves: list[Leaf] = [] + if isinstance(entry, str): + leaves.append(Leaf(None, entry)) + elif isinstance(entry, dict): + for title, value in entry.items(): + if isinstance(value, str): + leaves.append(Leaf(title, value)) + elif isinstance(value, list): + for sub in value: + leaves.extend(walk(sub)) + return leaves + + +def parse_nav(nav: list) -> tuple[list[Leaf], list[tuple[str, list[Leaf]]]]: + """Split the top-level nav into a prelude and titled sections. + + Returns `(prelude, sections)` where `prelude` is the top-level pages that + aren't grouped under a section, and `sections` is `(title, leaves)` pairs. + """ + prelude: list[Leaf] = [] + sections: list[tuple[str, list[Leaf]]] = [] + for entry in nav: + if isinstance(entry, str): + prelude.append(Leaf(None, entry)) + elif isinstance(entry, dict): + for title, value in entry.items(): + if isinstance(value, str): + prelude.append(Leaf(title, value)) + elif isinstance(value, list): + leaves = [leaf for sub in value for leaf in walk(sub)] + sections.append((title, leaves)) + return prelude, sections + + +def resolve(path: str, input_dir: Path, base_url: str) -> tuple[Path, str]: + """Map a nav source path to its built `.md` file and public `.md` URL. + + mkdocs (with `use_directory_urls`) builds `foo/bar.md` to `foo/bar/index.html` + and `foo/index.md` to `foo/index.html`; `html_to_markdown.py` writes an + `index.md` beside each. We link to the pretty `.md` URL, e.g. + `guides/reactivity.md` -> `/guides/reactivity.md`. + """ + rel = path.removesuffix(".md") + if rel == "index" or rel.endswith("/index"): + disk = input_dir / f"{rel}.md" + route = "" if rel == "index" else rel[: -len("/index")] + else: + disk = input_dir / rel / "index.md" + route = rel + url = f"{base_url}/{route}.md" if route else f"{base_url}/index.md" + return disk, url + + +def strip_markdown(text: str) -> str: + """Reduce inline markdown to plain-ish text for a one-line description.""" + text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text) # images + text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text) # links -> text + text = re.sub(r"[`*_]", "", text) # emphasis / code marks + text = text.replace("¶", "") # stray pilcrows + return re.sub(r"\s+", " ", text).strip() + + +def extract_h1(md: str) -> str | None: + for line in md.splitlines(): + s = line.strip() + if s.startswith("# "): + return strip_markdown(s[2:]) + return None + + +def extract_description(md: str) -> str: + """First prose paragraph after the H1, collapsed to a single line. + + Skips fenced code blocks (e.g. mkdocstrings signatures), headings, and + other block markers so the description is real summary prose. + """ + para: list[str] = [] + in_fence = False + for line in md.splitlines(): + s = line.strip() + if s.startswith("```"): + in_fence = not in_fence + if para: + break + continue + if in_fence: + continue + if not s: + if para: + break + continue + # Skip source comment, headings, block markers, and the mkdocstrings + # "Bases: ..." line, until real summary prose starts. + if s.startswith(("\n\n" + return header + md + + +def main(): + parser = argparse.ArgumentParser(description="Convert mkdocs HTML to markdown") + parser.add_argument( + "--input-dir", + type=Path, + required=True, + help="Path to the built site directory", + ) + parser.add_argument( + "--base-url", + type=str, + default="https://docs.marimo.io", + help="Base URL for resolving links", + ) + args = parser.parse_args() + + input_dir: Path = args.input_dir + base_url: str = args.base_url + + if not input_dir.exists(): + print(f"Error: input directory {input_dir} does not exist") + raise SystemExit(1) + + converted = 0 + skipped = 0 + + for root, _dirs, files in os.walk(input_dir): + for filename in files: + if filename != "index.html": + continue + + html_path = Path(root) / filename + md = convert_page(html_path, input_dir, base_url) + + if md is None: + skipped += 1 + continue + + # Write markdown alongside the HTML + md_path = html_path.with_name("index.md") + with open(md_path, "w", encoding="utf-8") as f: + f.write(md) + + converted += 1 + + print(f"Converted {converted} pages, skipped {skipped}") + + +if __name__ == "__main__": + main() diff --git a/docs/scripts/middleware.js b/docs/scripts/middleware.js new file mode 100644 index 00000000000..c54589ccdb3 --- /dev/null +++ b/docs/scripts/middleware.js @@ -0,0 +1,91 @@ +/** + * Vercel Edge Middleware for serving markdown versions of docs pages. + * + * Handles two cases: + * 1. Accept: text/markdown header -> rewrite to serve .md file + * 2. URL ending in .md -> serve the corresponding .md file + * + * Testing (deployed): + * + * # Via Accept header (content negotiation) + * curl -H "Accept: text/markdown" https://docs.marimo.io/guides/reactivity/ + * + * # Via .md URL extension + * curl https://docs.marimo.io/guides/reactivity.md + * + * Testing (local build): + * + * # 1. Build docs and generate markdown + * make docs + * python docs/scripts/html_to_markdown.py --input-dir site --base-url https://docs.marimo.io + * + * # 2. Verify .md files were created + * cat site/guides/reactivity/index.md + * + * Note: The middleware only runs on Vercel (edge runtime), so Accept header + * negotiation can't be tested locally. The .md files can be served directly + * by any static file server, e.g.: + * cd site && python -m http.server 8000 + * curl http://localhost:8000/guides/reactivity/index.md + */ +export default async function middleware(request) { + const url = new URL(request.url); + const pathname = url.pathname; + + // Already a direct .md static file request (e.g. /guides/reactivity/index.md) + // — let Vercel serve it as-is to avoid a rewrite loop. + if (pathname.endsWith("/index.md")) { + return; + } + + const acceptHeader = request.headers.get("accept") || ""; + const wantsMarkdown = acceptHeader.includes("text/markdown"); + const isMdUrl = pathname.endsWith(".md"); + + if (!wantsMarkdown && !isMdUrl) { + return; + } + + // Determine the .md file path + let mdPath; + if (isMdUrl) { + // /guides/reactivity.md -> /guides/reactivity/index.md + mdPath = pathname.replace(/\.md$/, "/index.md"); + } else { + // /guides/reactivity/ -> /guides/reactivity/index.md + mdPath = pathname.endsWith("/") + ? pathname + "index.md" + : pathname + "/index.md"; + } + + // Rewrite to the static .md file. In Vercel's edge runtime, fetch() to the + // same origin goes directly to the static file server without re-triggering + // middleware. The /index.md early-return above is an extra safety guard. + const rewriteUrl = new URL(mdPath, request.url); + const response = await fetch(rewriteUrl); + + if (!response.ok) { + // .md file doesn't exist — fall through to normal static serving + return; + } + + const headers = new Headers(response.headers); + headers.set("content-type", "text/markdown; charset=utf-8"); + if (!headers.has("cache-control")) { + headers.set("cache-control", "public, max-age=3600"); + } + headers.set("access-control-allow-origin", "*"); + headers.set("vary", "Accept"); + + return new Response(response.body, { + status: 200, + headers, + }); +} + +export const config = { + matcher: [ + // Match all paths except static assets and direct .md file requests + "/((?!_static|assets|stylesheets|search/search_index\\.json|sitemap|robots\\.txt|favicon|llms\\.txt|CLAUDE\\.md).*)", + ], +}; diff --git a/docs/scripts/patch_vercel_config.py b/docs/scripts/patch_vercel_config.py new file mode 100644 index 00000000000..261059d6a2d --- /dev/null +++ b/docs/scripts/patch_vercel_config.py @@ -0,0 +1,49 @@ +"""Patch .vercel/output/config.json to add edge middleware routing.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +def main(): + if len(sys.argv) < 2: + print("Usage: patch_vercel_config.py ") + raise SystemExit(1) + + config_path = Path(sys.argv[1]) + + if config_path.exists(): + with open(config_path) as f: + config = json.load(f) + else: + config = {"version": 3} + + # Ensure routes list exists + if "routes" not in config: + config["routes"] = [] + + # Add middleware route at the beginning if not already present + middleware_route = { + "src": "/(?!_static/|assets/|stylesheets/|favicon\\.ico)(.*)", + "middlewarePath": "_middleware", + "continue": True, + } + + # Check if middleware route already exists + has_middleware = any( + r.get("middlewarePath") == "_middleware" for r in config["routes"] + ) + + if not has_middleware: + config["routes"].insert(0, middleware_route) + + with open(config_path, "w") as f: + json.dump(config, f, indent=2) + + print(f"Patched {config_path} with middleware route") + + +if __name__ == "__main__": + main() diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 00000000000..2cf69f280df --- /dev/null +++ b/docs/security.md @@ -0,0 +1,141 @@ +# Security + +marimo takes security seriously. This document describes marimo's security model, our approach to vulnerability disclosure, and how to report security issues. + +## Security Model + +When you open a notebook, marimo assumes you might not trust its contents until you explicitly choose to run it. Once you've run code, marimo treats the outputs as trusted since they came from your execution. + +Like other notebooks, marimo allows arbitrary code execution when you run cells. This means that if you run code from untrusted sources, you could inadvertently execute malicious code. Therefore, it's important to only run notebooks from sources you trust or to review the code before executing it. + +However, marimo implements several security measures to minimize risks when opening and editing notebooks. +Our blanket policy is that no user code is executed without explicit user action (either as javascript or python). + +### Content sanitization + +marimo sanitizes HTML and JavaScript in specific contexts to prevent malicious code from executing when you open untrusted notebooks. +All user content shown before execution is sanitized to remove all scripts (including markdown and custom HTML outputs). + +After initial execution, outputs are trusted since they were generated by your code. +Being a responsible notebook user means running code from sources you trust, and/or reviewing code before executing it. + +### Static loading + +Although marimo notebook are just python files, opening a notebook through `marimo edit` does not execute it as a module. +marimo notebooks are statically loaded, meaning they are parsed but not executed as Python modules when opened. +This prevents arbitrary code execution at load time. +Code only runs when you explicitly execute cells. + +### Run modes and trust + +marimo behaves differently depending on how you run it: + +**Edit mode** (`marimo edit`): + +- Content is sanitized until you run your first cell or `auto_instantiate` is enabled (by default it is `disabled`) +- After you run code, subsequent outputs are trusted (you created them) +- Token authentication enabled by default for remote access + +**Run mode** (`marimo run`): + +- Notebooks run as web applications +- Content is treated as a trusted website (no sanitization) +- Token authentication can be configured via CLI flags or custom middleware +- With `marimo run --watch`, newly created notebooks in the watched folder can appear in gallery mode without restarting the server + +This distinction reflects the different threat models: editing is exploratory and may involve untrusted notebooks; deployed apps are intentional publications. + +### Authentication + +marimo provides token-based authentication: + +- Enabled by default when running `marimo edit` +- Configurable in run mode via `--token` and `--token-password` flags +- Extensible through ASGI middleware for custom authentication schemes + +See the [Authentication guide](guides/deploying/authentication.md) for more details. + +### Added security measures on [https://molab.marimo.io](https://molab.marimo.io) + +[molab](https://molab.marimo.io) takes a few other addition precautions. + +- Auto-running cells is disabled on notebook load (you can disable this during your session) +- Custom head tags are disabled + +These restrictions prevent code execution without explicit user consent. + +## Security Advisories + +marimo publishes security advisories for vulnerabilities that affect production deployments, particularly long-running applications. +We follow responsible disclosure practices and work with security researchers to address issues. + +### CVE Policy + +We issue CVEs and security advisories when: + +- A vulnerability could affect long-running app deployments +- End-users are directly impacted +- The issue has security implications beyond normal bug fixes + +For general safety improvements and hardening work, we document changes in our [release notes](https://github.com/marimo-team/marimo/releases) without issuing formal advisories. + +## molab Security + +[molab](guides/molab.md) is marimo's hosted notebook platform. For security issues affecting molab: + +- We handle disclosure on a case-by-case basis +- General security improvements are disclosed publicly when applicable, and will be documented on this page. +- User-specific issues are handled privately through direct notification +- Reports can be submitted through the same channels: [GitHub advisories](https://github.com/marimo-team/marimo/security/advisories/new) or security [at] marimo [dot] io + +## Reporting Vulnerabilities + +We appreciate the security research community's efforts to improve marimo's security. If you discover a vulnerability: + +**How to report:** + +1. [Draft a security advisory on GitHub](https://github.com/marimo-team/marimo/security/advisories/new), or +2. Email the marimo team at security [at] marimo [dot] io + +**What to expect:** + +- We review all reports and respond to actionable issues +- Advisories affecting end-users are escalated to CVEs when appropriate +- We provide attribution for all reports (unless you prefer to remain anonymous) +- We have a small allocation for bug bounties; please inquire if interested. + +**Recognition:** + +We're grateful to [the security researchers](https://github.com/marimo-team/marimo/blob/main/SECURITY.md) who have responsibly disclosed vulnerabilities. +Your contributions help keep marimo safe for the entire community. We encourage responsible disclosure and recognize all security researchers who help improve marimo. + +## Staying Up to Date + +marimo ships new releases approximately once per week, and we provide immediate updates for major security disclosures. To ensure you have the latest security fixes, we recommend using `uv` to keep marimo up to date: + +```bash +# Install or update to the latest version +uv pip install --upgrade marimo +``` + +Using `uv` ensures you benefit from the latest security improvements and patches as soon as they're available. + +## Questions? + +For security questions or concerns, please reach out to security [at] marimo [dot] io. +For general questions about marimo, see our [FAQ](faq.md) or join us on [Discord](https://marimo.io/discord). + +### Previous Advisories + +
+Click to expand previous security advisories + +
    +
  • [GHSA-xjv7-6w92-42r7]: Unauthenticated proxy vulnerability in matplotlib endpoint. The /mpl/[port]/[route] endpoint allowed external attackers to reach internal services. Affected versions 0.9.20 through 0.16.3. Fixed in 0.16.4.
  • + +
  • [GHSA-2679-6mx9-h9xc]: Remote code execution via unauthenticated terminal access in edit mode. The terminal panel did not require authentication, allowing unauthenticated users to execute arbitrary commands on instances of marimo edit exposed to the internet. Run mode (marimo run) is not affected. Affected versions 0.7.10 through 0.22.x. Fixed in 0.23.0. See our advisory for more information.
  • + +
  • [molab-0]: iframe sandbox escape via markdown render. In molab, an attacker could exploit a vulnerability in the iframe sandboxing to escape the iframe and execute code in the parent context. Fixed in molab deployment on 2025-10-19.
  • +
+ +
diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index c737658d232..41e8310d537 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -67,7 +67,7 @@ iframe.demo.medium { } iframe.demo.large { - height: 600px; + height: 700px; } iframe.demo.xlarge { @@ -87,3 +87,198 @@ video { border: 1px solid #ebebeb; border-radius: 5px; } + +.examples-grid img { + max-height: 125px; +} + +.demo-container { + border-radius: 8px; + overflow: hidden; + box-shadow: 0 4px 5px rgba(0,0,0,0.08); + margin-top: 1rem; + margin-bottom: 1rem; + margin-left: 5%; + margin-right: 5%; +} + +/* === Page Actions Dropdown === */ + +.pa-dropdown { + position: relative; + float: right; + display: inline-block; + margin: 0 0 0 0.4rem; +} + +/* Toggle button */ +.pa-toggle { + display: inline-flex; + align-items: center; + gap: 0.35rem; + cursor: pointer; + border: 1px solid var(--md-default-fg-color--lightest); + background: var(--md-default-bg-color); + color: var(--md-default-fg-color--light); + padding: 0.3rem 0.5rem 0.3rem 0.4rem; + border-radius: 0.25rem; + font-family: var(--md-text-font-family); + font-size: 0.7rem; + font-weight: 400; + line-height: 1; + transition: color 0.15s, border-color 0.15s; + text-rendering: geometricprecision; +} + +.pa-toggle:hover { + color: var(--md-default-fg-color); + border-color: var(--md-default-fg-color--lighter); +} + +.pa-toggle-icon { + display: flex; + align-items: center; +} + +.pa-toggle-icon svg { + width: 0.85rem; + height: 0.85rem; +} + +.pa-toggle-label { + white-space: nowrap; +} + +.pa-chevron { + width: 0.75rem; + height: 0.75rem; + transition: transform 0.15s; + opacity: 0.5; +} + +.pa-open .pa-chevron { + transform: rotate(180deg); +} + +/* Menu */ +.pa-menu { + position: absolute; + top: calc(100% + 0.25rem); + right: 0; + z-index: 100; + min-width: 280px; + padding: 0.25rem; + background: var(--md-default-bg-color); + border-radius: 0.4rem; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08), 0 1px 4px rgba(0, 0, 0, 0.04); + border: 1px solid var(--md-default-fg-color--lightest); +} + +[data-md-color-scheme="slate"] .pa-menu { + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3); +} + +.pa-menu[hidden] { + display: none; +} + +/* Menu items */ +.pa-item { + display: flex; + align-items: center; + gap: 0.4rem; + width: 100%; + padding: 0.4rem; + border: none; + background: none; + color: var(--md-default-fg-color--light); + font-family: var(--md-text-font-family); + cursor: pointer; + text-align: left; + border-radius: 0.3rem; + transition: background-color 0.1s; + outline: none; +} + +.pa-item:focus-visible { + box-shadow: 0 0 0 2px var(--md-accent-fg-color); +} + +.pa-item:hover, +.pa-item:focus-visible { + background: rgba(0, 0, 0, 0.04); +} + +[data-md-color-scheme="slate"] .pa-item:hover, +[data-md-color-scheme="slate"] .pa-item:focus-visible { + background: rgba(255, 255, 255, 0.04); +} + +/* Icon box */ +.pa-item-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 0.35rem; + border: 1px solid var(--md-default-fg-color--lightest); + border-radius: 0.25rem; +} + +.pa-item-icon svg { + width: 0.9rem; + height: 0.9rem; + display: block; +} + +/* Text block */ +.pa-item-text { + display: flex; + flex-direction: column; + gap: 0.05rem; + padding: 0 0.15rem; + min-width: 0; + flex: 1; +} + +.pa-item-label { + display: flex; + align-items: center; + gap: 0.2rem; + font-size: 0.72rem; + font-weight: 500; + color: var(--md-default-fg-color); + white-space: nowrap; +} + +.pa-item-desc { + font-size: 0.62rem; + color: var(--md-default-fg-color--light); + white-space: nowrap; +} + +/* Arrow icon for external links */ +.pa-arrow { + width: 0.6rem; + height: 0.6rem; + flex-shrink: 0; + opacity: 0.5; +} + +/* Check icon */ +.pa-check { + width: 0.8rem; + height: 0.8rem; + flex-shrink: 0; + color: var(--md-accent-fg-color); + opacity: 0; + transition: opacity 0.15s; + margin-left: auto; +} + +/* Mobile */ +@media screen and (max-width: 76.25em) { + .pa-item { + padding: 0.5rem; + } +} diff --git a/docs/vercel.json b/docs/vercel.json index a002941d50e..8342af3fe7a 100644 --- a/docs/vercel.json +++ b/docs/vercel.json @@ -1,5 +1,25 @@ { + "headers": [ + { + "source": "/(.*)\\.md", + "headers": [ + { + "key": "Content-Type", + "value": "text/markdown; charset=utf-8" + }, + { + "key": "Access-Control-Allow-Origin", + "value": "*" + } + ] + } + ], "redirects": [ + { + "source": "/guides/package_reproducibility/", + "destination": "/guides/package_management/inlining_dependencies/", + "permanent": true + }, { "source": "/guides/sql.html", "destination": "/guides/working_with_data/sql.html", @@ -27,572 +47,572 @@ }, { "source": "/api/app.html", - "destination": "/api/app", + "destination": "/api/app/", "permanent": true }, { "source": "/api/caching.html", - "destination": "/api/caching", + "destination": "/api/caching/", "permanent": true }, { "source": "/api/cell.html", - "destination": "/api/cell", + "destination": "/api/cell/", "permanent": true }, { "source": "/api/cli_args.html", - "destination": "/api/cli_args", + "destination": "/api/cli_args/", "permanent": true }, { "source": "/api/control_flow.html", - "destination": "/api/control_flow", + "destination": "/api/control_flow/", "permanent": true }, { "source": "/api/diagrams.html", - "destination": "/api/diagrams", + "destination": "/api/diagrams/", "permanent": true }, { "source": "/api/html.html", - "destination": "/api/html", + "destination": "/api/html/", "permanent": true }, { "source": "/api/index.html", - "destination": "/api", + "destination": "/api/", "permanent": true }, { "source": "/api/inputs/anywidget.html", - "destination": "/api/inputs/anywidget", + "destination": "/api/inputs/anywidget/", "permanent": true }, { "source": "/api/inputs/array.html", - "destination": "/api/inputs/array", + "destination": "/api/inputs/array/", "permanent": true }, { "source": "/api/inputs/batch.html", - "destination": "/api/inputs/batch", + "destination": "/api/inputs/batch/", "permanent": true }, { "source": "/api/inputs/button.html", - "destination": "/api/inputs/button", + "destination": "/api/inputs/button/", "permanent": true }, { "source": "/api/inputs/chat.html", - "destination": "/api/inputs/chat", + "destination": "/api/inputs/chat/", "permanent": true }, { "source": "/api/inputs/checkbox.html", - "destination": "/api/inputs/checkbox", + "destination": "/api/inputs/checkbox/", "permanent": true }, { "source": "/api/inputs/code_editor.html", - "destination": "/api/inputs/code_editor", + "destination": "/api/inputs/code_editor/", "permanent": true }, { "source": "/api/inputs/data_explorer.html", - "destination": "/api/inputs/data_explorer", + "destination": "/api/inputs/data_explorer/", "permanent": true }, { "source": "/api/inputs/dataframe.html", - "destination": "/api/inputs/dataframe", + "destination": "/api/inputs/dataframe/", "permanent": true }, { "source": "/api/inputs/dates.html", - "destination": "/api/inputs/dates", + "destination": "/api/inputs/dates/", "permanent": true }, { "source": "/api/inputs/dictionary.html", - "destination": "/api/inputs/dictionary", + "destination": "/api/inputs/dictionary/", "permanent": true }, { "source": "/api/inputs/dropdown.html", - "destination": "/api/inputs/dropdown", + "destination": "/api/inputs/dropdown/", "permanent": true }, { "source": "/api/inputs/file.html", - "destination": "/api/inputs/file", + "destination": "/api/inputs/file/", "permanent": true }, { "source": "/api/inputs/file_browser.html", - "destination": "/api/inputs/file_browser", + "destination": "/api/inputs/file_browser/", "permanent": true }, { "source": "/api/inputs/form.html", - "destination": "/api/inputs/form", + "destination": "/api/inputs/form/", "permanent": true }, { "source": "/api/inputs/index.html", - "destination": "/api/inputs", + "destination": "/api/inputs/", "permanent": true }, { "source": "/api/inputs/microphone.html", - "destination": "/api/inputs/microphone", + "destination": "/api/inputs/microphone/", "permanent": true }, { "source": "/api/inputs/multiselect.html", - "destination": "/api/inputs/multiselect", + "destination": "/api/inputs/multiselect/", "permanent": true }, { "source": "/api/inputs/nav_menu.html", - "destination": "/api/inputs/nav_menu", + "destination": "/api/inputs/nav_menu/", "permanent": true }, { "source": "/api/inputs/number.html", - "destination": "/api/inputs/number", + "destination": "/api/inputs/number/", "permanent": true }, { "source": "/api/inputs/radio.html", - "destination": "/api/inputs/radio", + "destination": "/api/inputs/radio/", "permanent": true }, { "source": "/api/inputs/range_slider.html", - "destination": "/api/inputs/range_slider", + "destination": "/api/inputs/range_slider/", "permanent": true }, { "source": "/api/inputs/refresh.html", - "destination": "/api/inputs/refresh", + "destination": "/api/inputs/refresh/", "permanent": true }, { "source": "/api/inputs/run_button.html", - "destination": "/api/inputs/run_button", + "destination": "/api/inputs/run_button/", "permanent": true }, { "source": "/api/inputs/slider.html", - "destination": "/api/inputs/slider", + "destination": "/api/inputs/slider/", "permanent": true }, { "source": "/api/inputs/switch.html", - "destination": "/api/inputs/switch", + "destination": "/api/inputs/switch/", "permanent": true }, { "source": "/api/inputs/table.html", - "destination": "/api/inputs/table", + "destination": "/api/inputs/table/", "permanent": true }, { "source": "/api/inputs/tabs.html", - "destination": "/api/inputs/tabs", + "destination": "/api/inputs/tabs/", "permanent": true }, { "source": "/api/inputs/text.html", - "destination": "/api/inputs/text", + "destination": "/api/inputs/text/", "permanent": true }, { "source": "/api/inputs/text_area.html", - "destination": "/api/inputs/text_area", + "destination": "/api/inputs/text_area/", "permanent": true }, { "source": "/api/layouts/accordion.html", - "destination": "/api/layouts/accordion", + "destination": "/api/layouts/accordion/", "permanent": true }, { "source": "/api/layouts/callout.html", - "destination": "/api/layouts/callout", + "destination": "/api/layouts/callout/", "permanent": true }, { "source": "/api/layouts/carousel.html", - "destination": "/api/layouts/carousel", + "destination": "/api/layouts/carousel/", "permanent": true }, { "source": "/api/layouts/index.html", - "destination": "/api/layouts", + "destination": "/api/layouts/", "permanent": true }, { "source": "/api/layouts/justify.html", - "destination": "/api/layouts/justify", + "destination": "/api/layouts/justify/", "permanent": true }, { "source": "/api/layouts/lazy.html", - "destination": "/api/layouts/lazy", + "destination": "/api/layouts/lazy/", "permanent": true }, { "source": "/api/layouts/plain.html", - "destination": "/api/layouts/plain", + "destination": "/api/layouts/plain/", "permanent": true }, { "source": "/api/layouts/routes.html", - "destination": "/api/layouts/routes", + "destination": "/api/layouts/routes/", "permanent": true }, { "source": "/api/layouts/sidebar.html", - "destination": "/api/layouts/sidebar", + "destination": "/api/layouts/sidebar/", "permanent": true }, { "source": "/api/layouts/stacks.html", - "destination": "/api/layouts/stacks", + "destination": "/api/layouts/stacks/", "permanent": true }, { "source": "/api/layouts/tree.html", - "destination": "/api/layouts/tree", + "destination": "/api/layouts/tree/", "permanent": true }, { "source": "/api/markdown.html", - "destination": "/api/markdown", + "destination": "/api/markdown/", "permanent": true }, { "source": "/api/media/audio.html", - "destination": "/api/media/audio", + "destination": "/api/media/audio/", "permanent": true }, { "source": "/api/media/download.html", - "destination": "/api/media/download", + "destination": "/api/media/download/", "permanent": true }, { "source": "/api/media/image.html", - "destination": "/api/media/image", + "destination": "/api/media/image/", "permanent": true }, { "source": "/api/media/index.html", - "destination": "/api/media", + "destination": "/api/media/", "permanent": true }, { "source": "/api/media/pdf.html", - "destination": "/api/media/pdf", + "destination": "/api/media/pdf/", "permanent": true }, { "source": "/api/media/plain_text.html", - "destination": "/api/media/plain_text", + "destination": "/api/media/plain_text/", "permanent": true }, { "source": "/api/media/video.html", - "destination": "/api/media/video", + "destination": "/api/media/video/", "permanent": true }, { "source": "/api/miscellaneous.html", - "destination": "/api/miscellaneous", + "destination": "/api/miscellaneous/", "permanent": true }, { "source": "/api/outputs.html", - "destination": "/api/outputs", + "destination": "/api/outputs/", "permanent": true }, { "source": "/api/plotting.html", - "destination": "/api/plotting", + "destination": "/api/plotting/", "permanent": true }, { "source": "/api/query_params.html", - "destination": "/api/query_params", + "destination": "/api/query_params/", "permanent": true }, { "source": "/api/state.html", - "destination": "/api/state", + "destination": "/api/state/", "permanent": true }, { "source": "/api/status.html", - "destination": "/api/status", + "destination": "/api/status/", "permanent": true }, { "source": "/apps/README.html", - "destination": "/apps", + "destination": "/apps/", "permanent": true }, { "source": "/community.html", - "destination": "/community", + "destination": "/community/", "permanent": true }, { "source": "/examples.html", - "destination": "/examples", + "destination": "/examples/", "permanent": true }, { "source": "/faq.html", - "destination": "/faq", + "destination": "/faq/", "permanent": true }, { "source": "/getting_started/index.html", - "destination": "/getting_started", + "destination": "/getting_started/", "permanent": true }, { "source": "/getting_started/installation.html", - "destination": "/getting_started/installation", + "destination": "/getting_started/installation/", "permanent": true }, { "source": "/getting_started/key_concepts.html", - "destination": "/getting_started/key_concepts", + "destination": "/getting_started/key_concepts/", "permanent": true }, { "source": "/getting_started/quickstart.html", - "destination": "/getting_started/quickstart", + "destination": "/getting_started/quickstart/", "permanent": true }, { "source": "/guides/apps.html", - "destination": "/guides/apps", + "destination": "/guides/apps/", "permanent": true }, { "source": "/guides/best_practices.html", - "destination": "/guides/best_practices", + "destination": "/guides/best_practices/", "permanent": true }, { "source": "/guides/coming_from/index.html", - "destination": "/guides/coming_from", + "destination": "/guides/coming_from/", "permanent": true }, { "source": "/guides/coming_from/jupyter.html", - "destination": "/guides/coming_from/jupyter", + "destination": "/guides/coming_from/jupyter/", "permanent": true }, { "source": "/guides/coming_from/jupytext.html", - "destination": "/guides/coming_from/jupytext", + "destination": "/guides/coming_from/jupytext/", "permanent": true }, { "source": "/guides/coming_from/papermill.html", - "destination": "/guides/coming_from/papermill", + "destination": "/guides/coming_from/papermill/", "permanent": true }, { "source": "/guides/coming_from/streamlit.html", - "destination": "/guides/coming_from/streamlit", + "destination": "/guides/coming_from/streamlit/", "permanent": true }, { "source": "/guides/configuration/html_head.html", - "destination": "/guides/configuration/html_head", + "destination": "/guides/configuration/html_head/", "permanent": true }, { "source": "/guides/configuration/index.html", - "destination": "/guides/configuration", + "destination": "/guides/configuration/", "permanent": true }, { "source": "/guides/configuration/runtime_configuration.html", - "destination": "/guides/configuration/runtime_configuration", + "destination": "/guides/configuration/runtime_configuration/", "permanent": true }, { "source": "/guides/configuration/theming.html", - "destination": "/guides/configuration/theming", + "destination": "/guides/configuration/theming/", "permanent": true }, { "source": "/guides/deploying/authentication.html", - "destination": "/guides/deploying/authentication", + "destination": "/guides/deploying/authentication/", "permanent": true }, { "source": "/guides/deploying/deploying_docker.html", - "destination": "/guides/deploying/deploying_docker", + "destination": "/guides/deploying/deploying_docker/", "permanent": true }, { "source": "/guides/deploying/deploying_hugging_face.html", - "destination": "/guides/deploying/deploying_hugging_face", + "destination": "/guides/deploying/deploying_hugging_face/", "permanent": true }, { "source": "/guides/deploying/deploying_ploomber.html", - "destination": "/guides/deploying/deploying_ploomber", + "destination": "/guides/deploying/deploying_ploomber/", "permanent": true }, { "source": "/guides/deploying/deploying_public_gallery.html", - "destination": "/guides/deploying/deploying_public_gallery", + "destination": "/guides/publishing/public_gallery/", "permanent": true }, { "source": "/guides/deploying/deploying_railway.html", - "destination": "/guides/deploying/deploying_railway", + "destination": "/guides/deploying/deploying_railway/", "permanent": true }, { "source": "/guides/deploying/index.html", - "destination": "/guides/deploying", + "destination": "/guides/deploying/", "permanent": true }, { "source": "/guides/deploying/prebuilt_containers.html", - "destination": "/guides/deploying/prebuilt_containers", + "destination": "/guides/deploying/prebuilt_containers/", "permanent": true }, { "source": "/guides/deploying/programmatically.html", - "destination": "/guides/deploying/programmatically", + "destination": "/guides/deploying/programmatically/", "permanent": true }, { "source": "/guides/editor_features/ai_completion.html", - "destination": "/guides/editor_features/ai_completion", + "destination": "/guides/editor_features/ai_completion/", "permanent": true }, { "source": "/guides/editor_features/hotkeys.html", - "destination": "/guides/editor_features/hotkeys", + "destination": "/guides/editor_features/hotkeys/", "permanent": true }, { "source": "/guides/editor_features/index.html", - "destination": "/guides/editor_features", + "destination": "/guides/editor_features/", "permanent": true }, { "source": "/guides/editor_features/overview.html", - "destination": "/guides/editor_features/overview", + "destination": "/guides/editor_features/overview/", "permanent": true }, { "source": "/guides/editor_features/package_management.html", - "destination": "/guides/editor_features/package_management", + "destination": "/guides/editor_features/package_management/", "permanent": true }, { "source": "/guides/expensive_notebooks.html", - "destination": "/guides/expensive_notebooks", + "destination": "/guides/expensive_notebooks/", "permanent": true }, { "source": "/guides/exporting.html", - "destination": "/guides/exporting", + "destination": "/guides/exporting/", "permanent": true }, { "source": "/guides/index.html", - "destination": "/guides", + "destination": "/guides/", "permanent": true }, { "source": "/guides/integrating_with_marimo/custom_ui_plugins.html", - "destination": "/guides/integrating_with_marimo/custom_ui_plugins", + "destination": "/guides/integrating_with_marimo/custom_ui_plugins/", "permanent": true }, { "source": "/guides/integrating_with_marimo/displaying_objects.html", - "destination": "/guides/integrating_with_marimo/displaying_objects", + "destination": "/guides/integrating_with_marimo/displaying_objects/", "permanent": true }, { "source": "/guides/integrating_with_marimo/index.html", - "destination": "/guides/integrating_with_marimo", + "destination": "/guides/integrating_with_marimo/", "permanent": true }, { "source": "/guides/interactivity.html", - "destination": "/guides/interactivity", + "destination": "/guides/interactivity/", "permanent": true }, { "source": "/guides/island_example.html", - "destination": "/guides/island_example", + "destination": "/guides/island_example/", "permanent": true }, { "source": "/guides/outputs.html", - "destination": "/guides/outputs", + "destination": "/guides/outputs/", "permanent": true }, { "source": "/guides/reactivity.html", - "destination": "/guides/reactivity", + "destination": "/guides/reactivity/", "permanent": true }, { "source": "/guides/scripts.html", - "destination": "/guides/scripts", + "destination": "/guides/scripts/", "permanent": true }, { "source": "/guides/state.html", - "destination": "/guides/state", + "destination": "/guides/state/", "permanent": true }, { "source": "/guides/troubleshooting.html", - "destination": "/guides/troubleshooting", + "destination": "/guides/troubleshooting/", "permanent": true }, { "source": "/guides/wasm.html", - "destination": "/guides/wasm", + "destination": "/guides/wasm/", "permanent": true }, { "source": "/guides/working_with_data/dataframes.html", - "destination": "/guides/working_with_data/dataframes", + "destination": "/guides/working_with_data/dataframes/", "permanent": true }, { "source": "/guides/working_with_data/index.html", - "destination": "/guides/working_with_data", + "destination": "/guides/working_with_data/", "permanent": true }, { "source": "/guides/working_with_data/plotting.html", - "destination": "/guides/working_with_data/plotting", + "destination": "/guides/working_with_data/plotting/", "permanent": true }, { "source": "/guides/working_with_data/sql.html", - "destination": "/guides/working_with_data/sql", + "destination": "/guides/working_with_data/sql/", "permanent": true }, { @@ -602,37 +622,102 @@ }, { "source": "/integrations/google_cloud_bigquery.html", - "destination": "/integrations/google_cloud_bigquery", + "destination": "/integrations/google_cloud_bigquery/", "permanent": true }, { "source": "/integrations/google_cloud_storage.html", - "destination": "/integrations/google_cloud_storage", + "destination": "/integrations/google_cloud_storage/", "permanent": true }, { "source": "/integrations/google_sheets.html", - "destination": "/integrations/google_sheets", + "destination": "/integrations/google_sheets/", "permanent": true }, { "source": "/integrations/index.html", - "destination": "/integrations", + "destination": "/integrations/", "permanent": true }, { "source": "/integrations/motherduck.html", - "destination": "/integrations/motherduck", + "destination": "/integrations/motherduck/", "permanent": true }, { "source": "/recipes.html", - "destination": "/recipes", + "destination": "/recipes/", + "permanent": true + }, + { + "source": "/guides/publishing/from_code_snippets/", + "destination": "/guides/publishing/embedding/", + "permanent": true + }, + { + "source": "/guides/publishing/from_github/", + "destination": "/guides/publishing/github/", + "permanent": true + }, + { + "source": "/guides/publishing/view_outputs_on_github/", + "destination": "/guides/publishing/github/", + "permanent": true + }, + { + "source": "/guides/publishing/github_pages/", + "destination": "/guides/publishing/github/", + "permanent": true + }, + { + "source": "/guides/deploying/deploying_public_gallery/", + "destination": "/guides/publishing/public_gallery/", + "permanent": true + }, + { + "source": "/guides/publishing/quarto/", + "destination": "/guides/exporting/quarto/", + "permanent": true + }, + { + "source": "/guides/publishing/deploy/", + "destination": "/guides/deploying/", + "permanent": true + }, + { + "source": "/guides/deploying/scheduled/", + "destination": "/guides/scripts/", + "permanent": true + }, + { + "source": "/guides/publishing/playground/", + "destination": "/guides/wasm/", + "permanent": true + }, + { + "source": "/guides/generate_with_ai/prompts/", + "destination": "/guides/generate_with_ai/customize_your_agent/", + "permanent": true + }, + { + "source": "/guides/generate_with_ai/skills/", + "destination": "/guides/generate_with_ai/customize_your_agent/", + "permanent": true + }, + { + "source": "/guides/generate_with_ai/using_claude_code/", + "destination": "/guides/generate_with_ai/customize_your_agent/", "permanent": true }, { "source": "/:path*.html", - "destination": "/:path*", + "destination": "/:path*/", + "permanent": true + }, + { + "source": "/:path([^.]*[^/]$)", + "destination": "/:path/", "permanent": true } ] diff --git a/examples/README.md b/examples/README.md index 27c70d3d583..f682805513a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,46 +1,55 @@ # Examples -This folder contains example marimo notebooks. +This folder contains example marimo notebooks, meant to teach you how to +use marimo's features as well as inspire you to make awesome notebooks. -- 🖱️ [`ui/`](ui/): marimo's many UI elements or widgets -- 🛢️ [`sql/`](sql/): using marimo's SQL cells -- 📽️ [`layouts/`](layouts/): present notebooks as slides, add sidebars, and more + +- 🖱️ [`ui/`](ui/): how to use UI elements or widgets +- 🛢️ [`sql/`](sql/): how to use SQL in marimo +- ⛲ [`control_flow/`](control_flow/): how to control cell execution and output display +- 📝 [`markdown/`](markdown/): how to write markdown, including dynamic markdown +- 📽️ [`layouts/`](layouts/): how to present notebooks as slides, add sidebars, and more - 🤖 [`ai/`](ai/): AI-related examples +- 🧪 [`testing/`](testing/): how to test marimo notebooks, and use marimo notebooks as tests - 📦 [`third_party/`](third_party/): using popular third-party packages in marimo - ☁️ [`cloud/`](cloud/): using various cloud providers - 🧩 [`frameworks/`](frameworks/): integrating with different frameworks (web/ASGI) - ✨ [`misc/`](misc/): miscellaneous topical examples > [!TIP] -> New to marimo? Run `marimo tutorial ui` at the command line first! +> New to marimo? Run `marimo tutorial intro` at the command line first! + +> [!TIP] +> Check out our [public gallery](https://marimo.io/gallery) of interactive +> notebooks to get inspired. > [!NOTE] > Submit a > [pull request](https://github.com/marimo-team/marimo/pulls) to add an example! > We especially welcome library developers to add examples to `third_party/`. - ## Running examples The requirements of each notebook are serialized in them as a top-level comment. Here are the steps to open an example notebook: -1. [Install marimo](https://docs.marimo.io/getting_started/index.html#installation) -2. [Install `uv`](https://github.com/astral-sh/uv/?tab=readme-ov-file#installation) -3. Open an example with `marimo edit --sandbox `. +1. [Install `uv`](https://github.com/astral-sh/uv/?tab=readme-ov-file#installation) +2. Open an example with `marimo edit --sandbox `. For example: ```bash -marimo edit --sandbox https://github.com/marimo-team/marimo/blob/main/examples/ui/reactive_plots.py +uvx marimo edit --sandbox https://github.com/marimo-team/marimo/blob/main/examples/misc/seam_carving.py ``` > [!TIP] > The [`--sandbox` flag](https://docs.marimo.io/guides/editor_features/package_management.html) opens the notebook in an isolated virtual environment, > automatically installing the notebook's dependencies 📦 -You can also open notebooks without `uv`, with just `marimo edit `; -however, you'll need to install the requirements yourself. +You can also open notebooks without `uv`, in which case you'll need to +manually [install marimo](https://docs.marimo.io/getting_started/index.html#installation) +first. Then run `marimo edit `; however, you'll also need to +install the requirements yourself. ## More examples 🌟 diff --git a/examples/ai/README.md b/examples/ai/README.md index b00aff52495..43f583af902 100644 --- a/examples/ai/README.md +++ b/examples/ai/README.md @@ -16,13 +16,14 @@ These examples showcase a few simple applications of AI. The requirements of each notebook are serialized in them as a top-level comment. Here are the steps to open an example notebook: -1. [Install marimo](https://docs.marimo.io/getting_started/index.html#installation) -2. [Install `uv`](https://github.com/astral-sh/uv/?tab=readme-ov-file#installation) -3. Open an example with `marimo edit --sandbox `. +1. [Install `uv`](https://github.com/astral-sh/uv/?tab=readme-ov-file#installation) +2. Open an example with `uvx marimo edit --sandbox ` > [!TIP] > The [`--sandbox` flag](https://docs.marimo.io/guides/editor_features/package_management.html) opens the notebook in an isolated virtual environment, > automatically installing the notebook's dependencies 📦 -You can also open notebooks without `uv`, with just `marimo edit `; -however, you'll need to install the requirements yourself. +You can also open notebooks without `uv`, in which case you'll need to +manually [install marimo](https://docs.marimo.io/getting_started/index.html#installation) +first. Then run `marimo edit `; however, you'll also need to +install the requirements yourself. diff --git a/examples/ai/chat/README.md b/examples/ai/chat/README.md index abdf5d30005..0c2e38618c2 100644 --- a/examples/ai/chat/README.md +++ b/examples/ai/chat/README.md @@ -3,10 +3,11 @@ These examples show how to make chatbots with marimo, using [`mo.ui.chat`](https://docs.marimo.io/api/inputs/chat.html#marimo.ui.chat). - `custom.py` shows how to make a custom chatbot. -- `openai_example.py` shows how to make a chatbot powered by OpenAI models. -- `anthropic_example.py` shows how to make a chatbot powered by Anthropic models. -- `gemini.py` shows how to make a chatbot powered by Google models like Gemini. -- `groq_example.py` shows how to make a chatbot powered by Groq models. +- `streaming_custom.py` shows how to make a custom chatbot with streaming responses (delta-based). +- `openai_example.py` shows how to make a chatbot powered by OpenAI models (streaming by default). +- `anthropic_example.py` shows how to make a chatbot powered by Anthropic models (streaming by default). +- `gemini.py` shows how to make a chatbot powered by Google models like Gemini (streaming by default). +- `groq_example.py` shows how to make a chatbot powered by Groq models (streaming by default). - `mlx_chat.py` shows a simple chatbot using local on-device models with Apple's [MLX](https://github.com/ml-explore/mlx), a machine learning framework from Apple that is similar to JAX and PyTorch. This specific example uses the [mlx-lm](https://github.com/ml-explore/mlx-examples/tree/main/llms) library. Note that Apple Silicon chips are required for using MLX. - `llm_datasette.py` shows how to make a chatbot powered by Simon W's LLM library. - `dagger_code_interpreter.py` shows how to make a basic code-interpreter chatbot powered by Dagger containers. @@ -14,6 +15,14 @@ These examples show how to make chatbots with marimo, using [`mo.ui.chat`](https - `simplemind_example.py` shows how to integrate [simplemind](https://github.com/kennethreitz/simplemind). - `generative_ui.py` shows how to make a chatbot that can generate UI code. +## Streaming Responses + +All built-in models (OpenAI, Anthropic, Google, Groq, Bedrock) stream responses using delta-based streaming. If a model doesn't support streaming, it will automatically fall back to non-streaming mode. + +For custom models, create an async generator function that yields delta chunks (new content only). + +See `streaming_custom.py` for a complete example of custom streaming. + Chatbot's in marimo are _reactive_: when the chatbot responds with a message, all other cells referencing the chatbot are automatically run or marked stale, with the chatbot's response stored in the object's `value` attribute. diff --git a/examples/ai/chat/anthropic_example.py b/examples/ai/chat/anthropic_example.py index 2277e12460e..6a850c9a46b 100644 --- a/examples/ai/chat/anthropic_example.py +++ b/examples/ai/chat/anthropic_example.py @@ -2,45 +2,45 @@ # requires-python = ">=3.11" # dependencies = [ # "marimo", +# "anthropic", # ] # /// import marimo -__generated_with = "0.9.9" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell -def __(): +def _(): import marimo as mo + return (mo,) -@app.cell -def __(mo): - mo.md( - r""" - # Using Anthropic +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + # Using Anthropic - This example shows how to use [`mo.ui.chat`](https://docs.marimo.io/api/inputs/chat.html#marimo.ui.chat) to make a chatbot backed by Anthropic. - """ - ) + This example shows how to use [`mo.ui.chat`](https://docs.marimo.io/api/inputs/chat.html#marimo.ui.chat) to make a chatbot backed by Anthropic. + """) return @app.cell -def __(mo): +def _(mo): import os os_key = os.environ.get("ANTHROPIC_API_KEY") input_key = mo.ui.text(label="Anthropic API key", kind="password") input_key if not os_key else None - return input_key, os, os_key + return input_key, os_key @app.cell -def __(input_key, mo, os_key): +def _(input_key, mo, os_key): key = os_key or input_key.value mo.stop( @@ -51,10 +51,10 @@ def __(input_key, mo, os_key): @app.cell -def __(key, mo): +def _(key, mo): chatbot = mo.ui.chat( mo.ai.llm.anthropic( - "claude-3-5-sonnet-20240620", + "claude-sonnet-4-5", system_message="You are a helpful assistant.", api_key=key, ), @@ -73,14 +73,16 @@ def __(key, mo): return (chatbot,) -@app.cell -def __(mo): - mo.md("""Access the chatbot's historical messages with [`chatbot.value`](https://docs.marimo.io/api/inputs/chat.html#accessing-chat-history).""") +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + Access the chatbot's historical messages with [`chatbot.value`](https://docs.marimo.io/api/inputs/chat.html#accessing-chat-history). + """) return @app.cell -def __(chatbot): +def _(chatbot): # chatbot.value is the list of chat messages chatbot.value return diff --git a/examples/ai/chat/bedrock_example.py b/examples/ai/chat/bedrock_example.py new file mode 100644 index 00000000000..7170132ced9 --- /dev/null +++ b/examples/ai/chat/bedrock_example.py @@ -0,0 +1,250 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "boto3", +# "marimo", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell(hide_code=True) +def _(): + import marimo as mo + + return (mo,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + # AWS Bedrock Chat Example + + This example demonstrates using AWS Bedrock with marimo's chat interface. + + AWS Bedrock provides access to foundation models from leading AI companies like Anthropic, Meta, and others. + + ⚠️ **Note:** You'll need an AWS account with access to the AWS Bedrock service and the specific model you want to use. + """) + return + + +@app.cell(hide_code=True) +def _(): + import os + import boto3 + + + # For this example, let's add a helper to check AWS configuration + def check_aws_config(): + """Check if AWS configuration is available""" + # Check for credentials + has_creds = False + try: + session = boto3.Session() + credentials = session.get_credentials() + if credentials: + has_creds = True + except: + pass + + return {"has_credentials": has_creds} + + + # Run the check + aws_config = check_aws_config() + return (aws_config,) + + +@app.cell +def _(aws_config, mo): + # Display AWS configuration status + mo.stop( + not aws_config["has_credentials"], + mo.md(""" + ### ⚠️ AWS Credentials Not Found + + To use AWS Bedrock, you need AWS credentials configured. + Options: + 1. Set environment variables: + ``` + export AWS_ACCESS_KEY_ID=your_key + export AWS_SECRET_ACCESS_KEY=your_secret + ``` + + 2. Configure AWS CLI: + ``` + aws configure + ``` + + 3. Use an AWS profile in ~/.aws/credentials + """), + ) + return + + +@app.cell(hide_code=True) +def _(mo): + # UI for model configuration + + # Predefined model options + model_options = [ + "bedrock/us.amazon.nova-pro-v1:0", + "bedrock/anthropic.claude-3-sonnet-20240229", + "bedrock/anthropic.claude-3-haiku-20240307", + "bedrock/meta.llama3-8b-instruct-v1:0", + "bedrock/amazon.titan-text-express-v1", + "bedrock/cohere.command-r-plus-v1", + ] + + # Region options + region_options = [ + "us-east-1", + "us-west-2", + "eu-central-1", + "ap-northeast-1", + "ap-southeast-1", + ] + + # Model selection + model = mo.ui.dropdown( + options=model_options, value=model_options[0], label="AWS Bedrock Model" + ) + + # Region selection + region = mo.ui.dropdown( + options=region_options, value="us-east-1", label="AWS Region" + ) + + # Optional profile name + profile = mo.ui.text( + value="", + label="AWS Profile (optional)", + placeholder="Leave empty to use default credentials", + ) + + # System message + system_message = mo.ui.text_area( + value="You are a helpful, harmless assistant. Provide clear, concise answers.", + label="System Message", + rows=2, + ) + + # Create a form to wrap all inputs + config_form = ( + mo.md(""" + AWS Bedrock Chat Configuration: + {model} + {region} + {profile} + {system_message} + """) + .batch( + model=model, + region=region, + profile=profile, + system_message=system_message, + ) + .form( + submit_button_label="Update Chat Configuration", + ) + ) + + config_form + return (config_form,) + + +@app.cell +def _(mo): + mo.md(r""" + ## AWS Bedrock Chat + """) + return + + +@app.cell +def _(config_form, max_tokens, mo, temperature): + # Create a refreshable chat component that updates when the form is submitted + def create_chat(config_form): + # temperature = config_form.value["temperature"] + # max_tokens = config_form.value["max_tokens"] + model = config_form.value["model"] + region = config_form.value["region"] + system_message = config_form.value["system_message"] + profile = config_form.value["profile"] + try: + # Create chat config + chat_config = mo.ai.ChatModelConfig( + temperature=temperature, + max_tokens=max_tokens, + # top_k=1, + # top_p=1.0, + # frequency_penalty=1, + # presence_penalty=1, + ) + + # Create model with optional profile + model_kwargs = { + "model": model, + "region_name": region, + "system_message": system_message, + } + + # Add profile if specified + if profile.strip(): + model_kwargs["profile_name"] = profile.strip() + + # Create chat interface + chatbot = mo.ui.chat( + mo.ai.llm.bedrock(**model_kwargs), + allow_attachments=[ + "image/png", + "image/jpeg", + ], + prompts=[ + "Hello", + "How are you?", + "I'm doing great, how about you?", + ], + max_height=400, + config=chat_config, + ) + return chatbot + except Exception as e: + mo.md(f"**Error initializing chat**: {str(e)}") + return None + + + # Display the chat interface + chatbot = create_chat(config_form) + chatbot + return + + +@app.cell +def _(mo): + mo.md(r""" + ## Notes on AWS Bedrock Usage + + 1. **Model Access**: You need to request access to the specific models you want to use in the AWS Bedrock console. + + 2. **Pricing**: Using AWS Bedrock incurs usage costs based on the number of input and output tokens. Check the [AWS Bedrock pricing](https://aws.amazon.com/bedrock/pricing/) for details. + + 3. **Regions**: AWS Bedrock is not available in all AWS regions. Make sure to choose a region where Bedrock is available. + + 4. **Authentication**: This example uses the standard AWS credential chain (environment variables, AWS config files, or instance profiles). You can also provide explicit credentials when creating the model. + + 5. **Troubleshooting**: If you encounter issues, check: + - That your AWS credentials are configured correctly + - That you have requested model access in the AWS Bedrock console + - That you're using a region where the selected model is available + """) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ai/chat/custom.py b/examples/ai/chat/custom.py index e9516a3659f..d0fb74c7bca 100644 --- a/examples/ai/chat/custom.py +++ b/examples/ai/chat/custom.py @@ -7,32 +7,31 @@ import marimo -__generated_with = "0.8.22" +__generated_with = "0.20.2" app = marimo.App(width="medium") @app.cell -def __(): +def _(): import marimo as mo + return (mo,) @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - # Custom chatbot - - This example shows how to make a custom chatbot: just supply a function that takes two arguments, - `messages` and `config`, and returns the chatbot's response. This response can be any object; it - doesn't have to be a string! - """ - ) +def _(mo): + mo.md(""" + # Custom chatbot + + This example shows how to make a custom chatbot: just supply a function that takes two arguments, + `messages` and `config`, and returns the chatbot's response. This response can be any object; it + doesn't have to be a string! + """) return @app.cell -def __(mo): +def _(mo): def simple_echo_model(messages, config): """This chatbot echoes what the user says.""" # messages is a list of chatbot messages @@ -42,27 +41,48 @@ def simple_echo_model(messages, config): # message.content: the content of the message return f"You said: {messages[-1].content}!" + chatbot = mo.ui.chat( simple_echo_model, prompts=["Hello", "How are you?"], - show_configuration_controls=False + show_configuration_controls=False, ) chatbot - return chatbot, simple_echo_model + return (chatbot,) @app.cell -def __(mo): - mo.md("""Access the chatbot's historical messages with `chatbot.value`.""") +def _(mo): + mo.md(""" + Access the chatbot's historical messages with `chatbot.value`. + """) return @app.cell -def __(chatbot): +def _(chatbot): # chatbot.value is the list of chat messages chatbot.value return +@app.cell +def _(mo): + def marimo_chatbot(messages, config): + """Returns marimo elements""" + message = messages[-1] + return mo.vstack( + [ + mo.md("## Response"), + mo.ui.table([1, 2, 3]), + mo.ui.slider(1, 10, label="slider"), + ] + ) + + + mo.ui.chat(marimo_chatbot) + return + + if __name__ == "__main__": app.run() diff --git a/examples/ai/chat/dagger_code_interpreter.py b/examples/ai/chat/dagger_code_interpreter.py index 20dc97f961b..8c76ac298f8 100644 --- a/examples/ai/chat/dagger_code_interpreter.py +++ b/examples/ai/chat/dagger_code_interpreter.py @@ -11,41 +11,40 @@ import marimo -__generated_with = "0.9.20" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell(hide_code=True) -def __(): +def _(): import marimo as mo import ell import textwrap - return ell, mo, textwrap + + return ell, mo @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - # Chatbot code-interpreter with [Dagger](https://dagger.io/) +def _(mo): + mo.md(""" + # Chatbot code-interpreter with [Dagger](https://dagger.io/) - This example shows how to create a code-interpreter that executes code using [Dagger](https://dagger.io/) so the code is run in an isolated container. + This example shows how to create a code-interpreter that executes code using [Dagger](https://dagger.io/) so the code is run in an isolated container. - This example requires Docker running on your computer. - """ - ) + This example requires Docker running on your computer. + """) return @app.cell(hide_code=True) -def __(mo): +def _(mo): backend = mo.ui.dropdown(["ollama", "openai"], label="Backend", value="openai") backend return (backend,) @app.cell(hide_code=True) -def __(mo): +def _(mo): # OpenAI config import os import openai @@ -56,11 +55,11 @@ def __(mo): value=os.environ.get("OPENAI_API_KEY", ""), ) input_key - return input_key, openai, os + return (input_key,) @app.cell(hide_code=True) -def __(backend, input_key, mo): +def _(backend, input_key, mo): def _get_open_ai_client(): openai_key = input_key.value @@ -95,33 +94,34 @@ def _get_ollama_client(): @app.cell -def __(): +def _(): import dagger + return (dagger,) @app.cell -def __(mo): +def _(mo): files = mo.ui.file(kind="area") files return (files,) @app.cell -def __(mo): +def _(mo): packages = mo.ui.text_area(label="Packages", value="pandas") packages return (packages,) @app.cell -def __(files): +def _(files): [file.name for file in files.value] return @app.cell -def __(dagger, ell, files, mo, packages): +def _(dagger, ell, files, mo, packages): @ell.tool() async def execute_code(code: str): """ @@ -153,33 +153,23 @@ async def execute_code(code: str): mo.md(result), ] ) + return (execute_code,) -@app.cell(hide_code=True) -def __(): - def describe_file(file): - if file.name.endswith(".py"): - return f"Python file: {file.name}" - if file.name.endswith(".txt"): - return f"Text file: {file.name}" - if file.name.endswith(".csv"): - return f"CSV file: {file.name}. Headers: {file.contents.decode('utf-8').splitlines()[0]}" - return f"File: {file.name}" - return (describe_file,) +@app.function(hide_code=True) +def describe_file(file): + if file.name.endswith(".py"): + return f"Python file: {file.name}" + if file.name.endswith(".txt"): + return f"Text file: {file.name}" + if file.name.endswith(".csv"): + return f"CSV file: {file.name}. Headers: {file.contents.decode('utf-8').splitlines()[0]}" + return f"File: {file.name}" @app.cell -def __( - client, - describe_file, - ell, - execute_code, - files, - mo, - model, - packages, -): +def _(client, ell, execute_code, files, mo, model, packages): files_instructions = "" packages_instructions = "" if files.value: @@ -222,11 +212,12 @@ def my_model(messages, config): if response.tool_calls: return response.tool_calls[0]() return mo.md(response.text) - return custom_chatbot, files_instructions, my_model, packages_instructions + + return (my_model,) @app.cell -def __(mo, my_model): +def _(mo, my_model): mo.ui.chat( my_model, prompts=[ diff --git a/examples/ai/chat/deepseek_example.py b/examples/ai/chat/deepseek_example.py new file mode 100644 index 00000000000..c721fdc23f0 --- /dev/null +++ b/examples/ai/chat/deepseek_example.py @@ -0,0 +1,104 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "marimo", +# "openai==1.60.2", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + # Using DeepSeek + + This example shows how to use [`mo.ui.chat`](https://docs.marimo.io/api/inputs/chat/?h=mo.ui.chat) to make a chatbot backed by [Deepseek](https://deepseek.com/). + """) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md( + r""" + + Powered by deepseek + + """ + ).center() + return + + +@app.cell +def _(mo): + import os + + os_key = os.environ.get("DEEPSEEK_API_KEY") + input_key = mo.ui.text(label="Deepseek API key", kind="password") + input_key if not os_key else None + return input_key, os_key + + +@app.cell +def _(input_key, mo, os_key): + key = os_key or input_key.value + + mo.stop( + not key, + mo.md("Please provide your Deepseek AI API key in the input field."), + ) + return (key,) + + +@app.cell +def _(key, mo): + chatbot = mo.ui.chat( + mo.ai.llm.openai( + model="deepseek-reasoner", + system_message="You are a helpful assistant.", + api_key=key, + base_url="https://api.deepseek.com", + ), + prompts=[ + "Hello", + "How are you?", + "I'm doing great, how about you?", + ], + ) + chatbot + return (chatbot,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + Access the chatbot's historical messages with [`chatbot.value`](https://docs.marimo.io/api/inputs/chat.html#accessing-chat-history). + """) + return + + +@app.cell +def _(chatbot): + # chatbot.value is the list of chat messages + chatbot.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ai/chat/gemini.py b/examples/ai/chat/gemini.py index e5618afdfd1..cae8bd417dd 100644 --- a/examples/ai/chat/gemini.py +++ b/examples/ai/chat/gemini.py @@ -2,60 +2,59 @@ # requires-python = ">=3.11" # dependencies = [ # "marimo", +# "google-genai>=1.20.0", # ] # /// import marimo -__generated_with = "0.8.22" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell -def __(): +def _(): import marimo as mo + return (mo,) @app.cell -def __(mo): - mo.md( - r""" - # Using Gemini +def _(mo): + mo.md(r""" + # Using Gemini - This example shows how to use [`mo.ui.chat`](https://docs.marimo.io/api/inputs/chat.html#marimo.ui.chat) to make a chatbot backed by Gemini. - """ - ) + This example shows how to use [`mo.ui.chat`](https://docs.marimo.io/api/inputs/chat.html#marimo.ui.chat) to make a chatbot backed by Gemini. + """) return @app.cell -def __(mo): +def _(mo): import os os_key = os.environ.get("GOOGLE_AI_API_KEY") input_key = mo.ui.text(label="Google AI API key", kind="password") input_key if not os_key else None - return input_key, os, os_key + return input_key, os_key @app.cell -def __(input_key, mo, os_key): +def _(input_key, mo, os_key): key = os_key or input_key.value mo.stop( not key, mo.md("Please provide your Google AI API key in the input field."), ) - return (key,) @app.cell -def __(key, mo): +def _(key, mo): chatbot = mo.ui.chat( mo.ai.llm.google( - "gemini-1.5-pro-latest", + "gemini-2.5-flash", system_message="You are a helpful assistant.", api_key=key, ), @@ -70,13 +69,15 @@ def __(key, mo): @app.cell(hide_code=True) -def __(mo): - mo.md("""Access the chatbot's historical messages with [`chatbot.value`](https://docs.marimo.io/api/inputs/chat.html#accessing-chat-history).""") +def _(mo): + mo.md(""" + Access the chatbot's historical messages with [`chatbot.value`](https://docs.marimo.io/api/inputs/chat.html#accessing-chat-history). + """) return @app.cell -def __(chatbot): +def _(chatbot): # chatbot.value is the list of chat messages chatbot.value return diff --git a/examples/ai/chat/generative_ui.py b/examples/ai/chat/generative_ui.py index 48c500c392f..ec66e6e8108 100644 --- a/examples/ai/chat/generative_ui.py +++ b/examples/ai/chat/generative_ui.py @@ -10,15 +10,16 @@ import marimo -__generated_with = "0.9.14" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell -def __(): +def _(): import polars as pl import marimo as mo import os + import altair has_api_key = os.environ.get("OPENAI_API_KEY") is not None mo.stop( @@ -28,11 +29,11 @@ def __(): # Grab a dataset df = pl.read_csv("hf://datasets/scikit-learn/Fish/Fish.csv") - return df, has_api_key, mo, os, pl + return df, mo @app.cell -def __(df, mo): +def _(df, mo): import ell @@ -62,11 +63,12 @@ def filter_dataset(sql_query: str): selection=None, show_column_summaries=False, ) + return chart_data, ell, filter_dataset @app.cell -def __(chart_data, df, ell, filter_dataset, mo): +def _(chart_data, df, ell, filter_dataset, mo): @ell.complex(model="gpt-4o", tools=[chart_data, filter_dataset]) def analyze_dataset(prompt: str) -> str: """You are a data scientist that can analyze a dataset""" @@ -88,7 +90,7 @@ def my_model(messages): "What is the sum of {{column}}?", ], ) - return analyze_dataset, my_model + return if __name__ == "__main__": diff --git a/examples/ai/chat/groq_example.py b/examples/ai/chat/groq_example.py index d5cc7be89f2..e9956d3efa4 100644 --- a/examples/ai/chat/groq_example.py +++ b/examples/ai/chat/groq_example.py @@ -3,36 +3,34 @@ # dependencies = [ # "marimo", # "groq==0.11.0", -# "anthropic==0.37.1", # ] # /// import marimo -__generated_with = "0.9.14" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell -def __(): +def _(): import marimo as mo + return (mo,) @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - # Using Groq +def _(mo): + mo.md(r""" + # Using Groq - This example shows how to use [`mo.ui.chat`](https://docs.marimo.io/api/inputs/chat.html#marimo.ui.chat) to make a chatbot backed by [Groq](https://groq.com/). - """ - ) + This example shows how to use [`mo.ui.chat`](https://docs.marimo.io/api/inputs/chat.html#marimo.ui.chat) to make a chatbot backed by [Groq](https://groq.com/). + """) return @app.cell(hide_code=True) -def __(mo): +def _(mo): # Hyperlinking the groq as it is mentioned here - https://console.groq.com/docs/badge mo.md( r""" @@ -49,17 +47,17 @@ def __(mo): @app.cell -def __(mo): +def _(mo): import os os_key = os.environ.get("GROQ_AI_API_KEY") input_key = mo.ui.text(label="Groq AI API key", kind="password") input_key if not os_key else None - return input_key, os, os_key + return input_key, os_key @app.cell -def __(input_key, mo, os_key): +def _(input_key, mo, os_key): key = os_key or input_key.value mo.stop( @@ -70,7 +68,7 @@ def __(input_key, mo, os_key): @app.cell -def __(key, mo): +def _(key, mo): chatbot = mo.ui.chat( mo.ai.llm.groq( model="llama-3.1-70b-versatile", @@ -88,13 +86,15 @@ def __(key, mo): @app.cell(hide_code=True) -def __(mo): - mo.md("""Access the chatbot's historical messages with [`chatbot.value`](https://docs.marimo.io/api/inputs/chat.html#accessing-chat-history).""") +def _(mo): + mo.md(""" + Access the chatbot's historical messages with [`chatbot.value`](https://docs.marimo.io/api/inputs/chat.html#accessing-chat-history). + """) return @app.cell -def __(chatbot): +def _(chatbot): # chatbot.value is the list of chat messages chatbot.value return diff --git a/examples/ai/chat/llm_datasette.py b/examples/ai/chat/llm_datasette.py index 3f81639308a..0b454aa1bc6 100644 --- a/examples/ai/chat/llm_datasette.py +++ b/examples/ai/chat/llm_datasette.py @@ -8,41 +8,46 @@ import marimo -__generated_with = "0.9.3" +__generated_with = "0.19.7" app = marimo.App() @app.cell(hide_code=True) -def __(mo): - mo.md(r"""## Using with `mo.ui.chat()`""") +def _(mo): + mo.md(r""" + ## Using with `mo.ui.chat()` + """) return @app.cell(hide_code=True) -def __(mo): - mo.md("""To set a key, run: `llm keys set openai` in your terminal""") +def _(mo): + mo.md(""" + To set a key, run: `llm keys set openai` in your terminal + """) return @app.cell -def __(): +def _(): import marimo as mo import llm + return llm, mo @app.cell -def __(llm, mo): +def _(llm, mo): model = llm.get_model("gpt-4o-mini") conversation = model.conversation() chat = mo.ui.chat(lambda messages: conversation.prompt(messages[-1].content)) chat - return chat, conversation, model + return (chat,) @app.cell -def __(chat): +def _(chat): chat.value return diff --git a/examples/ai/chat/mlx_chat.py b/examples/ai/chat/mlx_chat.py index 6d970a07124..5d6aff4c872 100644 --- a/examples/ai/chat/mlx_chat.py +++ b/examples/ai/chat/mlx_chat.py @@ -9,43 +9,42 @@ import marimo -__generated_with = "0.9.4" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell -def __(): +def _(): from mlx_lm import load, generate from pathlib import Path import marimo as mo from huggingface_hub import snapshot_download + return Path, generate, load, mo, snapshot_download @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - # Using MLX with Marimo - - ## Chat Example - This example shows how to use [`mo.ui.chat`](https://docs.marimo.io/api/inputs/chat.html#marimo.ui.chat) to make a chatbot backed by Apple's MLX, using the `mlx_lm` library and marimo. - [`mlx_lm`](https://github.com/ml-explore/mlx-examples/tree/main/llm) is a library for running large language models on Apple Silicon. - [`mlx`](https://github.com/ml-explore/mlx) is a framework for running machine learning models on Apple Silicon. - - Convert your own models to MLX, or find community-converted ones at various quantizations [here](https://huggingface.co/mlx-community). - - ### Things you can do to improve this example: - - [`prompt caching`](https://github.com/ml-explore/mlx-examples/blob/main/llms/README.md#long-prompts-and-generations) - - completions / notebook mode - - assistant pre-fill - """ - ) +def _(mo): + mo.md(r""" + # Using MLX with Marimo + + ## Chat Example + This example shows how to use [`mo.ui.chat`](https://docs.marimo.io/api/inputs/chat.html#marimo.ui.chat) to make a chatbot backed by Apple's MLX, using the `mlx_lm` library and marimo. + [`mlx_lm`](https://github.com/ml-explore/mlx-examples/tree/main/llm) is a library for running large language models on Apple Silicon. + [`mlx`](https://github.com/ml-explore/mlx) is a framework for running machine learning models on Apple Silicon. + + Convert your own models to MLX, or find community-converted ones at various quantizations [here](https://huggingface.co/mlx-community). + + ### Things you can do to improve this example: + - [`prompt caching`](https://github.com/ml-explore/mlx-examples/blob/main/llms/README.md#long-prompts-and-generations) + - completions / notebook mode + - assistant pre-fill + """) return @app.cell -def __(Path, snapshot_download): +def _(Path, snapshot_download): def get_model_path(path_or_hf_repo: str) -> Path: """ Ensures the model is available locally. If the path does not exist locally, @@ -80,11 +79,12 @@ def get_model_path(path_or_hf_repo: str) -> Path: raise ValueError( f"Error downloading model from Hugging Face: {str(e)}" ) + return (get_model_path,) @app.cell -def __(mo): +def _(mo): MODEL_ID = mo.ui.text( label="Hugging Face Model Repo or Local Path", value="mlx-community/Llama-3.2-3B-Instruct-bf16", @@ -99,7 +99,7 @@ def __(mo): @app.cell -def __(MODEL_ID, get_model_path, load, load_model_button, mo): +def _(MODEL_ID, get_model_path, load, load_model_button, mo): mo.stop(not load_model_button.value, "Click 'Load Model' to proceed") try: @@ -114,11 +114,11 @@ def __(MODEL_ID, get_model_path, load, load_model_button, mo): except Exception as e: mo.output.append(f"❌ Error loading model: {str(e)}") raise - return model, model_path, tokenizer + return model, tokenizer @app.cell(hide_code=True) -def __(mo): +def _(mo): # Create a text area for the system message system_message = mo.ui.text_area( value="You are a helpful AI assistant.", @@ -132,7 +132,7 @@ def __(mo): @app.cell(hide_code=True) -def __(mo): +def _(mo): temp_slider = mo.ui.slider( start=0.0, stop=2.0, step=0.1, value=0.7, label="Temperature Slider" ) @@ -143,15 +143,7 @@ def __(mo): @app.cell -def __( - generate, - max_tokens, - mo, - model, - system_message, - temp_slider, - tokenizer, -): +def _(generate, max_tokens, mo, model, system_message, temp_slider, tokenizer): def mlx_chat_model(messages, config): # Include the system message as the first message chat_messages = [{"role": "system", "content": system_message.value}] @@ -197,17 +189,19 @@ def mlx_chat_model(messages, config): # Display the chatbot chatbot - return chatbot, mlx_chat_model + return (chatbot,) @app.cell(hide_code=True) -def __(mo): - mo.md("""Access the chatbot's historical messages with `chatbot.value`.""") +def _(mo): + mo.md(""" + Access the chatbot's historical messages with `chatbot.value`. + """) return @app.cell -def __(chatbot): +def _(chatbot): # Display the chat history chatbot.value return diff --git a/examples/ai/chat/openai_example.py b/examples/ai/chat/openai_example.py index 670d4324601..2c2f8790901 100644 --- a/examples/ai/chat/openai_example.py +++ b/examples/ai/chat/openai_example.py @@ -8,40 +8,39 @@ import marimo -__generated_with = "0.9.14" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell -def __(): +def _(): import marimo as mo + return (mo,) @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - # Using OpenAI +def _(mo): + mo.md(r""" + # Using OpenAI - This example shows how to use [`mo.ui.chat`](https://docs.marimo.io/api/inputs/chat.html#marimo.ui.chat) to make a chatbot backed by OpenAI. - """ - ) + This example shows how to use [`mo.ui.chat`](https://docs.marimo.io/api/inputs/chat.html#marimo.ui.chat) to make a chatbot backed by OpenAI. + """) return @app.cell -def __(mo): +def _(mo): import os os_key = os.environ.get("OPENAI_API_KEY") input_key = mo.ui.text(label="OpenAI API key", kind="password") input_key if not os_key else None - return input_key, os, os_key + return input_key, os_key @app.cell -def __(input_key, mo, os_key): +def _(input_key, mo, os_key): openai_key = os_key or input_key.value mo.stop( @@ -52,7 +51,7 @@ def __(input_key, mo, os_key): @app.cell -def __(mo, openai_key): +def _(mo, openai_key): chatbot = mo.ui.chat( mo.ai.llm.openai( "gpt-4o", @@ -74,13 +73,15 @@ def __(mo, openai_key): @app.cell -def __(mo): - mo.md("""Access the chatbot's historical messages with [`chatbot.value`](https://docs.marimo.io/api/inputs/chat.html#accessing-chat-history).""") +def _(mo): + mo.md(""" + Access the chatbot's historical messages with [`chatbot.value`](https://docs.marimo.io/api/inputs/chat.html#accessing-chat-history). + """) return @app.cell -def __(chatbot): +def _(chatbot): # chatbot.value is the list of chat messages chatbot.value return diff --git a/examples/ai/chat/pydantic-ai-chat.py b/examples/ai/chat/pydantic-ai-chat.py new file mode 100644 index 00000000000..d7eb5507eb3 --- /dev/null +++ b/examples/ai/chat/pydantic-ai-chat.py @@ -0,0 +1,518 @@ +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "httpx==0.28.1", +# "marimo>=0.21.1", +# "pydantic==2.12.5", +# ] +# /// + +import marimo + +__generated_with = "0.23.10" +app = marimo.App(width="medium") + +with app.setup(hide_code=True): + import marimo as mo + import os + import httpx + + from pydantic_ai import ( + Agent, + BinaryImage, + DeferredToolRequests, + RunContext, + ) + from pydantic_ai.models.google import GoogleModel, GoogleModelSettings + from pydantic_ai.providers.google import GoogleProvider + from pydantic_ai.models.anthropic import AnthropicModel, AnthropicModelSettings + from pydantic_ai.providers.anthropic import AnthropicProvider + from pydantic_ai.providers.openai import OpenAIProvider + from pydantic_ai.models.openai import ( + OpenAIResponsesModel, + OpenAIResponsesModelSettings, + ) + from pydantic import BaseModel + from pydantic_ai.models import Model + from pydantic_ai.settings import ModelSettings + + +@app.cell(hide_code=True) +def _(): + mo.md(r""" + # Pydantic-AI 🤖 + + [Pydantic AI](https://ai.pydantic.dev/) is a modern framework to build applications that interact with LLMs. Key features include + + * ✨ **Structured Outputs:** Force LLMs to return clean, structured data (like JSON) that conforms to your Pydantic models. + * ✅ **Validation & Type-Safety:** Use Pydantic's validation and Python's type hints to ensure data integrity and make your code robust. + * 🧠 **Reasoning & Tool Use:** Define output models for complex reasoning tasks and reliable function calling (tool use). + + The following example uses [`mo.ui.chat`](https://docs.marimo.io/api/inputs/chat.html#marimo.ui.chat) to build a chatbot backed by Pydantic-AI. + """) + return + + +@app.cell(hide_code=True) +def _(): + structured = mo.ui.checkbox(label="Structured outputs") + thinking = mo.ui.checkbox(label="Reasoning") + fetch_dog_tool = mo.ui.checkbox(label="Fetch dog pics tool") + delete_file_tool = mo.ui.checkbox(label="Delete file tool (requires approval)") + + models = mo.ui.dropdown( + options={ + "Gemini 2.5 Flash": "gemini-2.5-flash", + "Claude Haiku 4.5": "claude-haiku-4-5", + "GPT 5 Nano": "gpt-5-nano", + "GPT 5 (multimodal)": "gpt-5", + }, + value="Gemini 2.5 Flash", + label="Choose a model", + ) + + mo.vstack([models, structured, thinking, fetch_dog_tool, delete_file_tool]) + return delete_file_tool, fetch_dog_tool, models, structured, thinking + + +@app.cell(hide_code=True) +def _(models): + model_name = models.value + if model_name.startswith("gemini"): + env_key = "GOOGLE_AI_API_KEY" + elif model_name.startswith("claude"): + env_key = "ANTHROPIC_API_KEY" + elif model_name.startswith("gpt"): + env_key = "OPENAI_API_KEY" + else: + raise NotImplementedError + + os_key = os.environ.get(env_key) + input_key = mo.ui.text(label="API key", kind="password") + input_key if not os_key else None + return input_key, model_name, os_key + + +@app.function +def get_model( + model_name: str, thinking: bool, api_key: str +) -> tuple[Model, ModelSettings]: + model_name = model_name.lower() + + if model_name.startswith("gemini"): + provider = GoogleProvider(api_key=api_key) + model = GoogleModel(model_name, provider=provider) + settings = GoogleModelSettings( + google_thinking_config={ + "include_thoughts": True if thinking else False + } + ) + elif model_name.startswith("claude"): + model = AnthropicModel( + model_name, provider=AnthropicProvider(api_key=api_key) + ) + settings = AnthropicModelSettings( + anthropic_thinking={"type": "enabled", "budget_tokens": 1024} + if thinking + else {"type": "disabled"}, + ) + elif model_name.startswith("gpt"): + model = OpenAIResponsesModel( + model_name, provider=OpenAIProvider(api_key=api_key) + ) + settings = ( + OpenAIResponsesModelSettings( + openai_reasoning_effort="low", + openai_reasoning_summary="detailed", + ) + if thinking + else OpenAIResponsesModelSettings() + ) + else: + raise NotImplementedError + + return model, settings + + +@app.cell(hide_code=True) +def _( + delete_file_tool, + fetch_dog_tool, + input_key, + model_name, + models, + os_key, + structured, + thinking, +): + class CodeOutput(BaseModel): + code: str + time_complexity: str + memory_complexity: str + algorithm_complexity: int + + + api_key = input_key.value or os_key + model, settings = get_model(models.value, thinking.value, api_key) + + output_type = str + if "image" in model_name or model_name == "gpt-5": + output_type = BinaryImage | str + elif structured.value: + output_type = [CodeOutput, str] + + # Tools that pause for human approval require `DeferredToolRequests` + # in the output type; pydantic-ai returns it whenever a tool flagged + # `requires_approval=True` is called. + if delete_file_tool.value: + if isinstance(output_type, list): + output_type = [*output_type, DeferredToolRequests] + else: + output_type = [output_type, DeferredToolRequests] + + agent = Agent( + model, + output_type=output_type, + instructions="You are a senior software engineer experienced in Python, React and Typescript.", + model_settings=settings, + ) + + if fetch_dog_tool.value: + + @agent.tool + def fetch_dog_picture_url(ctx: RunContext[str]) -> str: + """Returns URL of dog picture""" + response_json = httpx.get( + "https://dog.ceo/api/breeds/image/random" + ).json() + if "message" in response_json: + return response_json["message"] + else: + return "Error fetching dog URL" + + + if delete_file_tool.value: + + @agent.tool_plain(requires_approval=True) + def delete_file(path: str) -> str: + """Pretend to delete the file at `path`.""" + return f"File {path!r} deleted" + return (agent,) + + +@app.cell +def _(agent): + chatbot = mo.ui.chat( + mo.ai.llm.pydantic_ai(agent), + prompts=[ + "Write the fibonacci function in Python", + "Who is Ada Lovelace?", + "What is marimo?", + "I need dogs (render as markdown)", + "Delete the file at path 'secrets.env'", + ], + allow_attachments=True, + show_configuration_controls=True, + ) + chatbot + return (chatbot,) + + +@app.cell +def _(chatbot): + chatbot.value + return + + +@app.cell(hide_code=True) +def _(): + mo.md(r""" + ## Custom model sample + + `mo.ui.chat` accepts any async generator that yields Vercel AI SDK chunks. + The model below is a hand-rolled showcase of every part the SDK knows + about — reasoning, streamed tool input, file/source/data attachments, + a deliberately failed tool, and a final tool that pauses for human + approval. + """) + return + + +@app.cell(hide_code=True) +def _(): + import asyncio + import uuid + + import pydantic_ai.ui.vercel_ai.response_types as vercel + + + def _new_id(prefix: str) -> str: + return f"{prefix}_{uuid.uuid4().hex[:8]}" + + + def _pending_approval(messages) -> dict | None: + """Find a tool part the user just approved or denied, if any. + + After Approve/Deny, the SDK transitions the tool part on the last + assistant message to `approval-responded` and auto-resumes. We + look for that state on the most recent assistant turn so we know + whether to start a fresh showcase or finish the deletion. + """ + for message in reversed(messages): + if message.role != "assistant": + continue + for part in message.raw_or_dumped_parts(): + if not isinstance(part, dict): + continue + if not str(part.get("type", "")).startswith("tool-"): + continue + if part.get("state") == "approval-responded": + return part + return None + return None + + + async def _showcase_turn(): + reasoning_id = _new_id("reasoning") + search_id = _new_id("tc") + translate_id = _new_id("tc") + delete_id = _new_id("tc") + approval_id = _new_id("ap") + intro_id = _new_id("text") + followup_id = _new_id("text") + error_text_id = _new_id("text") + ask_id = _new_id("text") + data_id = _new_id("data") + + # Message-level metadata round-trips on `message.metadata` in the UI. + yield vercel.MessageMetadataChunk( + message_metadata={"demo": "vercel-ai-sdk-showcase", "turn": 1} + ) + + # ── Step 1: think + run a tool that succeeds ────────────────── + yield vercel.StartStepChunk() + + yield vercel.ReasoningStartChunk(id=reasoning_id) + for chunk in [ + "The user wants the full tour. ", + "I'll search for a famous painting, ", + "compose an answer with citations and an image, ", + "demonstrate an erroring tool, ", + "and finally offer to clean up a temp file ", + "behind a human-approval gate.", + ]: + yield vercel.ReasoningDeltaChunk(id=reasoning_id, delta=chunk) + await asyncio.sleep(0.04) + yield vercel.ReasoningEndChunk(id=reasoning_id) + + yield vercel.ToolInputStartChunk( + tool_call_id=search_id, tool_name="search_artwork" + ) + for delta in ['{"artist":', ' "Vincent van Gogh",', ' "limit": 1}']: + yield vercel.ToolInputDeltaChunk( + tool_call_id=search_id, input_text_delta=delta + ) + await asyncio.sleep(0.04) + yield vercel.ToolInputAvailableChunk( + tool_call_id=search_id, + tool_name="search_artwork", + input={"artist": "Vincent van Gogh", "limit": 1}, + ) + yield vercel.ToolOutputAvailableChunk( + tool_call_id=search_id, + output={ + "title": "The Starry Night", + "year": 1889, + "museum": "Museum of Modern Art", + }, + ) + + yield vercel.FinishStepChunk() + + # ── Step 2: compose the answer with rich media ──────────────── + yield vercel.StartStepChunk() + + yield vercel.TextStartChunk(id=intro_id) + for delta in [ + "One of Vincent van Gogh's most iconic works is ", + "**The Starry Night**, painted in 1889. ", + "Here is the painting:", + ]: + yield vercel.TextDeltaChunk(id=intro_id, delta=delta) + await asyncio.sleep(0.04) + yield vercel.TextEndChunk(id=intro_id) + + yield vercel.FileChunk( + url=( + "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ea/" + "Van_Gogh_-_Starry_Night_-_Google_Art_Project.jpg/" + "1280px-Van_Gogh_-_Starry_Night_-_Google_Art_Project.jpg" + ), + media_type="image/jpeg", + ) + + yield vercel.SourceUrlChunk( + source_id=_new_id("src"), + url="https://www.moma.org/collection/works/79802", + title="The Starry Night | MoMA", + ) + yield vercel.SourceDocumentChunk( + source_id=_new_id("src"), + media_type="application/pdf", + title="Faille catalogue raisonné, vol. III", + filename="van-gogh-catalogue.pdf", + ) + + # Custom data-* parts let backends ship arbitrary structured + # payloads to bespoke UI widgets without bending the text channel. + yield vercel.DataChunk( + id=data_id, + type="data-artwork-card", + data={ + "title": "The Starry Night", + "year": 1889, + "movement": "Post-Impressionism", + }, + ) + + yield vercel.TextStartChunk(id=followup_id) + yield vercel.TextDeltaChunk( + id=followup_id, + delta=( + "\n\nNext I'll try a translation tool that's expected to" + " fail — handy for seeing how errors render." + ), + ) + yield vercel.TextEndChunk(id=followup_id) + + yield vercel.FinishStepChunk() + + # ── Step 3: a tool whose execution fails ────────────────────── + yield vercel.StartStepChunk() + + yield vercel.ToolInputStartChunk( + tool_call_id=translate_id, tool_name="translate" + ) + yield vercel.ToolInputAvailableChunk( + tool_call_id=translate_id, + tool_name="translate", + input={"text": "Sterrennacht", "from": "nl", "to": "klingon"}, + ) + yield vercel.ToolOutputErrorChunk( + tool_call_id=translate_id, + error_text="UnsupportedLanguage: 'klingon' is not a supported target.", + ) + + yield vercel.TextStartChunk(id=error_text_id) + yield vercel.TextDeltaChunk( + id=error_text_id, + delta="That call failed, as expected — moving on.", + ) + yield vercel.TextEndChunk(id=error_text_id) + + yield vercel.FinishStepChunk() + + # ── Step 4: ask for approval, then stop ─────────────────────── + yield vercel.StartStepChunk() + + yield vercel.TextStartChunk(id=ask_id) + yield vercel.TextDeltaChunk( + id=ask_id, + delta=( + "I'd like to delete the search cache file. " + "Approve below to proceed, or deny to keep it." + ), + ) + yield vercel.TextEndChunk(id=ask_id) + + yield vercel.ToolInputStartChunk( + tool_call_id=delete_id, tool_name="delete_file" + ) + yield vercel.ToolInputAvailableChunk( + tool_call_id=delete_id, + tool_name="delete_file", + input={"path": "/tmp/van-gogh-search.cache"}, + ) + yield vercel.ToolApprovalRequestChunk( + approval_id=approval_id, tool_call_id=delete_id + ) + + yield vercel.FinishStepChunk() + yield vercel.FinishChunk(finish_reason="tool-calls") + + + async def _resume_after_approval(pending: dict): + tool_call_id = pending["toolCallId"] + approval = pending.get("approval") or {} + approved = bool(approval.get("approved")) + path = (pending.get("input") or {}).get("path", "") + + text_id = _new_id("text") + + yield vercel.MessageMetadataChunk( + message_metadata={ + "demo": "vercel-ai-sdk-showcase", + "turn": 2, + "approval": approval, + } + ) + yield vercel.StartStepChunk() + + if approved: + yield vercel.ToolOutputAvailableChunk( + tool_call_id=tool_call_id, + output={"deleted": True, "path": path}, + ) + yield vercel.TextStartChunk(id=text_id) + yield vercel.TextDeltaChunk( + id=text_id, delta=f"Done — `{path}` has been removed." + ) + yield vercel.TextEndChunk(id=text_id) + else: + yield vercel.ToolOutputDeniedChunk(tool_call_id=tool_call_id) + yield vercel.TextStartChunk(id=text_id) + yield vercel.TextDeltaChunk( + id=text_id, + delta=( + f"No problem — I'll leave `{path}` alone. " + f"Reason: {approval.get('reason') or 'no reason given'}." + ), + ) + yield vercel.TextEndChunk(id=text_id) + + yield vercel.FinishStepChunk() + yield vercel.FinishChunk(finish_reason="stop") + + + async def custom_model(messages, config): + del config + + pending = _pending_approval(messages) + if pending is not None: + async for chunk in _resume_after_approval(pending): + yield chunk + return + + async for chunk in _showcase_turn(): + yield chunk + + + custom_chat = mo.ui.chat( + custom_model, + prompts=[ + "Run the full Vercel AI SDK part showcase", + "Show me reasoning, citations, and an approval-gated tool", + ], + ) + custom_chat + return (custom_chat,) + + +@app.cell +def _(custom_chat): + custom_chat.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ai/chat/recipe_bot.py b/examples/ai/chat/recipe_bot.py index 43c229e9134..f36823c392e 100644 --- a/examples/ai/chat/recipe_bot.py +++ b/examples/ai/chat/recipe_bot.py @@ -5,14 +5,15 @@ # "openai==1.53.0", # ] # /// + import marimo -__generated_with = "0.9.10" +__generated_with = "0.15.5" app = marimo.App(width="medium") @app.cell -def __(): +def _(): import marimo as mo chat = mo.ui.chat( @@ -30,7 +31,7 @@ def __(): @app.cell -def __(chat, mo): +def _(chat, mo): mo.stop(not chat.value) last_message: str = chat.value[-1].content @@ -39,7 +40,7 @@ def __(chat, mo): with open(f"{title}.md", "w") as f: f.write(summary) mo.status.toast("Receipt summary saved!", description=title) - return f, last_message, summary, title + return if __name__ == "__main__": diff --git a/examples/ai/chat/simplemind_example.py b/examples/ai/chat/simplemind_example.py index 42a529fa608..0731a0c86df 100644 --- a/examples/ai/chat/simplemind_example.py +++ b/examples/ai/chat/simplemind_example.py @@ -8,26 +8,29 @@ import marimo -__generated_with = "0.9.14" +__generated_with = "0.19.7" app = marimo.App(width="full") @app.cell(hide_code=True) -def __(mo): - mo.md(r"""## Using [simplemind](https://github.com/kennethreitz/simplemind) with `mo.ui.chat()`""") +def _(mo): + mo.md(r""" + ## Using [simplemind](https://github.com/kennethreitz/simplemind) with `mo.ui.chat()` + """) return @app.cell(hide_code=True) -def __(): +def _(): import marimo as mo import os import simplemind as sm + return mo, os, sm @app.cell(hide_code=True) -def __(__file__, mo, os): +def _(mo, os): has_set_env = os.environ.get("OPENAI_API_KEY") is not None mo.md(f""" Missing OpenAI API key. Re-run this notebook with the following command: @@ -37,17 +40,17 @@ def __(__file__, mo, os): marimo edit {__file__} ``` """).callout("warn") if not has_set_env else "" - return (has_set_env,) + return @app.cell -def __(mo): +def _(mo): get_logs, set_logs = mo.state([], allow_self_loops=True) return get_logs, set_logs @app.cell -def __(set_logs, sm): +def _(set_logs, sm): def add_log(value): return set_logs(lambda logs: logs + [value]) @@ -71,11 +74,12 @@ def initialize_hook(self, conversation): def post_send_hook(self, conversation, response): add_log(f"Received response: {response.text}") - return LoggingPlugin, add_log + + return (LoggingPlugin,) @app.cell -def __(LoggingPlugin, mo, sm): +def _(LoggingPlugin, mo, sm): conversation = sm.create_conversation( llm_model="gpt-4o", llm_provider="openai" ) @@ -88,22 +92,22 @@ def on_message(messages): chat = mo.ui.chat(on_message) - return chat, conversation, on_message + return (chat,) @app.cell -def __(chat, get_logs, mo): +def _(chat, get_logs, mo): logs = list(reversed(get_logs())) mo.hstack( [chat, mo.ui.table(logs, selection=None)], widths="equal", ) - return (logs,) + return @app.cell -def __(chat): +def _(chat): chat.value return diff --git a/examples/ai/chat/streaming_custom.py b/examples/ai/chat/streaming_custom.py new file mode 100644 index 00000000000..6caad081106 --- /dev/null +++ b/examples/ai/chat/streaming_custom.py @@ -0,0 +1,107 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "marimo", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + import asyncio + + return asyncio, mo + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + # Custom streaming chatbot + + This example shows how to make a chatbot that streams responses. + Create an async generator function that yields intermediate results, + and watch the response appear incrementally! + """) + return + + +@app.cell +def _(asyncio, mo): + async def streaming_echo_model(messages, config): + """This chatbot echoes what the user says, word by word. + + Yields individual delta chunks that are accumulated by marimo. + This follows the standard streaming pattern used by OpenAI, Anthropic, + and other AI providers. + """ + # Get the user's message + user_message = messages[-1].content + + # Stream the response word by word + response = f"You said: '{user_message}'. Here's my response streaming word by word!" + words = response.split() + + for word in words: + yield word + " " # Yield delta chunks + await asyncio.sleep(0.2) # Delay to make streaming visible + + chatbot = mo.ui.chat( + streaming_echo_model, + prompts=["Hello", "Tell me a story", "What is streaming?"], + show_configuration_controls=True + ) + return (chatbot,) + + +@app.cell +def _(chatbot): + chatbot + return + + +@app.cell +def _(mo): + mo.md(""" + ## How it works + + The key is to make your model function an **async generator** that yields **delta chunks**: + + ```python + async def my_model(messages, config): + # Yield individual pieces of content (deltas) + for word in ['Building', 'up', 'text...']: + yield word + ' ' # Each yield is a delta + await asyncio.sleep(0.1) + ``` + + Each `yield` sends a new chunk to marimo, which accumulates and displays them. + This follows the standard streaming pattern used by OpenAI, Anthropic, and other AI providers. + + **Important**: Yield delta chunks (new content only), not accumulated text. + """) + return + + +@app.cell +def _(mo): + mo.md(""" + Access the chatbot's historical messages with `chatbot.value`. + """) + return + + +@app.cell +def _(chatbot): + # chatbot.value is the list of chat messages + chatbot.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ai/data/data_labeler.py b/examples/ai/data/data_labeler.py index 9ab9e7d67b8..23869914d01 100644 --- a/examples/ai/data/data_labeler.py +++ b/examples/ai/data/data_labeler.py @@ -1,17 +1,19 @@ import marimo -__generated_with = "0.1.4" +__generated_with = "0.19.7" app = marimo.App() @app.cell -def __(mo): - mo.md("# Data Labeler") +def _(mo): + mo.md(""" + # Data Labeler + """) return @app.cell -def __(NUMBER_OF_EXAMPLES, mo): +def _(NUMBER_OF_EXAMPLES, mo): get_index, set_index = mo.state(0) @@ -21,11 +23,12 @@ def increment_index(): def decrement_index() -> int: set_index(lambda v: max(0, v - 1)) + return decrement_index, get_index, increment_index, set_index @app.cell -def __(decrement_index, increment_index, mo): +def _(decrement_index, increment_index, mo): next_button = mo.ui.button(label="next", on_change=lambda _: increment_index()) previous_button = mo.ui.button( @@ -35,13 +38,15 @@ def __(decrement_index, increment_index, mo): @app.cell -def __(mo): - mo.md(f"**Choose an example to label.**") +def _(mo): + mo.md(f""" + **Choose an example to label.** + """) return @app.cell -def __(NUMBER_OF_EXAMPLES, get_index, mo, set_index): +def _(NUMBER_OF_EXAMPLES, get_index, mo, set_index): index = mo.ui.number( 0, NUMBER_OF_EXAMPLES - 1, @@ -51,51 +56,53 @@ def __(NUMBER_OF_EXAMPLES, get_index, mo, set_index): label="example number", on_change=set_index, ) - return index, + return (index,) @app.cell -def __(index, mo, next_button, previous_button): +def _(index, mo, next_button, previous_button): mo.hstack([index, previous_button, next_button], justify="start") return @app.cell -def __(index, mo): +def _(index, mo): mo.md(f"![img](https://picsum.photos/id/{index.value}/700/400)").center() return @app.cell -def __(mo): +def _(mo): mo.md("### Real or AI generated?").center() return @app.cell -def __(LABELS_PATH, NUMBER_OF_EXAMPLES, load_labels): +def _(LABELS_PATH, NUMBER_OF_EXAMPLES, load_labels): labels = load_labels(LABELS_PATH, NUMBER_OF_EXAMPLES) - return labels, + return (labels,) @app.cell -def __(LABELS_PATH, labels, write_labels): +def _(LABELS_PATH, labels, write_labels): def update_label(value, index): labels[index]["label"] = value write_labels(labels, LABELS_PATH) - return update_label, + + return (update_label,) @app.cell -def __(LABELS_PATH, labels, write_labels): +def _(LABELS_PATH, labels, write_labels): def update_notes(value, index): labels[index]["notes"] = value write_labels(labels, LABELS_PATH) - return update_notes, + + return (update_notes,) @app.cell -def __(mo, notes): +def _(mo, notes): mo.stop(len(notes.value) <= 100) _character_count = mo.md(f"`{len(notes.value)}/100` characters used").right() @@ -109,7 +116,7 @@ def __(mo, notes): @app.cell -def __(index, labels, mo, update_label, update_notes): +def _(index, labels, mo, update_label, update_notes): data = labels[index.value] label_picker = mo.ui.radio( @@ -126,11 +133,11 @@ def __(index, labels, mo, update_label, update_notes): ) mo.hstack([label_picker, notes], justify="space-around") - return data, label_picker, notes + return (notes,) @app.cell -def __(json, os): +def _(json, os): def load_labels(path, number_of_examples): if not os.path.exists(path): return [ @@ -138,7 +145,7 @@ def load_labels(path, number_of_examples): for _ in range(number_of_examples) ] - with open(path, "r") as f: + with open(path, "r", encoding="utf-8") as f: labels = json.loads(f.read()) assert len(labels) == number_of_examples return labels @@ -150,33 +157,36 @@ def write_labels(labels, path): {"label": item["label"], "notes": item["notes"][:100]} for item in labels ] - with open(path, "w") as f: + with open(path, "w", encoding="utf-8") as f: f.write(json.dumps(labels)) + return load_labels, write_labels @app.cell -def __(): +def _(): NUMBER_OF_EXAMPLES = 100 - return NUMBER_OF_EXAMPLES, + return (NUMBER_OF_EXAMPLES,) @app.cell -def __(): +def _(): LABELS_PATH = "labels.json" - return LABELS_PATH, + return (LABELS_PATH,) @app.cell -def __(): +def _(): import marimo as mo - return mo, + + return (mo,) @app.cell -def __(): +def _(): import json import os + return json, os diff --git a/examples/ai/data/model_comparison.py b/examples/ai/data/model_comparison.py index 5281d507246..302f090cd4b 100644 --- a/examples/ai/data/model_comparison.py +++ b/examples/ai/data/model_comparison.py @@ -7,29 +7,29 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App() @app.cell(hide_code=True) -def __(mo): - mo.md("""# Model Comparison""") +def _(mo): + mo.md(""" + # Model Comparison + """) return @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - !!! tip "This notebook is best viewed as an app." - Hit `Cmd/Ctrl+.` or click the "app view" button in the bottom right. - """ - ) +def _(mo): + mo.md(r""" + !!! tip "This notebook is best viewed as an app." + Hit `Cmd/Ctrl+.` or click the "app view" button in the bottom right. + """) return @app.cell(hide_code=True) -def __(NUMBER_OF_EXAMPLES, mo): +def _(NUMBER_OF_EXAMPLES, mo): get_index, set_index = mo.state(0) @@ -44,18 +44,11 @@ def decrement_index() -> int: previous_button = mo.ui.button( label="previous", on_change=lambda _: decrement_index() ) - return ( - decrement_index, - get_index, - increment_index, - next_button, - previous_button, - set_index, - ) + return get_index, next_button, previous_button, set_index @app.cell(hide_code=True) -def __(NUMBER_OF_EXAMPLES, get_index, mo, set_index): +def _(NUMBER_OF_EXAMPLES, get_index, mo, set_index): index = mo.ui.number( 0, NUMBER_OF_EXAMPLES - 1, @@ -69,13 +62,15 @@ def __(NUMBER_OF_EXAMPLES, get_index, mo, set_index): @app.cell(hide_code=True) -def __(mo): - mo.md(f"_Models A and B both predict spans. Which do you prefer?_") +def _(mo): + mo.md(f""" + _Models A and B both predict spans. Which do you prefer?_ + """) return @app.cell(hide_code=True) -def __(NUMBER_OF_EXAMPLES, mo, num_a_preferred, num_b_preferred): +def _(NUMBER_OF_EXAMPLES, mo, num_a_preferred, num_b_preferred): mo.ui.table( [ {"Model": "A", "Score": f"{num_a_preferred}/{NUMBER_OF_EXAMPLES}"}, @@ -87,22 +82,22 @@ def __(NUMBER_OF_EXAMPLES, mo, num_a_preferred, num_b_preferred): @app.cell -def __(index, mo, next_button, previous_button): +def _(index, mo, next_button, previous_button): mo.hstack([index, previous_button, next_button], justify="center") return @app.cell(hide_code=True) -def __(CHOICES_PATH, get_choices, index, mo, write_choices): +def _(CHOICES_PATH, get_choices, index, mo, write_choices): preference = get_choices()[index.value] mo.stop(preference is None, mo.md("**Choose the better model**.").center()) write_choices(get_choices(), CHOICES_PATH) mo.md(f"You prefer **model {preference}**.").center() - return (preference,) + return @app.cell(hide_code=True) -def __(annotate, mo): +def _(mo): mo.hstack( [ mo.md(annotate("Model A", [0, len("Model A")], "yellow")), @@ -114,7 +109,7 @@ def __(annotate, mo): @app.cell(hide_code=True) -def __(CHOICES_PATH, PARAGRAPHS, load_choices, mo): +def _(CHOICES_PATH, PARAGRAPHS, load_choices, mo): get_choices, set_choices = mo.state( load_choices(CHOICES_PATH, len(PARAGRAPHS)) ) @@ -122,7 +117,7 @@ def __(CHOICES_PATH, PARAGRAPHS, load_choices, mo): @app.cell(hide_code=True) -def __(index, mo, set_choices): +def _(index, mo, set_choices): model_A = mo.ui.button( label="Model A", on_change=lambda _: set_choices( @@ -137,11 +132,11 @@ def __(index, mo, set_choices): ), ) mo.hstack([model_A, model_B], justify="space-around") - return model_A, model_B + return @app.cell(hide_code=True) -def __(PARAGRAPHS, SPANS, annotate, index, mo): +def _(PARAGRAPHS, SPANS, index, mo): model_A_prediction = mo.md( annotate( PARAGRAPHS[index.value], @@ -161,7 +156,7 @@ def __(PARAGRAPHS, SPANS, annotate, index, mo): @app.cell -def __(mo, model_A_prediction, model_B_prediction): +def _(mo, model_A_prediction, model_B_prediction): mo.hstack( [model_A_prediction, model_B_prediction], gap=2, justify="space-around" ) @@ -169,20 +164,20 @@ def __(mo, model_A_prediction, model_B_prediction): @app.cell -def __(get_choices): +def _(get_choices): num_a_preferred = sum(1 for c in get_choices() if c == "A") num_b_preferred = sum(1 for c in get_choices() if c == "B") return num_a_preferred, num_b_preferred @app.cell -def __(): +def _(): CHOICES_PATH = "choices.json" return (CHOICES_PATH,) @app.cell -def __(json, os): +def _(json, os): def load_choices(path, number_of_examples): if not os.path.exists(path): return [ @@ -190,7 +185,7 @@ def load_choices(path, number_of_examples): for _ in range(number_of_examples) ] - with open(path, "r") as f: + with open(path, "r", encoding="utf-8") as f: choices = json.loads(f.read()) assert len(choices) == number_of_examples return choices @@ -198,13 +193,14 @@ def load_choices(path, number_of_examples): def write_choices(choices, path): # Trunacate notes - with open(path, "w") as f: + with open(path, "w", encoding="utf-8") as f: f.write(json.dumps(choices)) + return load_choices, write_choices @app.cell -def __(PARAGRAPHS, random): +def _(PARAGRAPHS, random): random.seed(0) @@ -217,11 +213,11 @@ def predict_spans(text): return first, second SPANS = [predict_spans(p) for p in PARAGRAPHS] - return SPANS, predict_spans + return (SPANS,) @app.cell -def __(HAMLET, textwrap): +def _(HAMLET, textwrap): PARAGRAPHS = [ textwrap.dedent(block).strip()[:1000] for block in HAMLET.split("\n\n") @@ -230,48 +226,48 @@ def __(HAMLET, textwrap): return (PARAGRAPHS,) -@app.cell -def __(): - def annotate(text, span, color): - mark_start = f"" - return ( - text[: span[0]] - + mark_start - + text[span[0] : span[1]] - + "" - + text[span[1] :] - ) - return (annotate,) +@app.function +def annotate(text, span, color): + mark_start = f"" + return ( + text[: span[0]] + + mark_start + + text[span[0] : span[1]] + + "" + + text[span[1] :] + ) @app.cell -def __(PARAGRAPHS): +def _(PARAGRAPHS): NUMBER_OF_EXAMPLES = len(PARAGRAPHS) return (NUMBER_OF_EXAMPLES,) @app.cell -def __(urllib): +def _(urllib): _hamlet_url = "https://gist.githubusercontent.com/provpup/2fc41686eab7400b796b/raw/b575bd01a58494dfddc1d6429ef0167e709abf9b/hamlet.txt" with urllib.request.urlopen(_hamlet_url) as f: HAMLET = f.read().decode('utf-8') - return HAMLET, f + return (HAMLET,) @app.cell -def __(): +def _(): import marimo as mo + return (mo,) @app.cell -def __(): +def _(): import json import os import random import textwrap import urllib + return json, os, random, textwrap, urllib diff --git a/examples/ai/misc/build_a_superhero.py b/examples/ai/misc/build_a_superhero.py index 7558231df17..1c21819ab29 100644 --- a/examples/ai/misc/build_a_superhero.py +++ b/examples/ai/misc/build_a_superhero.py @@ -8,34 +8,36 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App() @app.cell(hide_code=True) -def __(mo): - mo.md("""## Build a Superhero with Generative AI""") +def _(mo): + mo.md(""" + ## Build a Superhero with Generative AI + """) return @app.cell -def __(mo): +def _(mo): openaikey = mo.ui.text(label="🤖 OpenAI Key", kind="password") config = mo.hstack([openaikey]) mo.accordion({"⚙️ Enter your OpenAI key": config}) - return config, openaikey + return (openaikey,) @app.cell -def __(mo): +def _(mo): item = mo.ui.text(label="Enter the name of an animal: ").form() item return (item,) @app.cell -def __(item, mo): +def _(item, mo): content = f"💬 Suggest three superhero names, given the following animal: {item.value}" mo.md(content) if item.value else None @@ -43,7 +45,7 @@ def __(item, mo): @app.cell -def __(content, item, mo, openai, openaikey): +def _(content, item, mo, openai, openaikey): openai.api_key = openaikey.value result = None @@ -64,15 +66,15 @@ def __(content, item, mo, openai, openaikey): mo.md( f""" 🤖 Response: - + {result} """ ) if item.value else None - return response, result + return (result,) @app.cell -def __(mo, result): +def _(mo, result): choices = result.split("\n") if result else [] superhero = mo.ui.dropdown(choices) @@ -81,11 +83,11 @@ def __(mo, result): Choose a superhero: {superhero} """ ) if result else None - return choices, superhero + return (superhero,) @app.cell -def __(mo, openai, superhero): +def _(mo, openai, superhero): catchphrase = None if superhero.value: @@ -111,18 +113,18 @@ def __(mo, openai, superhero): {catchphrase} """ ) if superhero.value else None - return catchphrase, catchphraseResponse + return (catchphrase,) @app.cell -def __(catchphrase, mo): +def _(catchphrase, mo): generate_image_button = mo.ui.button(label="📷 Generate Image") generate_image_button if catchphrase else None return (generate_image_button,) @app.cell -def __(generate_image_button, mo, openai, superhero): +def _(generate_image_button, mo, openai, superhero): generate_image_button url = None @@ -135,13 +137,14 @@ def __(generate_image_button, mo, openai, superhero): url = res["data"][0]["url"] mo.image(src=url) if url else None - return res, url + return @app.cell -def __(): +def _(): import marimo as mo import openai + return mo, openai diff --git a/examples/ai/misc/micrograd_mlp.py b/examples/ai/misc/micrograd_mlp.py index dac1a892b43..f9fe5052133 100644 --- a/examples/ai/misc/micrograd_mlp.py +++ b/examples/ai/misc/micrograd_mlp.py @@ -11,108 +11,103 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App() @app.cell(hide_code=True) -def __(mo): - mo.md("""# Neural Networks with Micrograd""") +def _(mo): + mo.md(""" + # Neural Networks with Micrograd + """) return @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - This marimo notebook trains a tiny **neural network** using - Andrej Karpathy's [micrograd - library](https://github.com/karpathy/micrograd). - Micrograd is an implementation of PyTorch-like automatic - differentiation using only Python scalar operations. This notebook was - adapted from a [demo - notebook](https://github.com/karpathy/micrograd/blob/master/demo.ipynb) by - Andrej. - """ - ) +def _(mo): + mo.md(""" + This marimo notebook trains a tiny **neural network** using + Andrej Karpathy's [micrograd + library](https://github.com/karpathy/micrograd). + Micrograd is an implementation of PyTorch-like automatic + differentiation using only Python scalar operations. This notebook was + adapted from a [demo + notebook](https://github.com/karpathy/micrograd/blob/master/demo.ipynb) by + Andrej. + """) return @app.cell -def __(): +def _(): import random import numpy as np import matplotlib.pyplot as plt np.random.seed(1337) random.seed(1337) - return np, plt, random + return np, plt @app.cell -def __(): +def _(): from micrograd.engine import Value from micrograd.nn import Neuron, Layer, MLP - return Layer, MLP, Neuron, Value + + return MLP, Value @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - We start by generating a synthetic dataset of points labeled +1 or -1. - Our goal is to train a network that can classify these points according - to their labels, learning a decision boundary that separates them. - """ - ) +def _(mo): + mo.md(""" + We start by generating a synthetic dataset of points labeled +1 or -1. + Our goal is to train a network that can classify these points according + to their labels, learning a decision boundary that separates them. + """) return @app.cell -def __(): +def _(): from sklearn.datasets import make_moons, make_blobs X, y = make_moons(n_samples=100, noise=0.1) y = y * 2 - 1 # make y be -1 or 1 - return X, make_blobs, make_moons, y + return X, y @app.cell -def __(X, mo, plt, y): +def _(X, mo, plt, y): plt.figure(figsize=(5, 5)) mo.center(plt.scatter(X[:, 0], X[:, 1], c=y, s=20, cmap="jet")) return @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - Note that the decision boundary must be **nonlinear**, which can be readily - learned by neural networks. This could also be achieved by "shallow" or - classical machine learning methods with the appropriate featurization or - [kernelization](https://scikit-learn.org/stable/modules/svm.html). - """ - ) +def _(mo): + mo.md(""" + Note that the decision boundary must be **nonlinear**, which can be readily + learned by neural networks. This could also be achieved by "shallow" or + classical machine learning methods with the appropriate featurization or + [kernelization](https://scikit-learn.org/stable/modules/svm.html). + """) return @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - **Try it!** Train a neural network by hitting the "Train" button. The - learned decision boundary will be plotted below. - - _Try experimenting with the parameters. What happens if you change - the number of layers and their sizes?_ - """ - ) +def _(mo): + mo.md(""" + **Try it!** Train a neural network by hitting the "Train" button. The + learned decision boundary will be plotted below. + + _Try experimenting with the parameters. What happens if you change + the number of layers and their sizes?_ + """) return @app.cell -def __(mo): +def _(mo): n_layers = mo.ui.slider( start=1, stop=2, step=1, value=2, show_value=True, label="number of layers" @@ -122,7 +117,7 @@ def __(mo): @app.cell -def __(mo, n_layers): +def _(mo, n_layers): layer_sizes = mo.ui.array([ mo.ui.slider(4, 16, step=1, value=12, show_value=True) for i in range(n_layers.value) @@ -151,11 +146,11 @@ def __(mo, n_layers): ) training_parameters - return iterations, layer_sizes, training_parameters + return (training_parameters,) @app.cell -def __(MLP, mo, n_layers, train, training_parameters): +def _(MLP, mo, n_layers, train, training_parameters): mo.stop( training_parameters.value is None, mo.md("Click the `Train` button to continue").callout(kind="warn") @@ -169,17 +164,17 @@ def __(MLP, mo, n_layers, train, training_parameters): model, iters=training_parameters.value["iterations"] ) - return model, trained_model + return (trained_model,) @app.cell -def __(plot_decision_boundary, trained_model): +def _(plot_decision_boundary, trained_model): plot_decision_boundary(trained_model) return @app.cell -def __(Value, X, np, y): +def _(Value, X, np, y): def loss(model, batch_size=None): # inline DataLoader :) @@ -206,11 +201,12 @@ def loss(model, batch_size=None): (yi > 0) == (scorei.data > 0) for yi, scorei in zip(yb, scores) ] return total_loss, sum(accuracy) / len(accuracy) + return (loss,) @app.cell -def __(loss, mo): +def _(loss, mo): def train(model, iters=20): for k in mo.status.progress_bar(range(iters)): @@ -230,11 +226,12 @@ def train(model, iters=20): print(f"step {k} loss {total_loss.data}, accuracy {acc*100}%") return model + return (train,) @app.cell -def __(Value, X, np, plt, y): +def _(Value, X, np, plt, y): def plot_decision_boundary(model): h = 0.25 x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1 @@ -254,12 +251,14 @@ def plot_decision_boundary(model): plt.xlim(xx.min(), xx.max()) plt.ylim(yy.min(), yy.max()) return plt.gca() + return (plot_decision_boundary,) @app.cell -def __(): +def _(): import marimo as mo + return (mo,) diff --git a/examples/ai/misc/pdf_question_answer.py b/examples/ai/misc/pdf_question_answer.py index 63801204e60..8cabeb5bbe7 100644 --- a/examples/ai/misc/pdf_question_answer.py +++ b/examples/ai/misc/pdf_question_answer.py @@ -9,24 +9,28 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App() @app.cell -def __(mo): - mo.md("""# PDF Q&A""") +def _(mo): + mo.md(""" + # PDF Q&A + """) return @app.cell(hide_code=True) -def __(mo): - mo.md("""This app lets you upload a PDF and ask questions about it.""") +def _(mo): + mo.md(""" + This app lets you upload a PDF and ask questions about it. + """) return @app.cell -def __(mo): +def _(mo): mo.accordion({ "How is this app implemented?": """ - Your PDF is tokenized into chunks, which are embedded using @@ -42,15 +46,15 @@ def __(mo): @app.cell -def __(mo): +def _(mo): openaikey = mo.ui.text(label="🤖 OpenAI Key", kind="password") config = mo.hstack([openaikey]) mo.accordion({"⚙️ Config": config}) - return config, openaikey + return (openaikey,) @app.cell -def __(mo, openaikey): +def _(mo, openaikey): pdf = mo.ui.file( label="Upload PDF", filetypes=[".pdf"], multiple=False, kind="area" ) @@ -59,7 +63,7 @@ def __(mo, openaikey): @app.cell -def __( +def _( CharacterTextSplitter, FAISS, OpenAIEmbeddings, @@ -99,11 +103,11 @@ def parse_pdf(): knowledge_base = parse_pdf() - return knowledge_base, parse_pdf + return (knowledge_base,) @app.cell -def __(mo): +def _(mo): user_question = mo.ui.text_area( placeholder="💬 What are the 3 key points of the document?" ).form() @@ -112,7 +116,7 @@ def __(mo): @app.cell -def __( +def _( OpenAI, get_openai_callback, knowledge_base, @@ -138,11 +142,11 @@ def query_pdf(): res = query_pdf() mo.md(res) - return query_pdf, res + return @app.cell -def __(): +def _(): import marimo as mo import openai @@ -156,6 +160,7 @@ def __(): from langchain.callbacks import get_openai_callback import os + return ( CharacterTextSplitter, FAISS, @@ -167,7 +172,6 @@ def __(): load_qa_chain, mo, openai, - os, ) diff --git a/examples/ai/tools/chat_with_tools.py b/examples/ai/tools/chat_with_tools.py index b1d64bd98c0..668d12bce1c 100644 --- a/examples/ai/tools/chat_with_tools.py +++ b/examples/ai/tools/chat_with_tools.py @@ -16,12 +16,12 @@ import marimo -__generated_with = "0.9.4" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell -def __(): +def _(): import marimo as mo import ell import requests @@ -30,29 +30,30 @@ def __(): import polars as pl from bs4 import BeautifulSoup from vega_datasets import data - return BeautifulSoup, alt, data, ell, mo, pl, pyarrow, requests + + return BeautifulSoup, alt, data, ell, mo, pl, requests @app.cell -def __(mo): - mo.md( - """ - # Creating rich tools with ell +def _(mo): + mo.md(""" + # Creating rich tools with ell - This example shows how to use [`ell`](https://docs.ell.so/) with tools to analyze a dataset and return rich responses like charts and tables. - """ - ) + This example shows how to use [`ell`](https://docs.ell.so/) with tools to analyze a dataset and return rich responses like charts and tables. + """) return @app.cell -def __(mo): - mo.md(r"""## Setup""") +def _(mo): + mo.md(r""" + ## Setup + """) return @app.cell -def __(mo): +def _(mo): import os os_key = os.environ.get("OPENAI_API_KEY") @@ -62,11 +63,11 @@ def __(mo): value=os.environ.get("OPENAI_API_KEY", ""), ) input_key - return input_key, os, os_key + return input_key, os_key @app.cell -def __(input_key, mo, os_key): +def _(input_key, mo, os_key): openai_key = os_key or input_key.value import openai @@ -79,92 +80,76 @@ def __(input_key, mo, os_key): "Please set the `OPENAI_API_KEY` environment variable or provide it in the input field" ), ) - return client, openai, openai_key + return (client,) @app.cell -def __(mo): +def _(mo): get_dataset, set_dataset = mo.state("cars") return get_dataset, set_dataset @app.cell -def __(): - # data.list_datasets() - return - - -@app.cell -def __(get_dataset, mo, set_dataset): +def _(get_dataset, mo, set_dataset): options = ["cars", "barley", "countries", "disasters"] dataset_dropdown = mo.ui.dropdown( options, label="Datasets", value=get_dataset(), on_change=set_dataset ) - return dataset_dropdown, options + return (dataset_dropdown,) @app.cell -def __(data, dataset_dropdown, pl): +def _(data, dataset_dropdown, pl): selected_dataset = dataset_dropdown.value df = pl.DataFrame(data.__call__(selected_dataset)) - return df, selected_dataset + return (df,) @app.cell -def __(mo): - mo.md(r"""## Defining tools""") +def _(mo): + mo.md(r""" + ## Defining tools + """) return -@app.cell -def __(): - # https://stackoverflow.com/questions/33908794/get-value-of-last-expression-in-exec-call - def custom_exec(script, globals=None, locals=None): - """Execute a script and return the value of the last expression""" - import ast - - stmts = list(ast.iter_child_nodes(ast.parse(script))) - if not stmts: - return None - if isinstance(stmts[-1], ast.Expr): - # the last one is an expression and we will try to return the results - # so we first execute the previous statements - if len(stmts) > 1: - exec( - compile( - ast.Module(body=stmts[:-1]), filename="", mode="exec" - ), - globals, - locals, - ) - # then we eval the last one - return eval( +@app.function +# https://stackoverflow.com/questions/33908794/get-value-of-last-expression-in-exec-call +def custom_exec(script, globals=None, locals=None): + """Execute a script and return the value of the last expression""" + import ast + + stmts = list(ast.iter_child_nodes(ast.parse(script))) + if not stmts: + return None + if isinstance(stmts[-1], ast.Expr): + # the last one is an expression and we will try to return the results + # so we first execute the previous statements + if len(stmts) > 1: + exec( compile( - ast.Expression(body=stmts[-1].value), - filename="", - mode="eval", + ast.Module(body=stmts[:-1]), filename="", mode="exec" ), globals, locals, ) - else: - # otherwise we just execute the entire code - return exec(script, globals, locals) - return (custom_exec,) + # then we eval the last one + return eval( + compile( + ast.Expression(body=stmts[-1].value), + filename="", + mode="eval", + ), + globals, + locals, + ) + else: + # otherwise we just execute the entire code + return exec(script, globals, locals) @app.cell -def __( - BeautifulSoup, - alt, - client, - custom_exec, - dataset_dropdown, - df, - ell, - mo, - requests, -): +def _(BeautifulSoup, alt, client, dataset_dropdown, df, ell, mo, requests): @ell.tool() def show_dataset_selector(): """Ask the user to select a dataset""" @@ -249,21 +234,11 @@ def search_the_web(search_query: str, question: str): tool_docs[tool.__name__] = tool.__doc__ mo.accordion(tool_docs) - return ( - TOOLS, - chart_data, - execute_code, - filter_dataset_with_sql, - rag, - search_the_web, - show_dataset_selector, - tool, - tool_docs, - ) + return (TOOLS,) @app.cell -def __(TOOLS, client, df, ell, get_dataset, mo): +def _(TOOLS, client, df, ell, get_dataset, mo): @ell.complex( model="gpt-4-turbo", tools=TOOLS, @@ -296,11 +271,12 @@ def model(messages): [mo.md(f"Tool used: **{str(tool.tool.__name__)}**"), tool_response] ) return mo.md(response.text) - return custom_chatbot, model + + return (model,) @app.cell -def __(mo, model): +def _(mo, model): mo.ui.chat( model, prompts=[ diff --git a/examples/ai/tools/code_interpreter.py b/examples/ai/tools/code_interpreter.py index 9f5bfa6d814..e742b5ee5a1 100644 --- a/examples/ai/tools/code_interpreter.py +++ b/examples/ai/tools/code_interpreter.py @@ -9,39 +9,38 @@ import marimo -__generated_with = "0.9.4" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell(hide_code=True) -def __(): +def _(): import marimo as mo import ell import textwrap - return ell, mo, textwrap + + return ell, mo @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - # Creating a code interpreter +def _(mo): + mo.md(""" + # Creating a code interpreter - This example shows how to create a code-interpreter in a few lines of code. - """ - ) + This example shows how to create a code-interpreter in a few lines of code. + """) return @app.cell(hide_code=True) -def __(mo): +def _(mo): backend = mo.ui.dropdown(["ollama", "openai"], label="Backend", value="ollama") backend return (backend,) @app.cell(hide_code=True) -def __(backend, mo): +def _(backend, mo): # OpenAI config import os import openai @@ -53,11 +52,11 @@ def __(backend, mo): value=os.environ.get("OPENAI_API_KEY", ""), ) input_key if backend.value == "openai" else None - return input_key, openai, os, os_key + return input_key, openai, os_key @app.cell -def __(openai): +def _(openai): client = openai.Client( api_key="ollama", base_url="http://localhost:11434/v1", @@ -66,7 +65,7 @@ def __(openai): @app.cell(hide_code=True) -def __(backend, input_key, mo, os_key): +def _(backend, input_key, mo, os_key): def _get_open_ai_client(): openai_key = os_key or input_key.value @@ -100,45 +99,43 @@ def _get_ollama_client(): return (model,) -@app.cell(hide_code=True) -def __(): - # https://stackoverflow.com/questions/33908794/get-value-of-last-expression-in-exec-call - def exec_with_result(script, globals=None, locals=None): - """Execute a script and return the value of the last expression""" - import ast - - stmts = list(ast.iter_child_nodes(ast.parse(script))) - if not stmts: - return None - if isinstance(stmts[-1], ast.Expr): - # the last one is an expression and we will try to return the results - # so we first execute the previous statements - if len(stmts) > 1: - exec( - compile( - ast.Module(body=stmts[:-1]), filename="", mode="exec" - ), - globals, - locals, - ) - # then we eval the last one - return eval( +@app.function(hide_code=True) +# https://stackoverflow.com/questions/33908794/get-value-of-last-expression-in-exec-call +def exec_with_result(script, globals=None, locals=None): + """Execute a script and return the value of the last expression""" + import ast + + stmts = list(ast.iter_child_nodes(ast.parse(script))) + if not stmts: + return None + if isinstance(stmts[-1], ast.Expr): + # the last one is an expression and we will try to return the results + # so we first execute the previous statements + if len(stmts) > 1: + exec( compile( - ast.Expression(body=stmts[-1].value), - filename="", - mode="eval", + ast.Module(body=stmts[:-1]), filename="", mode="exec" ), globals, locals, ) - else: - # otherwise we just execute the entire code - return exec(script, globals, locals) - return (exec_with_result,) + # then we eval the last one + return eval( + compile( + ast.Expression(body=stmts[-1].value), + filename="", + mode="eval", + ), + globals, + locals, + ) + else: + # otherwise we just execute the entire code + return exec(script, globals, locals) @app.cell -def __(ell, exec_with_result, mo): +def _(ell, mo): def code_fence(code): return f"```python\n\n{code}\n\n```" @@ -159,11 +156,12 @@ def execute_code(code: str): code_fence(result if result is not None else output), ] return mo.md("\n\n".join(results)) - return code_fence, execute_code + + return (execute_code,) @app.cell -def __(client, ell, execute_code, mo, model): +def _(client, ell, execute_code, mo, model): @ell.complex(model=model, tools=[execute_code], client=client) def custom_chatbot(messages, config) -> str: """You are data scientist with access to writing python code.""" @@ -180,11 +178,12 @@ def my_model(messages, config): if response.tool_calls: return response.tool_calls[0]() return mo.md(response.text) - return custom_chatbot, my_model + + return (my_model,) @app.cell -def __(mo, my_model): +def _(mo, my_model): numbers = [x for x in range(1, 10)] mo.ui.chat( @@ -194,7 +193,7 @@ def __(mo, my_model): f"Can you sum this list using python: {numbers}", ], ) - return (numbers,) + return if __name__ == "__main__": diff --git a/examples/ai/tools/dataset_analysis.py b/examples/ai/tools/dataset_analysis.py index 87edec07f6d..c3ec502c450 100644 --- a/examples/ai/tools/dataset_analysis.py +++ b/examples/ai/tools/dataset_analysis.py @@ -16,12 +16,12 @@ import marimo -__generated_with = "0.9.4" +__generated_with = "0.19.7" app = marimo.App() @app.cell -def __(): +def _(): import marimo as mo import ell from pydantic import Field @@ -30,33 +30,32 @@ def __(): import pyarrow import polars as pl from vega_datasets import data - return Field, alt, data, ell, mo, pl, pyarrow, requests + + return alt, data, ell, mo, pl @app.cell -def __(mo): - mo.md( - """ - # Using tools with ell +def _(mo): + mo.md(""" + # Using tools with ell - This example shows how to use [`ell`](https://docs.ell.so/) with tools to analyze a dataset and return rich responses like charts and tables. - """ - ) + This example shows how to use [`ell`](https://docs.ell.so/) with tools to analyze a dataset and return rich responses like charts and tables. + """) return @app.cell -def __(mo): +def _(mo): import os os_key = os.environ.get("OPENAI_API_KEY") input_key = mo.ui.text(label="OpenAI API key", kind="password") input_key if not os_key else None - return input_key, os, os_key + return input_key, os_key @app.cell -def __(input_key, mo, os_key): +def _(input_key, mo, os_key): openai_key = os_key or input_key.value import openai @@ -69,18 +68,18 @@ def __(input_key, mo, os_key): "Please set the `OPENAI_API_KEY` environment variable or provide it in the input field" ), ) - return client, openai, openai_key + return (client,) @app.cell -def __(data, pl): +def _(data, pl): cars = pl.DataFrame(data.cars()) schema = cars.schema return cars, schema @app.cell -def __(alt, cars, client, ell, schema): +def _(alt, cars, client, ell, schema): @ell.tool() def get_chart( x_encoding: str, @@ -114,11 +113,12 @@ def get_filtered_table(sql_query: str): def analyze_dataset(prompt: str) -> str: """You are an agent that can analayze the a dataset""" return f"I have a dataset with schema: {schema}. \n{prompt}" - return analyze_dataset, get_chart, get_filtered_table + + return (analyze_dataset,) @app.cell -def __(input_key, mo, schema): +def _(input_key, mo, schema): text = mo.ui.text( full_width=True, disabled=not input_key.value, @@ -141,7 +141,7 @@ def __(input_key, mo, schema): @app.cell -def __(analyze_dataset, mo, text): +def _(analyze_dataset, mo, text): mo.stop(not text.value) with mo.status.spinner(title=f"Thinking...", subtitle=text.value): @@ -153,7 +153,7 @@ def __(analyze_dataset, mo, text): mo.output.replace(summary) except Exception as e: mo.output.replace(mo.callout(str(e))) - return response, summary + return if __name__ == "__main__": diff --git a/examples/cloud/README.md b/examples/cloud/README.md index c217decdbb6..e3cca98e3f2 100644 --- a/examples/cloud/README.md +++ b/examples/cloud/README.md @@ -11,13 +11,14 @@ These examples show how to use various cloud provider APIs. The requirements of each notebook are serialized in them as a top-level comment. Here are the steps to open an example notebook: -1. [Install marimo](https://docs.marimo.io/getting_started/index.html#installation) -2. [Install `uv`](https://github.com/astral-sh/uv/?tab=readme-ov-file#installation) -3. Open an example with `marimo edit --sandbox `. +1. [Install `uv`](https://github.com/astral-sh/uv/?tab=readme-ov-file#installation) +2. Open an example with `uvx marimo edit --sandbox ` > [!TIP] > The [`--sandbox` flag](https://docs.marimo.io/guides/editor_features/package_management.html) opens the notebook in an isolated virtual environment, > automatically installing the notebook's dependencies 📦 -You can also open notebooks without `uv`, with just `marimo edit `; -however, you'll need to install the requirements yourself. +You can also open notebooks without `uv`, in which case you'll need to +manually [install marimo](https://docs.marimo.io/getting_started/index.html#installation) +first. Then run `marimo edit `; however, you'll also need to +install the requirements yourself. diff --git a/examples/cloud/gcp/google_cloud_bigquery.py b/examples/cloud/gcp/google_cloud_bigquery.py index 9b9c02ab826..11d21ee8b79 100644 --- a/examples/cloud/gcp/google_cloud_bigquery.py +++ b/examples/cloud/gcp/google_cloud_bigquery.py @@ -10,26 +10,29 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell -def __(): +def _(): import marimo as mo import os from google.cloud import bigquery + return bigquery, mo, os @app.cell(hide_code=True) -def __(mo): - mo.md("""# Google Cloud BigQuery""") +def _(mo): + mo.md(""" + # Google Cloud BigQuery + """) return @app.cell(hide_code=True) -def __(mo): +def _(mo): # Configuration credentials = mo.ui.text(placeholder="path/to/creds.json") mo.md( @@ -46,14 +49,14 @@ def __(mo): @app.cell(hide_code=True) -def __(mo): +def _(mo): project = mo.ui.text(label="gcloud project") project return (project,) @app.cell(hide_code=True) -def __(bigquery, credentials, mo, os, project): +def _(bigquery, credentials, mo, os, project): # Set up client if credentials.value: os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = credentials.value @@ -72,7 +75,7 @@ def __(bigquery, credentials, mo, os, project): @app.cell -def __(datasets, mo): +def _(datasets, mo): # Dataset selection selected_dataset = mo.ui.dropdown( label="Select dataset", options=[d.dataset_id for d in datasets] @@ -82,7 +85,7 @@ def __(datasets, mo): @app.cell -def __(client, mo, selected_dataset): +def _(client, mo, selected_dataset): mo.stop(not selected_dataset.value) dataset = client.dataset(selected_dataset.value) @@ -90,21 +93,21 @@ def __(client, mo, selected_dataset): @app.cell -def __(client, dataset, mo): +def _(client, dataset, mo): # Table selection tables = list(client.list_tables(dataset)) selected_table = mo.ui.dropdown( label="Select table", options=[t.table_id for t in tables] ) selected_table - return selected_table, tables + return (selected_table,) @app.cell -def __(client, dataset, mo, selected_table): +def _(client, dataset, mo, selected_table): results = client.list_rows(dataset.table(selected_table.value), max_results=10) mo.ui.table(results.to_dataframe(), selection=None) - return (results,) + return if __name__ == "__main__": diff --git a/examples/cloud/gcp/google_cloud_storage.py b/examples/cloud/gcp/google_cloud_storage.py index 70f0ecbba22..d5311c5a432 100644 --- a/examples/cloud/gcp/google_cloud_storage.py +++ b/examples/cloud/gcp/google_cloud_storage.py @@ -9,21 +9,22 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App(width="full") @app.cell -def __(): +def _(): # Imports import marimo as mo import os from google.cloud import storage + return mo, os, storage @app.cell(hide_code=True) -def __(mo): +def _(mo): # Configuration credentials = mo.ui.text(placeholder="path/to/creds.json") mo.md( @@ -40,14 +41,14 @@ def __(mo): @app.cell(hide_code=True) -def __(mo): +def _(mo): project = mo.ui.text(label="gcloud project") project return (project,) @app.cell -def __(credentials, mo, os, project, storage): +def _(credentials, mo, os, project, storage): # Set up client if credentials.value: os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = credentials.value @@ -67,7 +68,7 @@ def __(credentials, mo, os, project, storage): @app.cell -def __(buckets, mo): +def _(buckets, mo): # Bucket selection selected_bucket = mo.ui.dropdown( label="Select bucket", options=[bucket.name for bucket in buckets] @@ -77,13 +78,13 @@ def __(buckets, mo): @app.cell -def __(mo): +def _(mo): get_prefix, set_prefix = mo.state("") return get_prefix, set_prefix @app.cell -def __(client, mo, selected_bucket): +def _(client, mo, selected_bucket): mo.stop(not selected_bucket.value) bucket = client.get_bucket(selected_bucket.value) @@ -91,14 +92,14 @@ def __(client, mo, selected_bucket): @app.cell -def __(bucket, get_prefix): +def _(bucket, get_prefix): _prefix = get_prefix() or None blobs = list(bucket.list_blobs(max_results=30, prefix=_prefix)) return (blobs,) @app.cell -def __(get_prefix, mo, set_prefix): +def _(get_prefix, mo, set_prefix): output = None _prefix = get_prefix() if _prefix: @@ -109,11 +110,11 @@ def __(get_prefix, mo, set_prefix): ] ) output - return (output,) + return @app.cell -def __(blobs, bucket, mo, set_prefix): +def _(blobs, bucket, mo, set_prefix): # Display files in a table _files = [ { @@ -139,7 +140,7 @@ def __(blobs, bucket, mo, set_prefix): @app.cell -def __(bucket, file_table, mo): +def _(bucket, file_table, mo): # Load selected file if len(file_table.value) >= 1: _selected_file = file_table.value[0] diff --git a/examples/cloud/gcp/google_sheets.py b/examples/cloud/gcp/google_sheets.py index fdde00c14ad..8ae4e46ae0d 100644 --- a/examples/cloud/gcp/google_sheets.py +++ b/examples/cloud/gcp/google_sheets.py @@ -10,23 +10,24 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App(width="full") @app.cell -def __(): +def _(): import marimo as mo import pandas as pd import os import gspread from oauth2client.service_account import ServiceAccountCredentials from oauth2client.client import GoogleCredentials + return GoogleCredentials, ServiceAccountCredentials, gspread, mo, os, pd @app.cell -def __(mo): +def _(mo): # Configuration credentials = mo.ui.text(placeholder="path/to/creds.json") mo.md( @@ -43,13 +44,7 @@ def __(mo): @app.cell -def __( - GoogleCredentials, - ServiceAccountCredentials, - credentials, - gspread, - os, -): +def _(GoogleCredentials, ServiceAccountCredentials, credentials, gspread, os): # Set up client _scopes = [ "https://spreadsheets.google.com/feeds", @@ -66,24 +61,24 @@ def __( creds = GoogleCredentials.get_application_default().create_scoped(_scopes) gc = gspread.authorize(creds) - return creds, gc + return (gc,) @app.cell -def __(mo): +def _(mo): spreadsheet_url = mo.ui.text(label="Spreadsheet URL", full_width=True) spreadsheet_url return (spreadsheet_url,) @app.cell -def __(gc, mo, pd, spreadsheet_url): +def _(gc, mo, pd, spreadsheet_url): mo.stop(not spreadsheet_url.value) # Get sheet records wks = gc.open_by_url(spreadsheet_url.value).sheet1 mo.ui.table(pd.DataFrame(wks.get_all_records()), selection=None) - return (wks,) + return if __name__ == "__main__": diff --git a/examples/cloud/modal/README.md b/examples/cloud/modal/README.md index abee3b05da7..133cd350810 100644 --- a/examples/cloud/modal/README.md +++ b/examples/cloud/modal/README.md @@ -1,7 +1,28 @@ -# Deploying marimo on modal +# Running marimo on Modal -This folder contains examples of how to use marimo notebook on Modal. +This folder contains examples of how to run marimo notebooks on +[Modal](https://modal.com/), making it easy to get access to cloud GPUs. To get +started, first create a modal account and follow their onboarding. You'll also +need to install the [uv package manager](https://docs.astral.sh/uv/). -[modal_app.py](modal_app.py) has an example of how to deploy a read-only marimo notebook as an app on Modal. +## Editable notebooks +[modal_edit.py](modal_edit.py) has an example of how to spin up an editable +marimo notebook that runs on a Modal container. Run with -[modal_edit.py](modal_edit.py) has an example of how to spin up an editable marimo notebook that runs on a Modal container. Run with `modal run modal_edit.py` (requires Modal account and configured credentials). +```bash +uvx -p 3.12 modal run modal_edit.py +``` + +You can configure your GPU selection by editing `modal_edit`. + +## Run as apps + +[modal_app.py](modal_app.py) has an example of how to deploy a read-only marimo +notebook as an app on Modal. Run with + +```bash +uvx -p 3.12 \ + --with modal \ + --with marimo \ + modal serve modal_app.py +``` diff --git a/examples/cloud/modal/modal_app.py b/examples/cloud/modal/modal_app.py index a30bf0ab3f4..3cab3c8e862 100644 --- a/examples/cloud/modal/modal_app.py +++ b/examples/cloud/modal/modal_app.py @@ -5,23 +5,20 @@ import marimo -server = marimo.create_asgi_app().with_app(path="", root="/marimo/home.py") +server = marimo.create_asgi_app().with_app(path="", root="/marimo/notebook.py") app = modal.App() @app.function( - image=modal.Image.debian_slim().pip_install("marimo>=0.8.3", "fastapi"), - gpu=False, - concurrency_limit=1, - allow_concurrent_inputs=2, + image=modal.Image.debian_slim() + .pip_install("marimo>=0.12.8", "fastapi") + .add_local_dir("./nbs", remote_path="/marimo"), + gpu=None, + max_containers=1, memory=256, - mounts=[modal.Mount.from_local_dir("./nbs", remote_path="/marimo")], ) +@modal.concurrent(max_inputs=2) @asgi_app() def marimo_asgi(): return server.build() - - -if __name__ == "__main__": - modal.serve(app) diff --git a/examples/cloud/modal/modal_edit.py b/examples/cloud/modal/modal_edit.py index 8e24c59bb92..b32f10ed8c4 100644 --- a/examples/cloud/modal/modal_edit.py +++ b/examples/cloud/modal/modal_edit.py @@ -5,15 +5,16 @@ import modal app = modal.App( - image=modal.Image.debian_slim().pip_install( - "marimo>=0.9.32", "modal>=0.67.31" - )) + image=modal.Image.debian_slim() + .pip_install("marimo>=0.12.8", "modal>=0.73.166") + .add_local_dir("nbs", remote_path="/root/nbs") +) TOKEN = secrets.token_urlsafe(16) PORT = 2718 -@app.function(concurrency_limit=1, timeout=1_500) +@app.function(max_containers=1, timeout=1_500, gpu="t4") def run_marimo(timeout: int): with modal.forward(PORT) as tunnel: marimo_process = subprocess.Popen( @@ -21,10 +22,13 @@ def run_marimo(timeout: int): "marimo", "edit", "--headless", - "--host", "0.0.0.0", - "--port", str(PORT), - f"--token-password", TOKEN, - "notebook.py", + "--host", + "0.0.0.0", + "--port", + str(PORT), + f"--token-password", + TOKEN, + "nbs/notebook.py", ], ) @@ -34,7 +38,9 @@ def run_marimo(timeout: int): end_time = time.time() + timeout while time.time() < end_time: time.sleep(5) - print(f"Reached end of {timeout} second timeout period. Exiting...") + print( + f"Reached end of {timeout} second timeout period. Exiting..." + ) except KeyboardInterrupt: print("Exiting...") finally: diff --git a/examples/cloud/modal/nbs/home.py b/examples/cloud/modal/nbs/home.py deleted file mode 100644 index 826c80a9e72..00000000000 --- a/examples/cloud/modal/nbs/home.py +++ /dev/null @@ -1,20 +0,0 @@ -import marimo - -__generated_with = "0.8.4" -app = marimo.App() - - -@app.cell -def __(mo): - mo.md("""# Hello, from inside Modal!""") - return - - -@app.cell -def __(): - import marimo as mo - return mo, - - -if __name__ == "__main__": - app.run() diff --git a/examples/cloud/modal/nbs/notebook.py b/examples/cloud/modal/nbs/notebook.py new file mode 100644 index 00000000000..02673c3c2c4 --- /dev/null +++ b/examples/cloud/modal/nbs/notebook.py @@ -0,0 +1,77 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + import sys + import platform + import os + + return mo, os, platform, sys + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + # Hello, from inside Modal! + """) + return + + +@app.cell +def _(mo): + import subprocess + + try: + result = subprocess.run( + ["nvidia-smi"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + output = mo.md( + f"""## 🚀 GPU is available: + ``` + {result.stdout.decode()} + + ``` + """, + ) + except FileNotFoundError: + output = mo.md("## ✅️ We are running on CPU") + + output + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + ## Runtime information + """) + return + + +@app.cell +def _(mo, os, platform, sys): + runtime_info = { + "python_version": sys.version, + "platform": sys.platform, + "arch": platform.machine(), + "executable_path": sys.executable, + "implementation": sys.implementation.name, + "sys.path": sys.path, + "argv": sys.argv, + "working_directory": os.getcwd(), + "ls": os.listdir(os.getcwd()), + } + mo.ui.table(runtime_info, selection=None, page_size=len(runtime_info)) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/control_flow/README.md b/examples/control_flow/README.md new file mode 100644 index 00000000000..3c99255f714 --- /dev/null +++ b/examples/control_flow/README.md @@ -0,0 +1,27 @@ +# Control Flow + +These basic examples show how to control execution of cells. + +> [!TIP] +> New to marimo? Run `marimo tutorial intro` and `marimo tutorial dataflow` +> at the command line first! + +_Looking for examples on making chatbots? Check out the [`ai/chat`](../ai/chat) +examples folder_. + +## Running examples + +The requirements of each notebook are serialized in them as a top-level +comment. Here are the steps to open an example notebook: + +1. [Install `uv`](https://github.com/astral-sh/uv/?tab=readme-ov-file#installation) +2. Open an example with `uvx marimo edit --sandbox ` + +> [!TIP] +> The [`--sandbox` flag](https://docs.marimo.io/guides/editor_features/package_management.html) opens the notebook in an isolated virtual environment, +> automatically installing the notebook's dependencies 📦 + +You can also open notebooks without `uv`, in which case you'll need to +manually [install marimo](https://docs.marimo.io/getting_started/index.html#installation) +first. Then run `marimo edit `; however, you'll also need to +install the requirements yourself. diff --git a/examples/control_flow/stop_execution.py b/examples/control_flow/stop_execution.py new file mode 100644 index 00000000000..672b4485fdd --- /dev/null +++ b/examples/control_flow/stop_execution.py @@ -0,0 +1,30 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + button = mo.ui.run_button() + button + return (button,) + + +@app.cell +def _(button, mo): + mo.stop(not button.value, "Click the button to continue") + + mo.md("# :tada:") + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/frameworks/fastapi-auth/README.md b/examples/frameworks/fastapi-auth/README.md new file mode 100644 index 00000000000..a2a49448a24 --- /dev/null +++ b/examples/frameworks/fastapi-auth/README.md @@ -0,0 +1,24 @@ +# FastAPI + marimo: Authentication Middleware + +This example shows the recommended pattern for passing user info into marimo +notebooks via `mo.app_meta().request.user` and `mo.app_meta().request.meta`. + +It includes: + +- Login / logout with session cookies +- A **pure ASGI middleware** that sets `scope["user"]` and `scope["meta"]` for + both HTTP and WebSocket connections +- A marimo notebook that reads user info via `mo.app_meta().request` + +### Why pure ASGI middleware? + +marimo uses WebSocket for real-time communication. Starlette's +`BaseHTTPMiddleware` only runs for HTTP requests, so `scope["user"]` set there +is not visible on WebSocket connections. A pure ASGI middleware handles both. + +## Running the app + +1. [Install `uv`](https://github.com/astral-sh/uv/?tab=readme-ov-file#installation) +2. Run the app with `uv run --no-project main.py` +3. Open http://localhost:8000/ and log in with `admin` / `password123` +4. The notebook cell will display the authenticated user and meta data diff --git a/examples/frameworks/fastapi-auth/main.py b/examples/frameworks/fastapi-auth/main.py new file mode 100644 index 00000000000..4e96cf906c8 --- /dev/null +++ b/examples/frameworks/fastapi-auth/main.py @@ -0,0 +1,182 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "fastapi", +# "marimo", +# "starlette", +# "uvicorn", +# "itsdangerous", +# "python-multipart", +# ] +# /// +"""Example: Authentication middleware that passes user info into marimo notebooks. + +This shows the recommended pattern for authentication with marimo when using +FastAPI. It uses a pure ASGI middleware (not BaseHTTPMiddleware) so that +scope["user"] and scope["meta"] are set for both HTTP *and* WebSocket +connections. + +The user info is then available in notebooks via: + + request = mo.app_meta().request + username = request.user["username"] + +Run with: + uv run --no-project main.py + +Then open http://localhost:8000/ and log in with admin / password123. +""" + +import os +import logging + +import marimo +import uvicorn +from fastapi import FastAPI, Request, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from starlette.middleware.sessions import SessionMiddleware +from starlette.responses import Response + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +notebook_dir = os.path.dirname(__file__) +notebook_path = os.path.join(notebook_dir, "notebook.py") + +# Simulated user database (replace with a real database in production) +users_db = {"admin": "password123"} + +app = FastAPI() + + +# Pure ASGI middleware — runs for both HTTP and WebSocket requests. +# This is the recommended pattern for passing user/meta into marimo. +# +# Important: Do NOT use Starlette's BaseHTTPMiddleware here. +# BaseHTTPMiddleware only processes HTTP requests, not WebSocket +# connections. marimo uses WebSocket for real-time communication, +# so scope["user"] and scope["meta"] would be lost. +# +# This is a simplified version of authentication and you may want to use +# starlette.middleware.authentication.AuthenticationMiddleware instead. +class AuthMiddleware: + # Paths that don't require authentication + PUBLIC_PATHS = {"/login"} + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] not in ("http", "websocket"): + await self.app(scope, receive, send) + return + + # SessionMiddleware has already run, so scope["session"] is available. + session = scope.get("session", {}) + username = session.get("username") + + if username: + # Set user/meta so marimo can read them via mo.app_meta().request + scope["user"] = { + "is_authenticated": True, + "username": username, + } + scope["meta"] = {"role": "admin"} + await self.app(scope, receive, send) + return + + # Not logged in — block unauthenticated access. + path = scope.get("path", "") + + # Allow public paths through without authentication. + if path in self.PUBLIC_PATHS: + await self.app(scope, receive, send) + return + + # Reject unauthenticated WebSocket connections. + if scope["type"] == "websocket": + from starlette.websockets import WebSocket + + ws = WebSocket(scope, receive, send) + await ws.close(code=4003) + return + + # Redirect unauthenticated HTTP requests to /login. + response = Response( + status_code=302, headers={"location": "/login"} + ) + await response(scope, receive, send) + + +# Middleware ordering: In Starlette, the LAST added middleware is the +# OUTERMOST (runs first). We need SessionMiddleware to run before +# AuthMiddleware so that scope["session"] is populated. So we add +# AuthMiddleware first (innermost) and SessionMiddleware last (outermost). +app.add_middleware(AuthMiddleware) +app.add_middleware( + SessionMiddleware, + secret_key=os.getenv("SECRET_KEY", "change-me-in-production"), +) + +LOGIN_PAGE = """\ + + +Login + +
+

Login

+ {error} +
+
+ +
+
+
+ +
+ + + + +""" + + +@app.get("/login") +async def get_login(): + return HTMLResponse(LOGIN_PAGE.format(error="")) + + +@app.post("/login") +async def post_login( + request: Request, + username: str = Form(...), + password: str = Form(...), +): + if username in users_db and password == users_db[username]: + request.session["username"] = username + logger.info("User %s logged in", username) + return RedirectResponse(url="/", status_code=302) + logger.warning("Failed login attempt for %s", username) + return HTMLResponse( + LOGIN_PAGE.format( + error='

Invalid credentials

' + ) + ) + + +@app.get("/logout") +async def logout(request: Request): + request.session.clear() + return RedirectResponse(url="/login") + + +# Mount marimo +marimo_app = ( + marimo.create_asgi_app(include_code=True) + .with_app(path="/", root=notebook_path) + .build() +) +app.mount("/", marimo_app) + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=8000) diff --git a/examples/frameworks/fastapi-auth/notebook.py b/examples/frameworks/fastapi-auth/notebook.py new file mode 100644 index 00000000000..ae340d80b9b --- /dev/null +++ b/examples/frameworks/fastapi-auth/notebook.py @@ -0,0 +1,31 @@ +import marimo + +__generated_with = "0.20.0" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + req = mo.app_meta().request + user = req.user if req else None + meta = req.meta if req else None + + mo.md(f""" + ## User info from `mo.app_meta().request` + + - **user**: `{user}` + - **username**: `{user['username'] if isinstance(user, dict) else 'N/A'}` + - **meta**: `{meta}` + """) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/frameworks/fastapi-endpoint/README.md b/examples/frameworks/fastapi-endpoint/README.md new file mode 100644 index 00000000000..ffde3a773fd --- /dev/null +++ b/examples/frameworks/fastapi-endpoint/README.md @@ -0,0 +1,12 @@ +# FastAPI + marimo, as an API endpoint + +This is a simple example of how to use FastAPI with marimo. This example turns marimo notebooks into an API endpoint, which can be embedded in any FastAPI app. + +- Turning functions defined in a notebook into an API endpoint +- Overriding global variables and returning cell outputs + +## Running the app + +1. [Install `uv`](https://github.com/astral-sh/uv/?tab=readme-ov-file#installation) +2. Run the app with `uv run --no-project main.py` +3. Then run `curl http://localhost:8000/greet?name=coder` diff --git a/examples/frameworks/fastapi-endpoint/main.py b/examples/frameworks/fastapi-endpoint/main.py new file mode 100644 index 00000000000..061e06c53b7 --- /dev/null +++ b/examples/frameworks/fastapi-endpoint/main.py @@ -0,0 +1,107 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "fastapi", +# "marimo", +# "starlette", +# "matplotlib==3.10.0", +# "pillow==11.1.0", +# ] +# /// +import marimo +import fastapi + +from fastapi import FastAPI, Request +import logging + +from fastapi.responses import HTMLResponse, StreamingResponse + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Create a FastAPI app +app = FastAPI() + + +@app.get("/add/{a}/{b}") +async def add_endpoint(request: Request, a: int, b: int) -> int: + from notebook import add + + # We grab the function from the definition + # and call it with the a and b arguments + return add(a, b) + + +@app.get("/greet") +async def greet(request: Request): + from notebook import greet + + name = request.query_params.get("name") + + # We grab the function from the definition + # and call it with the name argument + return greet(name) + + +@app.get("/plot") +async def plot(request: Request): + from notebook import plot + import json + from PIL import Image + import io + + try: + data = json.loads(request.query_params.get("data")) + except Exception as e: + data = { + "2019": 150, + "2020": 200, + "2021": 180, + "2022": 250, + "2023": 300, + } + + # Get the image from the notebook, + # which is the output of the plot function + output: Image.Image + # We override the plot_data argument + # to use the data passed in the query string + output, _ = plot.run(plot_data=data) + + # Save the image to a BytesIO object + buf = io.BytesIO() + output.save(buf, format="PNG") + buf.seek(0) + + # Return the image as a StreamingResponse + return StreamingResponse( + content=buf, + media_type="image/png", + ) + + +@app.get("/") +async def home(request: Request): + return HTMLResponse( + f""" + This example shows how to use marimo notebooks as API endpoints in a FastAPI app. +
+ using marimo {marimo.__version__} and fastapi {fastapi.__version__} +
+ + Try these endpoints: + + """ + ) + + +# Run the server +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="localhost", port=8000, log_level="info") diff --git a/examples/frameworks/fastapi-endpoint/notebook.py b/examples/frameworks/fastapi-endpoint/notebook.py new file mode 100644 index 00000000000..f786feccde0 --- /dev/null +++ b/examples/frameworks/fastapi-endpoint/notebook.py @@ -0,0 +1,119 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "marimo", +# "matplotlib==3.10.0", +# "pillow==11.1.0", +# ] +# /// + +import marimo + +__generated_with = "0.15.5" +app = marimo.App() + +with app.setup: + import matplotlib.pyplot as plt + from PIL import Image + + +@app.function +def add(a: int, b: int) -> int: + """Add two numbers together""" + return a + b + + +@app.function +def greet(name: str) -> str: + """Return a greeting message""" + return f"Hello {name}!" + + +@app.function +def fibonacci(n: int) -> list[int]: + """Return first n numbers of Fibonacci sequence""" + if n <= 0: + return [] + elif n == 1: + return [0] + + sequence = [0, 1] + while len(sequence) < n: + sequence.append(sequence[-1] + sequence[-2]) + return sequence + + +@app.function +def plot_dictionary(data: dict) -> None: + """Plot dictionary items as a bar chart""" + # Clear any existing plots + plt.clf() + + # Create figure with higher DPI + fig = plt.figure(figsize=(10, 6), dpi=100) + plt.bar(list(data.keys()), list(data.values())) + plt.xticks(rotation=45) + plt.xlabel("Items") + plt.ylabel("Values") + plt.title("Dictionary Items Plot") + plt.tight_layout() + + # Convert to PIL Image with proper size preservation + canvas = fig.canvas + canvas.draw() + width, height = fig.get_size_inches() * fig.get_dpi() + image = Image.frombytes( + "RGBA", (int(width), int(height)), canvas.buffer_rgba() + ) + plt.close(fig) # Clean up + return image + + +@app.cell(hide_code=True) +def plot(plot_data): + plot_dictionary(plot_data) + return + + +@app.cell(hide_code=True) +def _(sample_data): + plot_dictionary(sample_data) + return + + +@app.function +def stats(numbers: list[float]) -> dict: + """Calculate basic statistics for a list of numbers""" + if not numbers: + return {"mean": None, "min": None, "max": None} + return { + "mean": sum(numbers) / len(numbers), + "min": min(numbers), + "max": max(numbers), + } + + +@app.cell(hide_code=True) +def _(): + # Fallback data + plot_data = { + "2019": 150, + "2020": 200, + "2021": 180, + "2022": 250, + "2023": 300, + } + + # Example usage of plot_dictionary + sample_data = { + "Apple": 30, + "Banana": 25, + "Orange": 40, + "Mango": 15, + "Grapes": 35, + } + return plot_data, sample_data + + +if __name__ == "__main__": + app.run() diff --git a/examples/frameworks/fastapi-github/main.py b/examples/frameworks/fastapi-github/main.py index 72c1a8383fa..32778e576ce 100644 --- a/examples/frameworks/fastapi-github/main.py +++ b/examples/frameworks/fastapi-github/main.py @@ -7,6 +7,7 @@ # "requests", # "pydantic", # "jinja2", +# "vega-datasets==0.9.0", # ] # /// import tempfile diff --git a/examples/frameworks/fastapi/main.py b/examples/frameworks/fastapi/main.py index bb016dd401b..6b5d28f0cf2 100644 --- a/examples/frameworks/fastapi/main.py +++ b/examples/frameworks/fastapi/main.py @@ -10,6 +10,7 @@ # "python-multipart", # "passlib", # "pydantic", +# "vega-datasets==0.9.0", # ] # /// from typing import Callable, Coroutine diff --git a/examples/frameworks/fasthtml/main.py b/examples/frameworks/fasthtml/main.py index dc76a8a33a5..bba30f418d3 100644 --- a/examples/frameworks/fasthtml/main.py +++ b/examples/frameworks/fasthtml/main.py @@ -3,6 +3,7 @@ # dependencies = [ # "python-fasthtml", # "marimo", +# "vega-datasets==0.9.0", # ] # /// from fasthtml.common import * diff --git a/examples/frameworks/flask/main.py b/examples/frameworks/flask/main.py index 14c68789dbc..4490cc411ba 100644 --- a/examples/frameworks/flask/main.py +++ b/examples/frameworks/flask/main.py @@ -7,6 +7,7 @@ # "python-dotenv", # "flask-session", # "werkzeug", +# "vega-datasets==0.9.0", # ] # /// from flask import Flask, render_template, request, redirect, url_for, session diff --git a/examples/layouts/README.md b/examples/layouts/README.md index df0e817b3f4..41851237a0b 100644 --- a/examples/layouts/README.md +++ b/examples/layouts/README.md @@ -1,8 +1,8 @@ # Layouts 📽️ These examples show how to use marimo's built-in features for laying out -notebooks in interesting ways, such as presenting notebooks as slides and -adding sidebars. +notebooks in interesting ways, such as presenting notebooks as slides, +adding sidebars, and arranging cells into columns while editing. > [!TIP] > Submit a @@ -14,13 +14,14 @@ adding sidebars. The requirements of each notebook are serialized in them as a top-level comment. Here are the steps to open an example notebook: -1. [Install marimo](https://docs.marimo.io/getting_started/index.html#installation) -2. [Install `uv`](https://github.com/astral-sh/uv/?tab=readme-ov-file#installation) -3. Open an example with `marimo edit --sandbox `. +1. [Install `uv`](https://github.com/astral-sh/uv/?tab=readme-ov-file#installation) +2. Open an example with `uvx marimo edit --sandbox ` > [!TIP] > The [`--sandbox` flag](https://docs.marimo.io/guides/editor_features/package_management.html) opens the notebook in an isolated virtual environment, > automatically installing the notebook's dependencies 📦 -You can also open notebooks without `uv`, with just `marimo edit `; -however, you'll need to install the requirements yourself. +You can also open notebooks without `uv`, in which case you'll need to +manually [install marimo](https://docs.marimo.io/getting_started/index.html#installation) +first. Then run `marimo edit `; however, you'll also need to +install the requirements yourself. diff --git a/examples/layouts/columns.py b/examples/layouts/columns.py new file mode 100644 index 00000000000..49cc94f1cf6 --- /dev/null +++ b/examples/layouts/columns.py @@ -0,0 +1,181 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "altair==5.5.0", +# "marimo", +# "matplotlib==3.10.0", +# "pandas==2.2.3", +# "polars==1.20.0", +# "scikit-learn==1.6.1", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="columns") + + +@app.cell(column=0) +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +async def _(): + import sys + + if "pyodide" in sys.modules: + import micropip + + await micropip.install("altair") + + import altair as alt + + return (alt,) + + +@app.cell +def _(): + import sklearn + import sklearn.datasets + import sklearn.manifold + + return (sklearn,) + + +@app.cell +def _(): + import polars as pl + + return (pl,) + + +@app.cell +def _(alt): + def scatter(df): + return ( + alt.Chart(df) + .mark_circle() + .encode( + x=alt.X("x:Q").scale(domain=(-2.5, 2.5)), + y=alt.Y("y:Q").scale(domain=(-2.5, 2.5)), + color=alt.Color("digit:N"), + ) + .properties(width=500, height=500) + ) + + return (scatter,) + + +@app.cell +def _(raw_digits): + def show_images(indices, max_images=10): + import matplotlib.pyplot as plt + + indices = indices[:max_images] + images = raw_digits.reshape((-1, 8, 8))[indices] + fig, axes = plt.subplots(1, len(indices)) + fig.set_size_inches(12.5, 1.5) + if len(indices) > 1: + for im, ax in zip(images, axes.flat): + ax.imshow(im, cmap="gray") + ax.set_yticks([]) + ax.set_xticks([]) + else: + axes.imshow(images[0], cmap="gray") + axes.set_yticks([]) + axes.set_xticks([]) + plt.tight_layout() + return fig + + return (show_images,) + + +@app.cell(column=1, hide_code=True) +def _(mo): + mo.md(""" + # Embedding Visualizer + """) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + Here's a PCA **embedding of numerical digits**: each point represents a + digit, with similar digits close to each other. The data is from the UCI + ML handwritten digits dataset. + + This notebook will automatically drill down into points you **select with + your mouse**; try it! + """) + return + + +@app.cell +def _(sklearn): + raw_digits, raw_labels = sklearn.datasets.load_digits(return_X_y=True) + return raw_digits, raw_labels + + +@app.cell +def _(pl, raw_digits, raw_labels, sklearn): + X_embedded = sklearn.decomposition.PCA( + n_components=2, whiten=True + ).fit_transform(raw_digits) + + embedding = pl.DataFrame( + { + "x": X_embedded[:, 0], + "y": X_embedded[:, 1], + "digit": raw_labels, + "index": list(range(X_embedded.shape[0])), + } + ) + return (embedding,) + + +@app.cell +def _(embedding, mo, scatter): + chart = mo.ui.altair_chart(scatter(embedding)) + chart + return (chart,) + + +@app.cell +def _(chart, mo): + table = mo.ui.table(chart.value) + return (table,) + + +@app.cell +def _(chart, mo, show_images, table): + # show 10 images: either the first 10 from the selection, or the first ten + # selected in the table + mo.stop(not len(chart.value)) + + selected_images = ( + show_images(list(chart.value["index"])) + if not len(table.value) + else show_images(list(table.value["index"])) + ) + + mo.md( + f""" + **Here's a preview of the images you've selected**: + + {mo.as_html(selected_images)} + + Here's all the data you've selected. + + {table} + """ + ) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/layouts/grid-dashboard.py b/examples/layouts/grid-dashboard.py index 20faf87955f..d11e2fe8ec2 100644 --- a/examples/layouts/grid-dashboard.py +++ b/examples/layouts/grid-dashboard.py @@ -15,53 +15,58 @@ import marimo -__generated_with = "0.8.20" +__generated_with = "0.19.7" app = marimo.App( width="medium", layout_file="layouts/grid-dashboard.grid.json", ) -@app.cell -def __(mo): - mo.md(r"""# Gapminder Dashboard""") +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + # Gapminder Dashboard + """) return @app.cell(hide_code=True) -def __(__file__, mo): +def _(mo): mo.md( rf""" - !!! tip "This notebook is best viewed as an app." + /// TIP + + "This notebook is best viewed as an app." - `marimo run {__file__}` + `marimo run {__file__}` - or hit `Cmd/Ctrl+.` or click the "app view" button in the bottom right. - """ + or hit `Cmd/Ctrl+.` or click the "app view" button in the bottom right. + /// + """ ) return @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - If you would like to see _how_ this application is made, continue down. +def _(mo): + mo.md(""" + If you would like to see _how_ this application is made, continue down. - This application is adapted from - """ - ) + This application is adapted from + """) return -@app.cell -def __(mo): - mo.md(r"""# Getting the data""") +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + # Getting the data + """) return @app.cell -def __(): +def _(): import numpy as np import pandas as pd import panel as pn @@ -78,11 +83,11 @@ def __(): warnings.simplefilter("ignore") pn.extension("vega", "plotly", defer_load=True, sizing_mode="stretch_width") mpl.use("agg") - return alt, go, hvplot, mo, mpl, np, pd, pio, plt, pn, warnings + return alt, go, mo, np, pd, pio, plt @app.cell -def __(): +def _(): XLABEL = "GDP per capita (2000 dollars)" YLABEL = "Life expectancy (years)" YLIM = (20, 90) @@ -90,11 +95,11 @@ def __(): WIDTH = 500 # pixels ACCENT = "#D397F8" PERIOD = 1000 # milliseconds - return ACCENT, HEIGHT, PERIOD, WIDTH, XLABEL, YLABEL, YLIM + return HEIGHT, XLABEL, YLABEL, YLIM @app.cell -def __(pd): +def _(pd): dataset = pd.read_csv( "https://raw.githubusercontent.com/kirenz/datasets/b8f17b8fc4907748b3317554d65ffd780edcc057/gapminder.csv" ) @@ -103,20 +108,22 @@ def __(pd): @app.cell -def __(dataset): +def _(dataset): YEARS = [int(year) for year in dataset.year.unique()] str(YEARS) return (YEARS,) -@app.cell -def __(mo): - mo.md(r"""# Charting the data""") +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + # Charting the data + """) return @app.cell -def __(dataset, np): +def _(dataset, np): # Common utility functions @@ -136,22 +143,12 @@ def get_xlim(data): dataset["gdpPercap"].min() - 100, dataset[dataset["gdpPercap"] < 10000].max()["gdpPercap"] + 1000, ) + return get_data, get_title, get_xlim @app.cell -def __( - XLABEL, - YLABEL, - YLIM, - alt, - get_data, - get_title, - get_xlim, - go, - pio, - plt, -): +def _(XLABEL, YLABEL, YLIM, alt, get_data, get_title, get_xlim, go, pio, plt): # Charting functions @@ -284,11 +281,12 @@ def hvplot_view(year=1952, show_legend=True): xlabel=XLABEL, height=400, ) + return altair_view, hvplot_view, mpl_view, plotly_view @app.cell -def __(HEIGHT, altair_view, hvplot_view, mo, mpl_view, plotly_view): +def _(HEIGHT, altair_view, hvplot_view, mo, mpl_view, plotly_view): mo.ui.tabs( { "matplotlib": mpl_view(1952, True), @@ -300,26 +298,30 @@ def __(HEIGHT, altair_view, hvplot_view, mo, mpl_view, plotly_view): return -@app.cell -def __(mo): - mo.md(r"""# Building a dashboard""") +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + # Building a dashboard + """) return -@app.cell -def __(mo): - mo.md("""## Creating widgets""") +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + ## Creating widgets + """) return @app.cell -def __(YEARS, mo): +def _(YEARS, mo): get_year, set_year = mo.state(YEARS[-1]) return get_year, set_year @app.cell -def __(YEARS, get_year, mo, set_year): +def _(YEARS, get_year, mo, set_year): year = mo.ui.slider( value=get_year(), steps=YEARS, full_width=True, on_change=set_year ) @@ -328,7 +330,7 @@ def __(YEARS, get_year, mo, set_year): @app.cell -def __(mo, show_legend, year): +def _(mo, show_legend, year): mo.vstack( [ mo.md(f"Year: **{year.value}**"), @@ -340,14 +342,14 @@ def __(mo, show_legend, year): @app.cell -def __(mo): +def _(mo): autoplay = mo.ui.refresh(options=["1s", "3s", "5s"], label="Autoplay") autoplay return (autoplay,) @app.cell -def __(YEARS, autoplay, set_year): +def _(YEARS, autoplay, set_year): autoplay @@ -359,68 +361,66 @@ def increment(v): set_year(increment) - return (increment,) + return -@app.cell -def __(mo): - mo.md(r"""## Creating the charts, reactive to the widgets""") +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ## Creating the charts, reactive to the widgets + """) return @app.cell -def __(mpl_view, show_legend, year): +def _(mpl_view, show_legend, year): mpl_view(year=year.value, show_legend=show_legend.value) return @app.cell -def __(plotly_view, show_legend, year): +def _(plotly_view, show_legend, year): plotly_view(year=year.value, show_legend=show_legend.value) return @app.cell -def __(HEIGHT, altair_view, show_legend, year): +def _(HEIGHT, altair_view, show_legend, year): altair_view(year=year.value, show_legend=show_legend.value, height=HEIGHT - 100) return @app.cell -def __(hvplot_view, show_legend, year): +def _(hvplot_view, show_legend, year): hvplot_view(year=year.value, show_legend=show_legend.value) return @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - ## Add any extra flair +def _(mo): + mo.md(""" + ## Add any extra flair - Next we will toggle to "App view" (hit `Cmd/Ctrl+.` or click the "app view") in order to layout our dashboard with the grid layout editor. - """ - ) + Next we will toggle to "App view" (hit `Cmd/Ctrl+.` or click the "app view") in order to layout our dashboard with the grid layout editor. + """) return @app.cell -def __(mo): +def _(mo): mo.image("https://marimo.io/logotype-wide.svg") return @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - ## 🎓 Info +def _(mo): + mo.md(""" + ## 🎓 Info - Here you can try out four different plotting libraries controlled by a couple of widgets, for Hans Rosling's [gapminder](https://demo.bokeh.org/gapminder) example. + Here you can try out four different plotting libraries controlled by a couple of widgets, for Hans Rosling's [gapminder](https://demo.bokeh.org/gapminder) example. - This application is inspired by [Panel](https://examples.holoviz.org/gallery/gapminders/gapminders.html). - """ - ) + This application is inspired by [Panel](https://examples.holoviz.org/gallery/gapminders/gapminders.html). + """) return diff --git a/examples/layouts/sidebar.py b/examples/layouts/sidebar.py index ffad9edb72d..9a319d65007 100644 --- a/examples/layouts/sidebar.py +++ b/examples/layouts/sidebar.py @@ -9,37 +9,36 @@ import marimo -__generated_with = "0.8.20" +__generated_with = "0.19.7" app = marimo.App(width="full") @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - !!! tip "This notebook is best viewed as an app." - Hit `Cmd/Ctrl+.` or click the "app view" button in the bottom right. - """ - ) +def _(mo): + mo.md(r""" + !!! tip "This notebook is best viewed as an app." + Hit `Cmd/Ctrl+.` or click the "app view" button in the bottom right. + """) return @app.cell -def __(): +def _(): import marimo as mo import altair as alt from vega_datasets import data + return alt, data, mo @app.cell -def __(data): +def _(data): gapminder = data.gapminder() return (gapminder,) @app.cell -def __(gapminder, mo): +def _(gapminder, mo): # Filters year = mo.ui.slider.from_series( gapminder["year"], full_width=True, label="Year", step=5 @@ -51,7 +50,7 @@ def __(gapminder, mo): @app.cell -def __(gapminder, population, year): +def _(gapminder, population, year): # Filter the dataset filtered_data = gapminder[ (gapminder["year"] == year.value) @@ -62,7 +61,7 @@ def __(gapminder, population, year): @app.cell -def __(alt, filtered_data, mo): +def _(alt, filtered_data, mo): chart = mo.ui.altair_chart( alt.Chart(filtered_data) .mark_circle(opacity=0.7) @@ -86,14 +85,14 @@ def __(alt, filtered_data, mo): @app.cell -def __(chart): +def _(chart): # Show the chart selection chart.value if not chart.value.empty else None return @app.cell -def __(mo, population, year): +def _(mo, population, year): mo.sidebar( [ mo.md("# Gap Minder"), diff --git a/examples/layouts/slides.py b/examples/layouts/slides.py index d77b37ddecf..466f919d9dd 100644 --- a/examples/layouts/slides.py +++ b/examples/layouts/slides.py @@ -10,58 +10,54 @@ import marimo -__generated_with = "0.8.20" +__generated_with = "0.19.7" app = marimo.App(width="medium", layout_file="layouts/slides.slides.json") @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - !!! tip "This notebook is best viewed as an app." - Hit `Cmd/Ctrl+.` or click the "app view" button in the bottom right. - """ - ) +def _(mo): + mo.md(r""" + !!! tip "This notebook is best viewed as an app." + Hit `Cmd/Ctrl+.` or click the "app view" button in the bottom right. + """) return @app.cell -def __(mo): - mo.md( - """ - # DuckDB Tricks 🦆 +def _(mo): + mo.md(""" + # DuckDB Tricks 🦆 - We use a simple example data set to present a few tricks that are useful when using DuckDB. + We use a simple example data set to present a few tricks that are useful when using DuckDB. - > - > Turned into slides from - """ - ) + > + > Turned into slides from + """) return @app.cell -def __(mo): - mo.md( - r""" - ## Installation - - ```bash - uv pip install duckdb - ``` - """ - ) +def _(mo): + mo.md(r""" + ## Installation + + ```bash + uv add duckdb + ``` + """) return @app.cell -def __(mo): - mo.md(r"""# Creating the example data set""") +def _(mo): + mo.md(r""" + # Creating the example data set + """) return @app.cell -def __(duckdb, mo, print_and_run): +def _(duckdb, mo, print_and_run): _SQL = """ CREATE OR REPLACE TABLE example (s STRING, x DOUBLE); INSERT INTO example VALUES ('foo', 10/9), ('bar', 50/7), ('qux', 9/4); @@ -94,128 +90,126 @@ def __(duckdb, mo, print_and_run): @app.cell -def __(mo): - mo.md(r"""# Pretty-printing floating-point numbers""") +def _(mo): + mo.md(r""" + # Pretty-printing floating-point numbers + """) return @app.cell -def __(mo, print_and_run): - mo.md( - f""" - When printing a floating-point number to the output, the fractional parts can be difficult to read and compare. For example, the following query returns three numbers between 1 and 8 but their printed widths are very different due to their fractional parts. +def _(mo, print_and_run): + mo.md(f""" + When printing a floating-point number to the output, the fractional parts can be difficult to read and compare. For example, the following query returns three numbers between 1 and 8 but their printed widths are very different due to their fractional parts. - {print_and_run("SELECT x FROM 'example.csv';")} + {print_and_run("SELECT x FROM 'example.csv';")} - By casting a column to a DECIMAL with a fixed number of digits after the decimal point, we can pretty-print it as follows: + By casting a column to a DECIMAL with a fixed number of digits after the decimal point, we can pretty-print it as follows: - {print_and_run(''' - SELECT x::DECIMAL(15, 3) AS x - FROM 'example.csv'; - ''')} + {print_and_run(''' + SELECT x::DECIMAL(15, 3) AS x + FROM 'example.csv'; + ''')} - A typical alternative solution is to use the printf or format functions, e.g.: + A typical alternative solution is to use the printf or format functions, e.g.: - {print_and_run(''' - SELECT printf('%.3f', x) - FROM 'example.csv'; - ''')} + {print_and_run(''' + SELECT printf('%.3f', x) + FROM 'example.csv'; + ''')} - However, these approaches require us to specify a formatting string that's easy to forget. What's worse, the statement above returns string values, which makes subsequent operations (e.g., sorting) more difficult. Therefore, unless keeping the full precision of the floating-point numbers is a concern, casting to DECIMAL values should be the preferred solution for most use cases. - """ - ) + However, these approaches require us to specify a formatting string that's easy to forget. What's worse, the statement above returns string values, which makes subsequent operations (e.g., sorting) more difficult. Therefore, unless keeping the full precision of the floating-point numbers is a concern, casting to DECIMAL values should be the preferred solution for most use cases. + """) return @app.cell -def __(mo): - mo.md(r"""# Copying the schema of a table""") +def _(mo): + mo.md(r""" + # Copying the schema of a table + """) return @app.cell -def __(mo, print_and_run): - mo.md( - f""" - To copy the schema from a table without copying its data, we can use LIMIT 0. +def _(mo, print_and_run): + mo.md(f""" + To copy the schema from a table without copying its data, we can use LIMIT 0. - {print_and_run(''' - CREATE OR REPLACE TABLE example AS - FROM 'example.csv'; - CREATE OR REPLACE TABLE tbl AS - FROM example - LIMIT 0; - ''')} + {print_and_run(''' + CREATE OR REPLACE TABLE example AS + FROM 'example.csv'; + CREATE OR REPLACE TABLE tbl AS + FROM example + LIMIT 0; + ''')} - This will result in an empty table with the same schema as the source table: + This will result in an empty table with the same schema as the source table: - {print_and_run('DESCRIBE tbl;')} + {print_and_run('DESCRIBE tbl;')} - This will return the schema of the table. + This will return the schema of the table. - ```sql - CREATE TABLE example(s VARCHAR, x DOUBLE); - ``` + ```sql + CREATE TABLE example(s VARCHAR, x DOUBLE); + ``` - After editing the table’s name (e.g., example to tbl), this query can be used to create a new table with the same schema. - """ - ) + After editing the table’s name (e.g., example to tbl), this query can be used to create a new table with the same schema. + """) return @app.cell -def __(mo): - mo.md(r"""# Shuffling data""") +def _(mo): + mo.md(r""" + # Shuffling data + """) return @app.cell -def __(mo): +def _(mo): rerun = mo.ui.button(label="Run again") return (rerun,) @app.cell -def __(mo, print_and_run, rerun): - mo.md( - f""" - Sometimes, we need to introduce some entropy into the ordering of the data by shuffling it. To shuffle non-deterministically, we can simply sort on a random value provided the random() function: +def _(mo, print_and_run, rerun): + mo.md(f""" + Sometimes, we need to introduce some entropy into the ordering of the data by shuffling it. To shuffle non-deterministically, we can simply sort on a random value provided the random() function: - {rerun} + {rerun} - {print_and_run(''' - FROM 'example.csv' ORDER BY random(); - ''')} + {print_and_run(''' + FROM 'example.csv' ORDER BY random(); + ''')} - Shuffling deterministically is a bit more tricky. To achieve this, we can order on the hash, of the rowid pseudocolumn. Note that this column is only available in physical tables, so we first have to load the CSV in a table, then perform the shuffle operation as follows: + Shuffling deterministically is a bit more tricky. To achieve this, we can order on the hash, of the rowid pseudocolumn. Note that this column is only available in physical tables, so we first have to load the CSV in a table, then perform the shuffle operation as follows: - {rerun} + {rerun} - {print_and_run(''' - CREATE OR REPLACE TABLE example AS FROM 'example.csv'; - FROM example ORDER BY hash(rowid + 42); - ''')} + {print_and_run(''' + CREATE OR REPLACE TABLE example AS FROM 'example.csv'; + FROM example ORDER BY hash(rowid + 42); + ''')} - Note that the + 42 is only necessary to nudge the first row from its position – as hash(0) returns 0, the smallest possible value, using it for ordering leaves the first row in its place. - """ - ) + Note that the + 42 is only necessary to nudge the first row from its position – as hash(0) returns 0, the smallest possible value, using it for ordering leaves the first row in its place. + """) return @app.cell -def __(mo): - mo.md( - """ - # Closing thoughts +def _(mo): + mo.md(""" + # Closing thoughts - That’s it for today. The tricks shown in this post are available on [duckdbsnippets.com](https://duckdbsnippets.com). - """ - ) + That’s it for today. The tricks shown in this post are available on [duckdbsnippets.com](https://duckdbsnippets.com). + """) return @app.cell -def __(duckdb, mo): +def _(duckdb, mo): # Utils def print_and_run(sql: str): @@ -233,13 +227,15 @@ def print_and_run(sql: str): ``` {mo.ui.table(result.df(), selection=None, pagination=None, show_column_summaries=False)} """ + return (print_and_run,) @app.cell -def __(): +def _(): import marimo as mo import duckdb + return duckdb, mo diff --git a/examples/markdown/README.md b/examples/markdown/README.md new file mode 100644 index 00000000000..cacc689da24 --- /dev/null +++ b/examples/markdown/README.md @@ -0,0 +1,24 @@ +# Markdown + +These basic examples show how to use write markdown in marimo. + +> [!TIP] +> New to marimo? Run `marimo tutorial intro` and `marimo tutorial markdown` +> at the command line first! + +## Running examples + +The requirements of each notebook are serialized in them as a top-level +comment. Here are the steps to open an example notebook: + +1. [Install `uv`](https://github.com/astral-sh/uv/?tab=readme-ov-file#installation) +2. Open an example with `uvx marimo edit --sandbox ` + +> [!TIP] +> The [`--sandbox` flag](https://docs.marimo.io/guides/editor_features/package_management.html) opens the notebook in an isolated virtual environment, +> automatically installing the notebook's dependencies 📦 + +You can also open notebooks without `uv`, in which case you'll need to +manually [install marimo](https://docs.marimo.io/getting_started/index.html#installation) +first. Then run `marimo edit `; however, you'll also need to +install the requirements yourself. diff --git a/examples/markdown/admonitions.py b/examples/markdown/admonitions.py new file mode 100644 index 00000000000..8f69c020809 --- /dev/null +++ b/examples/markdown/admonitions.py @@ -0,0 +1,45 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + Use **admonitions** in markdown to bring attention to text. Here are some examples. + """) + return + + +@app.cell +def _(mo): + mo.md(""" + /// admonition | Heads up. + + Here's some information. + /// + """) + return + + +@app.cell +def _(mo): + mo.md(""" + /// attention | Attention! + + This is important. + /// + """) + return + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +if __name__ == "__main__": + app.run() diff --git a/examples/markdown/details.py b/examples/markdown/details.py new file mode 100644 index 00000000000..56de5944936 --- /dev/null +++ b/examples/markdown/details.py @@ -0,0 +1,91 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + Create expandable markdown blocks with `details`: + """) + return + + +@app.cell +def _(mo): + mo.md(""" + /// details | Hello, details! + + Some additional content. + + /// + """) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + Style details using the "type" argument: + """) + return + + +@app.cell +def _(mo): + mo.md(""" + /// details | Info details + type: info + + Some additional content. + /// + """) + return + + +@app.cell +def _(mo): + mo.md(""" + /// details | Warning details + type: warn + + This highlights something to watch out for + /// + """) + return + + +@app.cell +def _(mo): + mo.md(""" + /// details | Danger details + type: danger + + This indicates a critical warning or dangerous situation + /// + """) + return + + +@app.cell +def _(mo): + mo.md(""" + /// details | Success details + type: success + + This indicates a successful outcome or positive note + /// + """) + return + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +if __name__ == "__main__": + app.run() diff --git a/examples/markdown/dynamic_markdown.py b/examples/markdown/dynamic_markdown.py new file mode 100644 index 00000000000..f3b72e2f7b5 --- /dev/null +++ b/examples/markdown/dynamic_markdown.py @@ -0,0 +1,85 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + Use `mo.md` with an `f-string` to create markdown that depends on the value of Python objects. + """) + return + + +@app.cell +def _(): + name = "Alice" + return (name,) + + +@app.cell +def _(mo, name): + mo.md( + f""" + Hello, {name}! + """ + ) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + Embed marimo UI elements in markdown directly: + """) + return + + +@app.cell +def _(mo): + text_input = mo.ui.text(placeholder="My name is ...", debounce=False) + return (text_input,) + + +@app.cell +def _(mo, text_input): + mo.md( + f""" + What's your name? {text_input} + + Hello, {text_input.value}! + """ + ) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + Wrap plots and data structures in `mo.as_html()` to hook into marimo's rich media viewer: + """) + return + + +@app.cell +def _(mo): + mo.md( + f""" + Here's a list of numbers: + + {mo.as_html([1, 2, 3])} + """ + ) + return + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +if __name__ == "__main__": + app.run() diff --git a/examples/markdown/emoji.py b/examples/markdown/emoji.py new file mode 100644 index 00000000000..b5d321ca712 --- /dev/null +++ b/examples/markdown/emoji.py @@ -0,0 +1,31 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + Use colon syntax as a shortcut for **emojis** in your markdown. + """) + return + + +@app.cell +def _(mo): + mo.md(r""" + :rocket: :smile: + """) + return + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +if __name__ == "__main__": + app.run() diff --git a/examples/markdown/mermaid.py b/examples/markdown/mermaid.py new file mode 100644 index 00000000000..84fde793785 --- /dev/null +++ b/examples/markdown/mermaid.py @@ -0,0 +1,71 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "marimo", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + mo.mermaid( + """ + graph TD + A[Enter Chart Definition] --> B(Preview) + B --> C{decide} + C --> D[Keep] + C --> E[Edit Definition] + E --> B + D --> F[Save Image and Code] + F --> B + """ + ).center() + return + + +@app.cell +def _(mo): + graph = mo.ui.code_editor( + value="""sequenceDiagram + Alice->>John: Hello John, how are you? + John-->>Alice: Great! + Alice-)John: See you later!""", + language="md", + label="Mermaid editor", + ) + graph + return (graph,) + + +@app.cell +def _(graph, mo): + mo.mermaid(graph.value).text + return + + +@app.cell +def _(graph, mo): + mo.md(f""" + You can render mermaid directly inside `mo.md`. Using + + `mo.mermaid()` + + {mo.mermaid(graph.value).text} + """) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/misc/README.md b/examples/misc/README.md index 6e924c6614a..adf5868f571 100644 --- a/examples/misc/README.md +++ b/examples/misc/README.md @@ -11,9 +11,8 @@ A hodgepodge of examples! The requirements of each notebook are serialized in them as a top-level comment. Here are the steps to open an example notebook: -1. [Install marimo](https://docs.marimo.io/getting_started/index.html#installation) -2. [Install `uv`](https://github.com/astral-sh/uv/?tab=readme-ov-file#installation) -3. Open an example with `marimo edit --sandbox `. +1. [Install `uv`](https://github.com/astral-sh/uv/?tab=readme-ov-file#installation) +2. Open an example with `uvx marimo edit --sandbox `. > [!TIP] > The [`--sandbox` flag](https://docs.marimo.io/guides/editor_features/package_management.html) opens the notebook in an isolated virtual environment, diff --git a/examples/misc/bayes_theorem.py b/examples/misc/bayes_theorem.py index 420689da32c..deeb5c93111 100644 --- a/examples/misc/bayes_theorem.py +++ b/examples/misc/bayes_theorem.py @@ -8,53 +8,51 @@ import marimo -__generated_with = "0.9.10" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - # Bayes' Theorem +def _(mo): + mo.md(r""" + # Bayes' Theorem - _This interactive notebook was made with [marimo](https://github.com/marimo-team/marimo), and is [based on an explanation of Bayes' Theorem by Grant Sanderson](https://www.youtube.com/watch?v=HZGCoVF3YvM&list=PLzq7odmtfKQw2KIbQq0rzWrqgifHKkPG1&index=1&t=3s)_. + _This interactive notebook was made with [marimo](https://github.com/marimo-team/marimo), and is [based on an explanation of Bayes' Theorem by Grant Sanderson](https://www.youtube.com/watch?v=HZGCoVF3YvM&list=PLzq7odmtfKQw2KIbQq0rzWrqgifHKkPG1&index=1&t=3s)_. - Bayes theorem provides a convenient way to calculate the probability - of a hypothesis event $H$ given evidence $E$: + Bayes theorem provides a convenient way to calculate the probability + of a hypothesis event $H$ given evidence $E$: - \[ - P(H \mid E) = \frac{P(H) P(E \mid H)}{P(E)}. - \] + \[ + P(H \mid E) = \frac{P(H) P(E \mid H)}{P(E)}. + \] - **The numerator.** The numerator is the probability of events $E$ and $H$ happening - together; that is, + **The numerator.** The numerator is the probability of events $E$ and $H$ happening + together; that is, - \[ - P(H) P(E \mid H) = P(E \cap H). - \] + \[ + P(H) P(E \mid H) = P(E \cap H). + \] - **The denominator.** - In most calculations, it is helpful to rewrite the denominator $P(E)$ as + **The denominator.** + In most calculations, it is helpful to rewrite the denominator $P(E)$ as - \[ - P(E) = P(H)P(E \mid H) + P(\neg H) P (E \mid \neg H), - \] + \[ + P(E) = P(H)P(E \mid H) + P(\neg H) P (E \mid \neg H), + \] - which in turn can also be written as + which in turn can also be written as - \[ - P(E) = P(E \cap H) + P(E \cap \neg H). - \] - """ - ).left() + \[ + P(E) = P(E \cap H) + P(E \cap \neg H). + \] + """) return @app.cell(hide_code=True) -def __( +def _( bayes_result, construct_probability_plot, mo, @@ -104,9 +102,9 @@ def __( @app.cell(hide_code=True) -def __(mo): +def _(mo): p_h = mo.ui.slider(0.0, 1, label="$P(H)$", value=0.1, step=0.1) - p_e_given_h = mo.ui.slider(0.0, 1, label="$P(E \mid H)$", value=0.3, step=0.1) + p_e_given_h = mo.ui.slider(0.0, 1, label=r"$P(E \mid H)$", value=0.3, step=0.1) p_e_given_not_h = mo.ui.slider( 0.0, 1, label=r"$P(E \mid \neg H)$", value=0.3, step=0.1 ) @@ -114,21 +112,21 @@ def __(mo): @app.cell(hide_code=True) -def __(p_e_given_h, p_e_given_not_h, p_h): - p_e = p_h.value*p_e_given_h.value + (1 - p_h.value)*p_e_given_not_h.value +def _(p_e_given_h, p_e_given_not_h, p_h): + p_e = p_h.value * p_e_given_h.value + (1 - p_h.value) * p_e_given_not_h.value bayes_result = p_h.value * p_e_given_h.value / p_e return bayes_result, p_e @app.cell -def __(p_e_given_h, p_e_given_not_h, p_h): +def _(p_e_given_h, p_e_given_not_h, p_h): def construct_probability_plot(): import matplotlib.pyplot as plt plt.axes() # Radius: 1, face-color: red, edge-color: blue - plt.figure(figsize=(6,6)) + plt.figure(figsize=(6, 6)) base = plt.Rectangle((0, 0), 1, 1, fc="black", ec="white", alpha=0.25) h = plt.Rectangle((0, 0), p_h.value, 1, fc="yellow", ec="white", label="H") e_given_h = plt.Rectangle( @@ -141,7 +139,12 @@ def construct_probability_plot(): label="E", ) e_given_not_h = plt.Rectangle( - (p_h.value, 0), 1 - p_h.value, p_e_given_not_h.value, fc="teal", ec="white", alpha=0.5 + (p_h.value, 0), + 1 - p_h.value, + p_e_given_not_h.value, + fc="teal", + ec="white", + alpha=0.5, ) plt.gca().add_patch(base) plt.gca().add_patch(h) @@ -149,12 +152,14 @@ def construct_probability_plot(): plt.gca().add_patch(e_given_h) plt.legend() return plt.gca() + return (construct_probability_plot,) @app.cell -def __(): +def _(): import marimo as mo + return (mo,) diff --git a/examples/misc/colliding_blocks_and_pi.py b/examples/misc/colliding_blocks_and_pi.py new file mode 100644 index 00000000000..aedef6dff97 --- /dev/null +++ b/examples/misc/colliding_blocks_and_pi.py @@ -0,0 +1,331 @@ +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "marimo", +# "matplotlib==3.10.1", +# "numpy==2.2.3", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + # Finding $\pi$ in colliding blocks + + One of the remarkable things about mathematical constants like $\pi$ is how frequently they arise in nature, in the most surprising of places. + + Inspired by 3Blue1Brown, this [marimo notebook](https://github.com/marimo-team/marimo) shows how the number of collisions incurred in a particular system involving two blocks converges to the digits in $\pi$. + """) + return + + +@app.cell(hide_code=True) +def _(mo): + slider = mo.ui.slider(start=0, stop=3, value=3, show_value=True) + return (slider,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + ## Simulate! + """) + return + + +@app.cell(hide_code=True) +def _(mo, slider): + mo.md(f"Use this slider to control the weight of the heavier block: {slider}") + return + + +@app.cell(hide_code=True) +def _(mo, slider): + mo.md(rf"The heavier block weighs **$100^{{ {slider.value} }}$** kg.") + return + + +@app.cell(hide_code=True) +def _(mo): + run_button = mo.ui.run_button(label="Run simulation!") + run_button.right() + return (run_button,) + + +@app.cell +def _(run_button, simulate_collisions, slider): + if run_button.value: + mass_ratio = 100**slider.value + _, ani, collisions = simulate_collisions( + mass_ratio, total_time=15, dt=0.001 + ) + return (ani,) + + +@app.cell +def _(ani, mo, run_button): + video = None + if run_button.value: + with mo.status.spinner(title="Rendering collision video ..."): + video = mo.Html(ani.to_html5_video()) + video + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ## The 3Blue1Brown video + + If you haven't seen it, definitely check out the video that inspired this notebook: + """) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.accordion( + { + "🎥 Watch the video": mo.Html( + '' + ) + } + ) + return + + +@app.cell +def _(): + import numpy as np + import matplotlib.pyplot as plt + import matplotlib.animation as animation + from matplotlib.patches import Rectangle + + return Rectangle, animation, plt + + +@app.class_definition +class Block: + def __init__(self, mass, velocity, position, size=1.0): + self.mass = mass + self.velocity = velocity + self.position = position + self.size = size + + def update(self, dt): + self.position += self.velocity * dt + + def collide(self, other): + # Calculate velocities after elastic collision + m1, m2 = self.mass, other.mass + v1, v2 = self.velocity, other.velocity + + new_v1 = (m1 - m2) / (m1 + m2) * v1 + (2 * m2) / (m1 + m2) * v2 + new_v2 = (2 * m1) / (m1 + m2) * v1 + (m2 - m1) / (m1 + m2) * v2 + + self.velocity = new_v1 + other.velocity = new_v2 + + return 1 + + +@app.function +def check_collisions(small_block, big_block, wall_pos=0): + collisions = 0 + + # Check for collision between blocks + if small_block.position + small_block.size > big_block.position: + small_block.position = big_block.position - small_block.size + collisions += small_block.collide(big_block) + + # Check for collision with the wall + if small_block.position < wall_pos: + small_block.position = wall_pos + small_block.velocity *= -1 + collisions += 1 + + return collisions + + +@app.cell +def _(create_animation): + def simulate_collisions(mass_ratio, total_time=15, dt=0.001, animate=True): + # Initialize blocks + small_block = Block(mass=1, velocity=0, position=2) + big_block = Block(mass=mass_ratio, velocity=-0.5, position=4) + + # Simulation variables + time = 0 + collision_count = 0 + + # For animation + times = [] + small_positions = [] + big_positions = [] + collision_counts = [] + + # Run simulation + while time < total_time: + # Update positions + small_block.update(dt) + big_block.update(dt) + + # Check for and handle collisions + new_collisions = check_collisions(small_block, big_block) + collision_count += new_collisions + + # Store data for animation + times.append(time) + small_positions.append(small_block.position) + big_positions.append(big_block.position) + collision_counts.append(collision_count) + + time += dt + + print(f"Mass ratio: {mass_ratio}, Total collisions: {collision_count}") + + if animate: + axis, ani = create_animation( + times, small_positions, big_positions, collision_counts, mass_ratio + ) + else: + axis, ani = None + + return axis, ani, collision_count + + return (simulate_collisions,) + + +@app.cell +def _(Rectangle, animation, plt): + def create_animation( + times, small_positions, big_positions, collision_counts, mass_ratio + ): + fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8)) + + # Setup for blocks visualization + ax1.set_xlim(-1, 10) + ax1.set_ylim(-1, 2) + ax1.set_xlabel("Position") + ax1.set_title(f"Block Collisions (Mass Ratio = {mass_ratio})") + wall = plt.Line2D([0, 0], [-1, 2], color="black", linewidth=3) + ax1.add_line(wall) + + small_block = Rectangle((small_positions[0], 0), 1, 1, color="blue") + big_block = Rectangle((big_positions[0], 0), 1, 1, color="red") + ax1.add_patch(small_block) + ax1.add_patch(big_block) + + # Add weight labels for each block + small_label = ax1.text( + small_positions[0] + 0.5, + 1.2, + f"{1}kg", + ha="center", + va="center", + color="blue", + fontweight="bold", + ) + big_label = ax1.text( + big_positions[0] + 0.5, + 1.2, + f"{mass_ratio}kg", + ha="center", + va="center", + color="red", + fontweight="bold", + ) + + # Setup for collision count + ax2.set_xlim(0, times[-1]) + # ax2.set_ylim(0, collision_counts[-1] * 1.1) + ax2.set_ylim(0, collision_counts[-1] * 1.1) + ax2.set_xlabel("Time") + ax2.set_ylabel("# Collisions:") + ax2.set_yscale("symlog") + (collision_line,) = ax2.plot([], [], "g-") + + # Add text for collision count + collision_text = ax2.text( + 0.02, 0.9, "", transform=ax2.transAxes, fontsize="x-large" + ) + + def init(): + small_block.set_xy((small_positions[0], 0)) + big_block.set_xy((big_positions[0], 0)) + small_label.set_position((small_positions[0] + 0.5, 1.2)) + big_label.set_position((big_positions[0] + 0.5, 1.2)) + collision_line.set_data([], []) + collision_text.set_text("") + return small_block, big_block, collision_line, collision_text + + frame_step = 300 + + def animate(i): + # Speed up animation but ensure we reach the final frame + frame_index = min(i * frame_step, len(times) - 1) + + small_block.set_xy((small_positions[frame_index], 0)) + big_block.set_xy((big_positions[frame_index], 0)) + + # Update the weight labels to follow the blocks + small_label.set_position((small_positions[frame_index] + 0.5, 1.2)) + big_label.set_position((big_positions[frame_index] + 0.5, 1.2)) + + # Show data up to the current frame + collision_line.set_data( + times[: frame_index + 1], collision_counts[: frame_index + 1] + ) + + # For the last frame, show the final collision count + if frame_index >= len(times) - 1: + collision_text.set_text(f"# Collisions: {collision_counts[-1]}") + else: + collision_text.set_text( + f"# Collisions: {collision_counts[frame_index]}" + ) + + return ( + small_block, + big_block, + small_label, + big_label, + collision_line, + collision_text, + ) + + plt.tight_layout() + + frames = max(1, len(times) // frame_step) # Ensure at least 1 frame + ani = animation.FuncAnimation( + fig, + animate, + frames=frames + 1, # +1 to ensure we reach the end + init_func=init, + blit=True, + interval=30, + ) + + plt.tight_layout() + return plt.gca(), ani + + # Uncomment to save animation + # ani.save('pi_collisions.mp4', writer='ffmpeg', fps=30) + return (create_animation,) + + +if __name__ == "__main__": + app.run() diff --git a/examples/misc/compound_interest.py b/examples/misc/compound_interest.py index d5d7776e222..7d732e230e3 100644 --- a/examples/misc/compound_interest.py +++ b/examples/misc/compound_interest.py @@ -8,18 +8,20 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App() @app.cell(hide_code=True) -def __(mo): - mo.md("""# Compound Interest""") +def _(mo): + mo.md(""" + # Compound Interest + """) return @app.cell(hide_code=True) -def __(mo): +def _(mo): mo.md( """ This notebook illustrates exponential growth, using compound interest @@ -30,7 +32,7 @@ def __(mo): @app.cell(hide_code=True) -def __(mo): +def _(mo): initial_investment = mo.ui.slider(0, 1000000, step=1e4, value=10000) monthly_investment = mo.ui.slider(0, 50000, value=1000, step=1e3) annual_return = mo.ui.slider(0, 0.15, value=0.07, step=0.01) @@ -73,7 +75,6 @@ def __(mo): capital_gains_tax_rate, initial_investment, monthly_investment, - table, years, ) @@ -130,38 +131,32 @@ def total_return(self, months): _prose = f""" ## Net Worth - With an initial investment of **\${initial_investment.value :,.02f}**, an annual + With an initial investment of **${{initial_investment.value :,.02f}}**, an annual return of **{annual_return.value * 100:.02f}%** with - **\${monthly_investment.value:,.02f}** invested monthly, in {years.value} years - you will have approximately **\${values[-1]:,.02f}** accumulated in + **${{monthly_investment.value:,.02f}}** invested monthly, in {years.value} years + you will have approximately **${{values[-1]:,.02f}}** accumulated in equities. Assuming a long-term capitals gain tax of **{capital_gains_tax_rate.value*100:.02f}%**, the net portfolio value is - **\${values_less_taxes[-1]:,.02f}**. Compare that to the - **\${investment_principals[-1]:,.02f}** that you contributed in total. + **${{values_less_taxes[-1]:,.02f}}**. Compare that to the + **${{investment_principals[-1]:,.02f}}** that you contributed in total. """ ax = plt.gca() mo.md(_prose) - return ( - Portfolio, - ax, - investment_principals, - portfolio, - values, - values_less_taxes, - ) + return (ax,) @app.cell -def __(ax): +def _(ax): ax return @app.cell -def __(): +def _(): import marimo as mo import matplotlib.pyplot as plt + return mo, plt diff --git a/examples/misc/create_your_own_shape.py b/examples/misc/create_your_own_shape.py new file mode 100644 index 00000000000..050eefd2aed --- /dev/null +++ b/examples/misc/create_your_own_shape.py @@ -0,0 +1,230 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "marimo", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + # Inputs + + There are many way that a user can input with your notebook, such as text boxes, sliders, dates, and more. + """) + return + + +@app.cell +def _(mo): + mo.md(r""" + ## Text boxes + """) + return + + +@app.cell +def _(mo): + mo.hstack( + [ + username := mo.ui.text(label="Username"), + email := mo.ui.text(label="Email", kind="email"), + mo.ui.text(label="Password", kind="password"), + ] + ) + return (username,) + + +@app.cell +def _(mo, username): + mo.stop(not username.value, mo.md("What is your name?")) + + mo.md(f"👋 Hello {username.value}, nice to meet you!") + return + + +@app.cell +def _(mo): + mo.ui.text_area( + label="A space for your thoughts", full_width=True, max_length=1000 + ) + return + + +@app.cell +def _(mo): + mo.ui.number(label="What is your favorite number?", start=0, stop=10) + return + + +@app.cell +def _(mo): + mo.md(r""" + ## Sliders + """) + return + + +@app.cell +def _(mo): + slider = mo.ui.slider(0, 100, value=50, label="Basic slider", show_value=True) + range_slider = mo.ui.range_slider( + 0, 100, value=(30, 70), label="Range slider", show_value=True + ) + custom_steps = mo.ui.slider( + steps=[1, 10, 100, 1000], value=10, label="Custom steps", show_value=True + ) + vertical = mo.ui.slider( + 0, 100, value=50, label="Vertical slider", orientation="vertical" + ) + mo.vstack([slider, range_slider, custom_steps, vertical]).center() + return + + +@app.cell +def _(mo): + mo.md(r""" + ## Checkboxes and Radios + """) + return + + +@app.cell +def _(mo): + COLORS = ["red", "green", "blue"] + colors = mo.ui.array( + [mo.ui.checkbox(label=color) for color in COLORS], + ) + + shape = mo.ui.radio( + ["circle", "square", "triangle"], inline=True, value="square" + ) + mo.md(f""" + Let's build something: + + **Pick a shape:** + + {shape} + + **Pick a color:** + + {colors.hstack().left()} + """).center() + return COLORS, colors, shape + + +@app.cell(hide_code=True) +def _(COLORS, colors, mo, shape): + selected_colors = [color for i, color in enumerate(COLORS) if colors.value[i]] + + + def draw_shape(shape, colors): + if not colors: + return "" + + gradient = "" + if isinstance(colors, list) and len(colors) > 1: + gradient_id = f"grad{hash(tuple(colors)) % 1000}" + stops = "".join( + [ + f'' + for i, color in enumerate(colors) + ] + ) + gradient = f'{stops}' + fill_color = f"url(#{gradient_id})" + else: + fill_color = colors if isinstance(colors, str) else colors[0] + + if shape == "circle": + html = f'{gradient}' + elif shape == "square": + html = f'{gradient}' + elif shape == "triangle": + html = f'{gradient}' + else: + html = "Shape not recognized" + return mo.Html(html) + + + mo.md(f""" + A {"/".join(selected_colors)} {shape.value}: + {draw_shape(shape.value, selected_colors)} + """).center() + return + + +@app.cell +def _(mo): + mo.md(""" + ## Dates + """) + return + + +@app.cell +def _(mo): + import datetime + + start_date = mo.ui.date( + label="Start date", + start=datetime.date(2020, 1, 1), + stop=datetime.date(2020, 12, 31), + ) + end_date = mo.ui.date( + label="End date", + start=datetime.date(2020, 1, 1), + stop=datetime.date(2020, 12, 31), + ) + return end_date, start_date + + +@app.cell +def _(end_date, mo, start_date): + mo.hstack( + [ + mo.hstack([start_date, "➡️", end_date]).left(), + mo.md(f"From {start_date.value} to {end_date.value}"), + ] + ) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + ## Dropdowns + """) + return + + +@app.cell +def _(mo): + single = mo.ui.dropdown( + ["Option 1", "Option 2", "Option 3", "Option 4", "Option 5"], + label="Single select", + ) + multi = mo.ui.multiselect( + ["Option 1", "Option 2", "Option 3", "Option 4", "Option 5"], + label="Multi select", + value=["Option 1", "Option 2"], + ) + mo.hstack([single, multi]) + return + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +if __name__ == "__main__": + app.run() diff --git a/examples/misc/custom_configuration.py b/examples/misc/custom_configuration.py new file mode 100644 index 00000000000..7025ae9d970 --- /dev/null +++ b/examples/misc/custom_configuration.py @@ -0,0 +1,42 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "marimo", +# ] +# [tool.marimo.runtime] +# auto_instantiate = false +# on_cell_change = "lazy" +# [tool.marimo.display] +# theme = "dark" +# cell_output = "below" +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + mo.md(r""" + This is not auto-run because it has custom marimo configuration in the file header: + + ```toml + [tool.marimo.runtime] + auto_instantiate = false + on_cell_change = "lazy" + ``` + """) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/misc/explore_your_own_data.py b/examples/misc/explore_your_own_data.py new file mode 100644 index 00000000000..8447120d46e --- /dev/null +++ b/examples/misc/explore_your_own_data.py @@ -0,0 +1,126 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "marimo", +# "pandas==2.2.3", +# "matplotlib==3.9.2", +# "altair==5.4.1", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="full") + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + # Data Explorer + """) + return + + +@app.cell(hide_code=True) +def _(mo): + sample = "https://github.com/vega/vega/blob/main/docs/data/stocks.csv" + + mo.md( + f""" + This notebook lets you upload a CSV and plot its columns. + + You can download a sample CSV if you'd like. + """ + ) + return + + +@app.cell(hide_code=True) +def _(mo, uploaded_file): + mo.md( + f""" + {mo.hstack([mo.md("**Upload a CSV.**")], justify="center")} + + {uploaded_file} + """ + ) + return + + +@app.cell +def _(io, mo, pd, uploaded_file): + mo.stop(not uploaded_file.name()) + df = pd.read_csv(io.StringIO(uploaded_file.contents().decode())) + return (df,) + + +@app.cell +def _(df, mo): + mo.ui.table(df, page_size=5, selection=None) + return + + +@app.cell +def _(df, mo): + plot_type = mo.ui.dropdown( + ["line", "hist"], value="line", label="Choose a plot type: " + ) + + x_column = mo.ui.dropdown(df.columns, label="Choose x-axis: ") + y_column = mo.ui.dropdown(df.columns, label="Choose y-axis: ") + color_column = mo.ui.dropdown(df.columns, label="Choose color-axis: ") + return color_column, plot_type, x_column, y_column + + +@app.cell +def _(color_column, mo, plot_type, x_column, y_column): + mo.hstack( + [x_column, y_column, color_column, plot_type], justify="space-around" + ).callout(kind="warn" if not x_column.value else "neutral") + return + + +@app.cell +def _(alt, color_column, df, mo, plot_type, x_column, y_column): + mo.stop(not x_column.value) + + + def plot(x_column, y_column, color_column): + y_column = y_column or "count()" + title = f"{y_column} by {x_column}" + encoding = {"x": x_column, "y": y_column} + if color_column: + encoding["color"] = color_column + if plot_type.value == "line": + chart = alt.Chart(df).mark_line() + else: + chart = alt.Chart(df).mark_bar().encode(x=alt.X(x_column, bin=True)) + return chart.encode(**encoding).properties(title=title, width="container") + + + plot(x_column.value, y_column.value, color_column.value) + return + + +@app.cell +def _(mo): + uploaded_file = mo.ui.file(filetypes=[".csv"], kind="area") + return (uploaded_file,) + + +@app.cell +def _(): + import marimo as mo + import altair as alt + + + import io + import matplotlib.pyplot as plt + import pandas as pd + + return alt, io, mo, pd + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/filterable_table.py b/examples/misc/filterable_table.py similarity index 81% rename from examples/ui/filterable_table.py rename to examples/misc/filterable_table.py index e735a7e3607..1f9abc2b08b 100644 --- a/examples/ui/filterable_table.py +++ b/examples/misc/filterable_table.py @@ -8,25 +8,27 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell(hide_code=True) -def __(mo): - mo.md(r"""# Filterable DataFrame""") +def _(mo): + mo.md(r""" + # Filterable DataFrame + """) return @app.cell -def __(data_url, pd): +def _(pd): # Read the csv df = pd.read_json(data_url("cars.json")) return (df,) @app.cell -def __(df): +def _(df): # Create options for select widgets manufacturer_options = df["Name"].str.split().str[0].unique() manufacturer_options.sort() @@ -36,7 +38,7 @@ def __(df): @app.cell -def __(cylinder_options, df, manufacturer_options, mo): +def _(cylinder_options, df, manufacturer_options, mo): # Create the filters manufacturer = mo.ui.dropdown(manufacturer_options, label="Manufacturer") cylinders = mo.ui.dropdown(cylinder_options, label="Cylinders") @@ -51,13 +53,13 @@ def __(cylinder_options, df, manufacturer_options, mo): @app.cell -def __(df, filter_df): +def _(df, filter_df): filter_df(df) return @app.cell -def __(cylinders, horse_power, manufacturer): +def _(cylinders, horse_power, manufacturer): def filter_df(df): filtered_df = df if manufacturer.value: @@ -73,20 +75,20 @@ def filter_df(df): & (filtered_df["Horsepower"] <= right) ] return filtered_df + return (filter_df,) -@app.cell -def __(): - def data_url(file): - return f"https://cdn.jsdelivr.net/npm/vega-datasets@v1.29.0/data/{file}" - return (data_url,) +@app.function +def data_url(file): + return f"https://cdn.jsdelivr.net/npm/vega-datasets@v1.29.0/data/{file}" @app.cell -def __(): +def _(): import marimo as mo import pandas as pd + return mo, pd diff --git a/examples/misc/iss.py b/examples/misc/iss.py new file mode 100644 index 00000000000..103b4b9cf6a --- /dev/null +++ b/examples/misc/iss.py @@ -0,0 +1,156 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "pandas==2.2.3", +# "vega-datasets==0.9.0", +# "requests==2.32.3", +# "altair==5.4.1", +# "marimo", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="full") + + +@app.cell +def _(chart, iss_df, mo, n_points_slider, refresh_interval_slider, refresher): + mo.hstack( + [ + mo.vstack( + [ + mo.md( + "## Settings | [`marimo.ui.slider`](https://docs.marimo.io/api/inputs/slider.html), [`marimo.ui.refresh`](https://docs.marimo.io/recipes.html#run-a-cell-on-a-timer)\n---" + ), + refresh_interval_slider, + n_points_slider, + refresher, + mo.md( + "## ISS Positions | [`marimo.ui.altair_chart`](https://docs.marimo.io/api/plotting.html#marimo.ui.altair_chart)\n---" + ), + mo.as_html(chart).style({"width": "700px"}), + ], + align="center", + ), + mo.vstack( + [ + mo.md( + "## Data | [`marimo.as_html`](https://docs.marimo.io/api/html.html)`(pd.DataFrame)`\n---" + ), + mo.as_html(iss_df), + ] + ), + ], + justify="center", + wrap=True, + gap=3, + ) + return + + +@app.cell +def _(alt, get_iss_positions, sphere, world): + hover = alt.selection_point(on="mouseover", clear="mouseout") + + # iss positions + iss_df = get_iss_positions() + iss = ( + alt.Chart(iss_df[["longitude", "latitude", "timestamp"]]) + .mark_circle( + stroke="black", + size=100, + ) + .encode( + longitude=alt.Longitude("longitude:Q"), + latitude="latitude:Q", + fill=alt.Fill( + "timestamp:Q", scale=alt.Scale(scheme="purples"), legend=None + ), + strokeWidth=alt.condition( + hover, alt.value(3, empty=False), alt.value(0) + ), + tooltip=[ + alt.Tooltip("longitude:Q", title="Longitude", format=".4f"), + alt.Tooltip("latitude:Q", title="Latitude", format=".4f"), + alt.Tooltip( + "timestamp:T", title="Timestamp", format="%Y-%m-%d %H:%M:%S" + ), + ], + ) + .add_params(hover) + ) + + chart = ( + alt.layer(sphere, world, iss) + .project(type="naturalEarth1") + .properties(width=640, title="") + ) + return chart, iss_df + + +@app.cell +def _(alt, data): + # load geo data from Vega Datasets + countries = alt.topo_feature(data.world_110m.url, 'countries') + + # world base + sphere = alt.Chart(alt.sphere()).mark_geoshape( + fill="aliceblue", stroke="black", strokeWidth=1.5 + ) + + # world map + world = alt.Chart(countries).mark_geoshape( + fill="mintcream", stroke="black", strokeWidth=0.35 + ) + return sphere, world + + +@app.cell +def _(n_points_slider, pd, refresh_interval_slider, refresher, requests, time): + def get_iss_positions(refresher=refresher): + refresher + timepoints = [int(time())] + while len(timepoints) <= n_points_slider.value: + timepoints.append(timepoints[-1] - refresh_interval_slider.value) + else: + timepoints.pop(0) + timepoints_str = str(timepoints)[1:-1].replace(" ", "") + iss_url = f"https://api.wheretheiss.at/v1/satellites/25544/positions?timestamps={timepoints_str}" + response = requests.get(iss_url) + df = pd.DataFrame(response.json()) + df['timestamp'] = pd.to_datetime(df.timestamp, unit='s') + return df[['timestamp','latitude','longitude','altitude','velocity','visibility']] + + return (get_iss_positions,) + + +@app.cell +def _(mo, refresh_interval_slider): + refresher = mo.ui.refresh(default_interval=f"{refresh_interval_slider.value}s") + return (refresher,) + + +@app.cell +def _(mo): + refresh_interval_slider = mo.ui.slider(start=5, stop=60, step=1, value=10, label="refresh interval (default = 10 sec)") + n_points_slider = mo.ui.slider(start=5, stop=30, step=1, value=15, label="number of points (default = 15)") + return n_points_slider, refresh_interval_slider + + +@app.cell +def _(): + import altair as alt + import marimo as mo + import pandas as pd + import requests + from time import time + from vega_datasets import data + + pd.options.display.max_rows = 30 + return alt, data, mo, pd, requests, time + + +if __name__ == "__main__": + app.run() diff --git a/examples/misc/monotonic_splines.py b/examples/misc/monotonic_splines.py index be9d91bb2b8..d49fbb879f3 100644 --- a/examples/misc/monotonic_splines.py +++ b/examples/misc/monotonic_splines.py @@ -13,12 +13,12 @@ import marimo -__generated_with = "0.9.14" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell -def __(): +def _(): import marimo as mo import matplotlib import matplotlib.pyplot as plt @@ -30,36 +30,34 @@ def __(): np.random.seed(0) matplotlib.style.use("ggplot") plt.rcParams["figure.figsize"] = [10, 4] - return check_array, make_pipeline, matplotlib, mo, np, plt, sklearn + return mo, np, plt @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - ## Introduction to monotonic splines +def _(mo): + mo.md(r""" + ## Introduction to monotonic splines - > It turns out that you can generate features that can help turn (linear) machine learning models into models that respent monotonicity. While this technique isn't going to be useful for every application out there, it is a nice exercise in feature engineering because it does show off some lesser known and unconventional techniques. - > - > This document reflects the code discussed in [this probabl livestream](https://www.youtube.com/watch?v=BLsWIJSKcGg) which in turn was heavily insired by [this blogpost](https://matekadlicsko.github.io/posts/monotonic-splines/). + > It turns out that you can generate features that can help turn (linear) machine learning models into models that respent monotonicity. While this technique isn't going to be useful for every application out there, it is a nice exercise in feature engineering because it does show off some lesser known and unconventional techniques. + > + > This document reflects the code discussed in [this probabl livestream](https://www.youtube.com/watch?v=BLsWIJSKcGg) which in turn was heavily insired by [this blogpost](https://matekadlicsko.github.io/posts/monotonic-splines/). - We are going to dive into feature engineering in this document, but before going there it would help to have a dataset first. So let's draw one! **Draw some points below**, but make sure that you only draw a single class of points here. We're going for a regression dataset here where the x-values need to predict the y-values. - """ - ) + We are going to dive into feature engineering in this document, but before going there it would help to have a dataset first. So let's draw one! **Draw some points below**, but make sure that you only draw a single class of points here. We're going for a regression dataset here where the x-values need to predict the y-values. + """) return @app.cell -def __(mo): +def _(mo): from drawdata import ScatterWidget widget = mo.ui.anywidget(ScatterWidget()) widget - return ScatterWidget, widget + return (widget,) @app.cell -def __(mo, widget): +def _(mo, widget): mo.stop( not widget.value["data"], mo.md("Draw a dataset above to proceed!").callout(), @@ -71,21 +69,19 @@ def __(mo, widget): @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - ## General splines +def _(mo): + mo.md(""" + ## General splines - You have probably drawn something that is very much non-linear. So you might expect a linear model to perform quite badly here. However, thanks to non-linear feature-engineering, we might still be able to get a nice fit. After all, getting the right features is 90% of the work towards a good model. + You have probably drawn something that is very much non-linear. So you might expect a linear model to perform quite badly here. However, thanks to non-linear feature-engineering, we might still be able to get a nice fit. After all, getting the right features is 90% of the work towards a good model. - So let's build a pipeline that uses the [SplineTransformer](https://scikit-learn.org/1.5/modules/generated/sklearn.preprocessing.SplineTransformer.html) from scikit-learn. This featurizer can generate "hills" on our behalf that span the input space of the x-axis. - """ - ) + So let's build a pipeline that uses the [SplineTransformer](https://scikit-learn.org/1.5/modules/generated/sklearn.preprocessing.SplineTransformer.html) from scikit-learn. This featurizer can generate "hills" on our behalf that span the input space of the x-axis. + """) return @app.cell(hide_code=True) -def __(X, np, plt, tfm): +def _(X, np, plt, tfm): X_tfm = tfm.fit_transform(X) x_range = np.linspace(-50, 900, 2000).reshape(-1, 1) @@ -96,21 +92,19 @@ def __(X, np, plt, tfm): @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - You can see the x-values that our drawing widget can provide and you can also see all the generated features. Each feature is represented with a different colored line and you should also see how each hill goes up and typically goes back down again. At the edges of the samples that we have we see straight lines in an attempt to also have some features for extrapolation. +def _(mo): + mo.md(""" + You can see the x-values that our drawing widget can provide and you can also see all the generated features. Each feature is represented with a different colored line and you should also see how each hill goes up and typically goes back down again. At the edges of the samples that we have we see straight lines in an attempt to also have some features for extrapolation. - There are some inputs for this `SplineTransformer` though. We can ask the transformer to add more hills, each hill also has a polynomial degree attached to it that we may alter and we can also tell the component to have the placement of each hill be determined by the quantiles in the dataset. + There are some inputs for this `SplineTransformer` though. We can ask the transformer to add more hills, each hill also has a polynomial degree attached to it that we may alter and we can also tell the component to have the placement of each hill be determined by the quantiles in the dataset. - Feel free to change the drawing and the parameters at this point to try and get a feeling for this. - """ - ) + Feel free to change the drawing and the parameters at this point to try and get a feeling for this. + """) return @app.cell(hide_code=True) -def __(mo): +def _(mo): n_knots = mo.ui.slider( 2, 20, step=1, show_value=True, label="number of knots", value=5 ) @@ -121,30 +115,34 @@ def __(mo): @app.cell -def __(degree, knots, n_knots): +def _(degree, knots, n_knots): from sklearn.preprocessing import SplineTransformer tfm = SplineTransformer( n_knots=n_knots.value, knots=knots.value, degree=degree.value ) tfm - return SplineTransformer, tfm + return (tfm,) @app.cell(hide_code=True) -def __(mo): - mo.md(r"""When you then take these generated features and pass them to a linear model, you should be able to see that we're indeed able to fit a very non-linear curve with a linear model.""") +def _(mo): + mo.md(r""" + When you then take these generated features and pass them to a linear model, you should be able to see that we're indeed able to fit a very non-linear curve with a linear model. + """) return @app.cell(hide_code=True) -def __(mo): - mo.md(r"""... turn into these features:""") +def _(mo): + mo.md(r""" + ... turn into these features: + """) return @app.cell -def __(X_tfm, df, y): +def _(X_tfm, df, y): import altair as alt from sklearn.linear_model import Ridge @@ -156,55 +154,53 @@ def __(X_tfm, df, y): p2 = alt.Chart(pltr).mark_line(color="red").encode(x="x", y="preds") (p1 + p2).properties(width=1000) - return Ridge, alt, p1, p2, pltr, preds + return Ridge, alt, pltr @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - ## Towards monotonic features +def _(mo): + mo.md(r""" + ## Towards monotonic features - But let's now do a trick. We will take the features that we generated and then we will cumsum over each single feature. + But let's now do a trick. We will take the features that we generated and then we will cumsum over each single feature. - That means that these features ... - """ - ) + That means that these features ... + """) return @app.cell -def __(plt, x_range, x_range_tfm): +def _(plt, x_range, x_range_tfm): plt.plot(x_range, x_range_tfm) return @app.cell -def __(plt, x_range, x_range_tfm): +def _(plt, x_range, x_range_tfm): plt.plot(x_range, x_range_tfm.cumsum(axis=0)) return @app.cell(hide_code=True) -def __(mo): - mo.md(r"""Note the correspondence between the lines here. The color in the chart above has a direct correspondence with the line below.""") +def _(mo): + mo.md(r""" + Note the correspondence between the lines here. The color in the chart above has a direct correspondence with the line below. + """) return @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - You could wonder ... what would happen if I use these 'cumsum' features? Would I still be able to get a nice fit? +def _(mo): + mo.md(r""" + You could wonder ... what would happen if I use these 'cumsum' features? Would I still be able to get a nice fit? - The chart below shows you the new predictions. - """ - ) + The chart below shows you the new predictions. + """) return @app.cell -def __(mo): +def _(mo): strictly_positive = mo.ui.checkbox(label="Strictly positive") show_iso = mo.ui.checkbox(label="Show Isotonic Regression") @@ -213,13 +209,14 @@ def __(mo): @app.cell -def __(): +def _(): import pandas as pd + return (pd,) @app.cell -def __(Ridge, X, X_tfm, alt, pd, pltr, show_iso, strictly_positive, y): +def _(Ridge, X, X_tfm, alt, pd, pltr, show_iso, strictly_positive, y): from sklearn.isotonic import IsotonicRegression preds_mono = ( @@ -244,31 +241,20 @@ def __(Ridge, X, X_tfm, alt, pd, pltr, show_iso, strictly_positive, y): ) together.properties(width=1000).interactive() - return ( - IsotonicRegression, - df_iso, - final_df, - iso, - p1_mono, - p2_mono, - preds_mono, - together, - ) + return @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - You can choose to compare the results with a prediction made by an [IsotonicRegression](https://scikit-learn.org/1.5/modules/isotonic.html) model. It may help to appreciate the feature generation technique, especially when you force the linear model to only learn strictly positive weights. +def _(mo): + mo.md(r""" + You can choose to compare the results with a prediction made by an [IsotonicRegression](https://scikit-learn.org/1.5/modules/isotonic.html) model. It may help to appreciate the feature generation technique, especially when you force the linear model to only learn strictly positive weights. - There are a few things to notice here: + There are a few things to notice here: - 1. Take the chart with a grain of salt. It does demonstrate the idea, but it does not represent a proper benchmark and we are showing everything being fit on a train set here. - 2. Notice how the feature approach has a slightly more smooth prediction over here compared to the isotonic regressor. - 3. Note that this technique is very general. It can be used on whatever estimator that enables you to learn strictly positive weights. - """ - ) + 1. Take the chart with a grain of salt. It does demonstrate the idea, but it does not represent a proper benchmark and we are showing everything being fit on a train set here. + 2. Notice how the feature approach has a slightly more smooth prediction over here compared to the isotonic regressor. + 3. Note that this technique is very general. It can be used on whatever estimator that enables you to learn strictly positive weights. + """) return diff --git a/examples/misc/mortgage_calculator.py b/examples/misc/mortgage_calculator.py index 1be96553318..94317eff4cd 100644 --- a/examples/misc/mortgage_calculator.py +++ b/examples/misc/mortgage_calculator.py @@ -10,24 +10,28 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App() @app.cell(hide_code=True) -def __(mo): - mo.md("""# Mortgage Calculator""") +def _(mo): + mo.md(""" + # Mortgage Calculator + """) return @app.cell(hide_code=True) -def __(mo): - mo.md("""## Income""") +def _(mo): + mo.md(""" + ## Income + """) return @app.cell -def __(mo): +def _(mo): income = mo.ui.number(1, 1e8, step=50, value=100, label="income (thousands)") retirement_contribution = mo.ui.number(10, 40, step=10, value=19.5) @@ -42,7 +46,7 @@ def __(mo): @app.cell -def __(mo): +def _(mo): mo.callout( mo.md( """ @@ -62,7 +66,7 @@ def __(mo): @app.cell -def __(income, mo, np, retirement_contribution): +def _(income, mo, np, retirement_contribution): standard_deduction = 25100 taxable_income = ( income.value * 1000 @@ -98,23 +102,15 @@ def calculate_federal_tax(income): mo.md( f""" - With an after-tax income of **\${income.value*1000:,}**, you'll take home - **\${net_cash_per_month:,.02f}** every month. + With an after-tax income of **${{income.value*1000:,}}**, you'll take home + **${{net_cash_per_month:,.02f}}** every month. """ ) - return ( - ca_tax, - calculate_federal_tax, - fed_tax, - net_cash, - net_cash_per_month, - standard_deduction, - taxable_income, - ) + return net_cash, net_cash_per_month @app.cell -def __(mo): +def _(mo): home_price = mo.ui.number( 100, 5000, step=100, value=500, label="home price (thousands)" ) @@ -160,25 +156,24 @@ def __(mo): @app.cell -def __(home_expense_parameters, home_purchase_parameters, mo): +def _(home_expense_parameters, home_purchase_parameters, mo): mo.hstack([home_purchase_parameters, home_expense_parameters]) return @app.cell -def __(down_payment_pct, home_price, mortgage, rate, years): +def _(down_payment_pct, home_price, mortgage, rate, years): down_payment = down_payment_pct.value / 100 * home_price.value principal = home_price.value - down_payment loan = mortgage.Loan( principal=principal * 1e3, interest=rate.value / 100, term=years.value ) - return down_payment, loan, principal + return (loan,) @app.cell -def __( - down_payment, +def _( home_insurance, home_price, loan, @@ -208,50 +203,42 @@ def __( mo.md( f""" - You're purchasing a home worth **\${home_price.value * 1000:,}**, with a - down payment of **\${down_payment*1000:,.02f}**. + You're purchasing a home worth **${{home_price.value * 1000:,}}**, with a + down payment of **${{down_payment*1000:,.02f}}**. At a rate of **{rate.value}**%, - you will owe **\${annual_home_payment:,.02f}** per year on home expenses. - That's **\${monthly_home_payment:,.02f}** per month, which is + you will owe **${{annual_home_payment:,.02f}}** per year on home expenses. + That's **${{monthly_home_payment:,.02f}}** per month, which is **{monthly_home_payment / (net_cash_per_month) * 100:,.02f}%** of your take-home pay. - You'll have **\${cash_less_housing/12:,.02f}** left over per + You'll have **${{cash_less_housing/12:,.02f}}** left over per month for expenses and saving. """ ).callout() - return ( - annual_home_payment, - cash_less_housing, - home_insurance_monthly, - monthly_home_payment, - mortgage_monthly, - property_tax_monthly, - utilities_monthly, - ) + return (cash_less_housing,) @app.cell -def __(mo): - mo.md("""## Monthly Expenses""") +def _(mo): + mo.md(""" + ## Monthly Expenses + """) return @app.cell -def __(mo): - mo.md( - """ - In addition to paying for your home, you'll have monthly expenses on - necessities and entertainment. Let's estimate these to see how much - you'll save per month, after all expenses. - """ - ) +def _(mo): + mo.md(""" + In addition to paying for your home, you'll have monthly expenses on + necessities and entertainment. Let's estimate these to see how much + you'll save per month, after all expenses. + """) return @app.cell -def __(mo): +def _(mo): vacation = mo.ui.number(0, 100000, step=100, value=100) groceries = mo.ui.number(0, 100000, step=100, value=100) dining_out = mo.ui.number(0, 100000, step=100, value=100) @@ -273,7 +260,7 @@ def __(mo): @app.cell -def __( +def _( car_payment, clothing, dining_out, @@ -304,7 +291,7 @@ def _row(kind, control): @app.cell -def __( +def _( car_payment, cash_less_housing, clothing, @@ -331,23 +318,23 @@ def __( mo.md( f""" - Your total monthly expenses are **\${monthly_expenses:,.02f}**. + Your total monthly expenses are **${{monthly_expenses:,.02f}}**. - This means you will save **\${annual_cash_saved/12:,.02f}** per month, - or **\${annual_cash_saved:,.02f}** annually. + This means you will save **${{annual_cash_saved/12:,.02f}}** per month, + or **${{annual_cash_saved:,.02f}}** annually. """ ) - return annual_cash_saved, monthly_expenses + return @app.cell -def __(loan): +def _(loan): schedule = loan.schedule()[1:] return (schedule,) @app.cell -def __(np, schedule): +def _(np, schedule): _interest, _principal = zip( *[(payment.interest, payment.principal) for payment in schedule] ) @@ -358,7 +345,7 @@ def __(np, schedule): @app.cell -def __(interest_payments, mo, np, principal_payments, years): +def _(interest_payments, mo, np, principal_payments, years): import matplotlib.pyplot as plt fig, axs = plt.subplots(1, 2) @@ -399,14 +386,15 @@ def __(interest_payments, mo, np, principal_payments, years): {mo.as_html(fig)} """ ) - return axs, fig, plt + return @app.cell -def __(): +def _(): import marimo as mo import mortgage import numpy as np + return mo, mortgage, np diff --git a/examples/misc/movies_by_the_decade.py b/examples/misc/movies_by_the_decade.py index c697ddaf208..ac194b31109 100644 --- a/examples/misc/movies_by_the_decade.py +++ b/examples/misc/movies_by_the_decade.py @@ -10,24 +10,22 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App(width="full") @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - !!! tip "This notebook is best viewed as an app." - If you're editing this notebook and see code cells, hit `Cmd/Ctrl+.` or - click the "app view" button in the bottom right. - """ - ) +def _(mo): + mo.md(r""" + !!! tip "This notebook is best viewed as an app." + If you're editing this notebook and see code cells, hit `Cmd/Ctrl+.` or + click the "app view" button in the bottom right. + """) return @app.cell -def __( +def _( button_00s, button_10s, button_80s, @@ -56,7 +54,7 @@ def __( @app.cell -def __(mo, pd, set_end_date, set_start_date): +def _(mo, pd, set_end_date, set_start_date): def decade_button(decade): s = pd.to_datetime(f"{decade}-01-01") e = pd.to_datetime(f"{decade + 10}-01-01") @@ -75,11 +73,11 @@ def handle_click(v): button_90s = decade_button(1990) button_00s = decade_button(2000) button_10s = decade_button(2010) - return button_00s, button_10s, button_80s, button_90s, decade_button + return button_00s, button_10s, button_80s, button_90s @app.cell -def __(mo, previous_end_date, previous_start_date): +def _(mo, previous_end_date, previous_start_date): mo.md( f""" > Compared to: {previous_start_date.strftime("%Y-%m-%d")} - {previous_end_date.strftime("%Y-%m-%d")} @@ -89,7 +87,7 @@ def __(mo, previous_end_date, previous_start_date): @app.cell -def __(get_end_date, get_start_date, mo, pd, set_end_date, set_start_date): +def _(get_end_date, get_start_date, mo, pd, set_end_date, set_start_date): start_date = mo.ui.date( label="Start Date", value=get_start_date().strftime("%Y-%m-%d"), @@ -104,7 +102,7 @@ def __(get_end_date, get_start_date, mo, pd, set_end_date, set_start_date): @app.cell -def __( +def _( filtered_movies, get_average_budget, get_average_gross, @@ -181,31 +179,11 @@ def __( widths="equal", gap=1, ) - return ( - average_rating, - budget_current, - budget_previous, - budget_rate, - budget_stat, - gross_current, - gross_previous, - gross_rate, - gross_stat, - previous_total_movies_change_rate, - previous_total_movies_count, - rating_current, - rating_previous, - rating_rate, - runtime_current, - runtime_previous, - runtime_rate, - runtime_stat, - total_movies, - ) + return @app.cell -def __(filtered_movies, mo): +def _(filtered_movies, mo): mo.ui.tabs( { "📑 Data": mo.ui.table( @@ -220,7 +198,7 @@ def __(filtered_movies, mo): @app.cell -def __(alt, filtered_movies, mo): +def _(alt, filtered_movies, mo): # chart of rating by budget _chart = ( alt.Chart(filtered_movies) @@ -244,7 +222,7 @@ def __(alt, filtered_movies, mo): @app.cell -def __( +def _( chart, get_average_budget, get_average_gross, @@ -298,7 +276,7 @@ def __( @app.cell -def __(alt, filtered_movies, mo): +def _(alt, filtered_movies, mo): # chart of ratings by genre # colored by decade _bar_chart = ( @@ -313,11 +291,11 @@ def __(alt, filtered_movies, mo): ) bar_chart = mo.ui.altair_chart(_bar_chart) bar_chart - return (bar_chart,) + return @app.cell -def __(datetime): +def _(datetime): def get_average_budget(df, previous): current = df["US_Gross"].mean() previous = previous["US_Gross"].mean() @@ -351,8 +329,8 @@ def get_previous_date_range(start_date, end_date): def format_date(date): return date.strftime("%Y-%m-%d") + return ( - format_date, get_average_budget, get_average_gross, get_average_rating, @@ -362,18 +340,19 @@ def format_date(date): @app.cell -def __(): +def _(): import marimo as mo import vega_datasets as data import time import pandas as pd import datetime import altair as alt - return alt, data, datetime, mo, pd, time + + return alt, data, datetime, mo, pd @app.cell -def __(data, pd): +def _(data, pd): movies = data.data.movies() # convert to date @@ -382,25 +361,18 @@ def __(data, pd): @app.cell -def __(mo, pd): +def _(mo, pd): # min = movies["Release_Date"].min() # max = movies["Release_Date"].max() min = "2010-01-01" max = "2021-01-01" get_start_date, set_start_date = mo.state(pd.to_datetime(min)) get_end_date, set_end_date = mo.state(pd.to_datetime(max)) - return ( - get_end_date, - get_start_date, - max, - min, - set_end_date, - set_start_date, - ) + return get_end_date, get_start_date, set_end_date, set_start_date @app.cell -def __(end_date, get_previous_date_range, movies, pd, start_date): +def _(end_date, get_previous_date_range, movies, pd, start_date): start = pd.to_datetime(start_date.value) end = pd.to_datetime(end_date.value) filtered_movies = movies[ @@ -419,19 +391,12 @@ def __(end_date, get_previous_date_range, movies, pd, start_date): previous_end_date = end previous_movies = filtered_movies return ( - end, filtered_movies, previous_end_date, previous_movies, previous_start_date, - start, ) -@app.cell -def __(): - return - - if __name__ == "__main__": app.run() diff --git a/examples/misc/notebook_dir.py b/examples/misc/notebook_dir.py new file mode 100644 index 00000000000..056e6a8d194 --- /dev/null +++ b/examples/misc/notebook_dir.py @@ -0,0 +1,31 @@ +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "marimo", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + with open(mo.notebook_dir() / ".." / ".." / "pyproject.toml") as f: + contents = f.read() + + mo.plain_text(contents) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/misc/pokemon_stats.py b/examples/misc/pokemon_stats.py index 37951fdaaf8..890ce58001e 100644 --- a/examples/misc/pokemon_stats.py +++ b/examples/misc/pokemon_stats.py @@ -13,18 +13,20 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App(width="full") @app.cell(hide_code=True) -def __(mo): - mo.md("""# Pokémon Statistics 📊🔬""") +def _(mo): + mo.md(""" + # Pokémon Statistics 📊🔬 + """) return @app.cell(hide_code=True) -def __(clear_selection, mo, pokemon_types): +def _(clear_selection, mo, pokemon_types): mo.md( f""" Compare Pokémon by primary type, or drill down into @@ -37,28 +39,28 @@ def __(clear_selection, mo, pokemon_types): @app.cell -def __(pokemon): +def _(pokemon): types = pokemon.groupby(["Type 1"])["#"].count() types_name = list(types.keys()) types_name.sort() - return types, types_name + return (types_name,) @app.cell -def __(mo): +def _(mo): clear_selection = mo.ui.button(label="_Clear selection_") return (clear_selection,) @app.cell -def __(clear_selection, mo, types_name): +def _(clear_selection, mo, types_name): clear_selection pokemon_types = mo.ui.multiselect(types_name) return (pokemon_types,) @app.cell -def __(mo): +def _(mo): attribute = mo.ui.dropdown( { "HP": "HP", @@ -75,13 +77,13 @@ def __(mo): @app.cell -def __(mo, pokemon_types): +def _(mo, pokemon_types): mo.md("`selected types: " + ", ".join(pokemon_types.value) + "`") if pokemon_types.value else None return @app.cell -def __(mo, pokemon_types): +def _(mo, pokemon_types): mo.md( """ **Compare distributions** by type or **drill down** @@ -92,7 +94,7 @@ def __(mo, pokemon_types): @app.cell -def __(pokemon, pokemon_types): +def _(pokemon, pokemon_types): def get_filtered_pokemon(value): if value == "All": return pokemon @@ -102,11 +104,11 @@ def get_filtered_pokemon(value): filtered_pokemons = {} for pokemon_type in pokemon_types.value: filtered_pokemons[pokemon_type] = get_filtered_pokemon(pokemon_type) - return filtered_pokemons, get_filtered_pokemon, pokemon_type + return (filtered_pokemons,) @app.cell -def __(distribution_plot, drilldown, mo, pokemon_types): +def _(distribution_plot, drilldown, mo, pokemon_types): mo.ui.tabs( { "**Compare distributions**": distribution_plot, @@ -117,7 +119,7 @@ def __(distribution_plot, drilldown, mo, pokemon_types): @app.cell -def __(attribute, colors, filtered_pokemons, mo, plt, pokemon_types, sns): +def _(attribute, colors, filtered_pokemons, mo, plt, pokemon_types, sns): def plot(): plt.figure(figsize=(6.5, 4)) if attribute.value is not None: @@ -143,11 +145,11 @@ def plot(): distribution_plot = ( plot() if pokemon_types.value else None ) - return distribution_plot, plot + return (distribution_plot,) @app.cell -def __(filtered_pokemons, mo, pokemon_types): +def _(filtered_pokemons, mo, pokemon_types): def make_table(): records = [ row @@ -158,11 +160,11 @@ def make_table(): table = make_table() if pokemon_types.value else None - return make_table, table + return (table,) @app.cell -def __(mo, plot_pokemon, table): +def _(mo, plot_pokemon, table): _names = [v["Name"] for v in table.value] if table is not None else [] stat_plot = plot_pokemon(_names) @@ -174,11 +176,11 @@ def __(mo, plot_pokemon, table): {mo.hstack([table, stat_plot], justify="start")} """ ) - return drilldown, stat_plot + return (drilldown,) @app.cell -def __(pokemon): +def _(pokemon): def plot_single_pokemon(ax, angles, labels, name): pkmn = pokemon[pokemon.Name == name] @@ -195,11 +197,12 @@ def plot_single_pokemon(ax, angles, labels, name): ] ax.fill(angles, stats, alpha=0.2, label=name) return ax + return (plot_single_pokemon,) @app.cell -def __(np, plot_single_pokemon, plt): +def _(np, plot_single_pokemon, plt): def plot_pokemon(names): fig, ax = plt.subplots(figsize=(6, 6), subplot_kw=dict(polar=True)) labels = np.array( @@ -215,11 +218,12 @@ def plot_pokemon(names): if names: plt.legend(loc="upper left") return ax + return (plot_pokemon,) @app.cell -def __(): +def _(): import marimo as mo import numpy as np import pandas as pd @@ -233,11 +237,11 @@ def __(): _downloaded = requests.get("https://gist.githubusercontent.com/armgilles/194bcff35001e7eb53a2a8b441e8b2c6/raw/92200bc0a673d5ce2110aaad4544ed6c4010f687/pokemon.csv").content pokemon = pd.read_csv(io.BytesIO(_downloaded), encoding="utf8") pokemon = pokemon.drop(["Legendary", "Generation"], axis=1) - return io, mo, np, os, pd, plt, pokemon, requests, sns + return mo, np, plt, pokemon, sns @app.cell -def __(): +def _(): # Defining colors for graphs colors = { "Bug": "#A6B91A", @@ -263,9 +267,10 @@ def __(): @app.cell -def __(): +def _(): import plotly.express as px - return (px,) + + return if __name__ == "__main__": diff --git a/examples/misc/public/marimos.webp b/examples/misc/public/marimos.webp new file mode 100644 index 00000000000..661950bd873 Binary files /dev/null and b/examples/misc/public/marimos.webp differ diff --git a/examples/misc/public_folder.py b/examples/misc/public_folder.py new file mode 100644 index 00000000000..cd6d35cb8fc --- /dev/null +++ b/examples/misc/public_folder.py @@ -0,0 +1,27 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(mo): + mo.md(r""" + Load images under the public folder. This will search for files in the /public folder next to the notebook. + + + + + """) + return + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/reactive_plots.py b/examples/misc/reactive_plots.py similarity index 84% rename from examples/ui/reactive_plots.py rename to examples/misc/reactive_plots.py index e8287a6636f..393fe6f2693 100644 --- a/examples/ui/reactive_plots.py +++ b/examples/misc/reactive_plots.py @@ -9,31 +9,33 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App(width="full") @app.cell(hide_code=True) -def __(mo): - mo.md("""# Welcome to marimo!""") +def _(mo): + mo.md(""" + # Welcome to marimo! + """) return @app.cell -def __(bars, mo, scatter): +def _(bars, mo, scatter): chart = mo.ui.altair_chart(scatter & bars) chart return (chart,) @app.cell -def __(chart, mo): +def _(chart, mo): (filtered_data := mo.ui.table(chart.value)) return (filtered_data,) @app.cell -def __(alt, filtered_data, mo): +def _(alt, filtered_data, mo): mo.stop(not len(filtered_data.value)) mpg_hist = mo.ui.altair_chart( alt.Chart(filtered_data.value) @@ -46,11 +48,11 @@ def __(alt, filtered_data, mo): .encode(alt.X("Horsepower:Q", bin=True), y="count()") ) mo.hstack([mpg_hist, horsepower_hist], justify="space-around", widths="equal") - return horsepower_hist, mpg_hist + return @app.cell -def __(alt, data): +def _(alt, data): cars = data.cars() brush = alt.selection_interval() scatter = ( @@ -69,19 +71,21 @@ def __(alt, data): .encode(y="Origin:N", color="Origin:N", x="count(Origin):Q") .transform_filter(brush) ) - return bars, brush, cars, scatter + return bars, scatter @app.cell -def __(): +def _(): import altair as alt from vega_datasets import data + return alt, data @app.cell -def __(): +def _(): import marimo as mo + return (mo,) diff --git a/examples/misc/seam_carving.py b/examples/misc/seam_carving.py index ee668f13e59..3bbb4e80fca 100644 --- a/examples/misc/seam_carving.py +++ b/examples/misc/seam_carving.py @@ -11,41 +11,39 @@ import marimo -__generated_with = "0.9.6" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - # Seam Carving +def _(mo): + mo.md(""" + # Seam Carving - _Example adapted from work by [Vincent Warmerdam](https://x.com/fishnets88)_. + _Example adapted from work by [Vincent Warmerdam](https://x.com/fishnets88)_. - ## The seam carving algorithm - This marimo demonstration is partially an homage to [a great video by Grant - Sanderson](https://www.youtube.com/watch?v=rpB6zQNsbQU) of 3Blue1Brown, which demonstrates - the seam carving algorithm in [Pluto.jl](https://plutojl.org/): + ## The seam carving algorithm + This marimo demonstration is partially an homage to [a great video by Grant + Sanderson](https://www.youtube.com/watch?v=rpB6zQNsbQU) of 3Blue1Brown, which demonstrates + the seam carving algorithm in [Pluto.jl](https://plutojl.org/): - + - As Grant explains, the seam carving algorithm preserves the shapes of the main content in the image, while killing the "dead space": the image is resized, but the clocks and other content are not resized or deformed. + As Grant explains, the seam carving algorithm preserves the shapes of the main content in the image, while killing the "dead space": the image is resized, but the clocks and other content are not resized or deformed. - This notebook is a Python version of the seam carving algorithm, but it is also a - demonstration of marimo's [persistent caching - feature](https://docs.marimo.io/recipes.html#persistent-caching-for-very-expensive-computations), - which is helpful because the algorithm is compute intensive even when you - use [Numba](https://numba.pydata.org/). + This notebook is a Python version of the seam carving algorithm, but it is also a + demonstration of marimo's [persistent caching + feature](https://docs.marimo.io/recipes.html#persistent-caching-for-very-expensive-computations), + which is helpful because the algorithm is compute intensive even when you + use [Numba](https://numba.pydata.org/). - Try it out by playing with the slider! - """ - ) + Try it out by playing with the slider! + """) return @app.cell(hide_code=True) -def __(): +def _(): import requests input_image = "The_Persistence_of_Memory.jpg" @@ -55,17 +53,19 @@ def __(): with open(input_image, "wb") as handler: handler.write(img_data) - return handler, img_data, input_image, requests + return (input_image,) @app.cell(hide_code=True) -def __(mo): - mo.md("""## Try it!""") +def _(mo): + mo.md(""" + ## Try it! + """) return @app.cell -def __(): +def _(): import marimo as mo slider = mo.ui.slider( @@ -81,17 +81,17 @@ def __(): @app.cell -def __(efficient_seam_carve, input_image, mo, slider): +def _(efficient_seam_carve, input_image, mo, slider): with mo.persistent_cache("seam_carves"): scale_factor = slider.value result = efficient_seam_carve(input_image, scale_factor) mo.hstack([mo.image(input_image), mo.image(result)], justify="start") - return result, scale_factor + return @app.cell -def __(): +def _(): import numpy as np from numba import jit from skimage import io, filters, transform @@ -170,20 +170,8 @@ def efficient_seam_carve(image_path, scale_factor): print(f"Seam carving completed in {end_time - start_time:.2f} seconds") return carved_img - return ( - compute_energy_map, - efficient_seam_carve, - filters, - find_seam, - io, - jit, - np, - remove_seam, - rgb2gray, - seam_carving, - time, - transform, - ) + + return (efficient_seam_carve,) if __name__ == "__main__": diff --git a/examples/ui/task_list.py b/examples/misc/task_list.py similarity index 82% rename from examples/ui/task_list.py rename to examples/misc/task_list.py index 72fffe819c8..a1f0b2fcb85 100644 --- a/examples/ui/task_list.py +++ b/examples/misc/task_list.py @@ -7,34 +7,35 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App() @app.cell(hide_code=True) -def __(mo): +def _(mo): mo.md("# Task List").left() return @app.cell -def __(dataclass): +def _(dataclass): @dataclass class Task: name: str done: bool = False + return (Task,) @app.cell -def __(mo): +def _(mo): get_tasks, set_tasks = mo.state([]) mutation_signal, set_mutation_signal = mo.state(False) return get_tasks, mutation_signal, set_mutation_signal, set_tasks @app.cell -def __(mo, mutation_signal): +def _(mo, mutation_signal): mutation_signal task_entry_box = mo.ui.text(placeholder="a task ...") @@ -42,7 +43,7 @@ def __(mo, mutation_signal): @app.cell -def __(Task, mo, set_mutation_signal, set_tasks, task_entry_box): +def _(Task, mo, set_mutation_signal, set_tasks, task_entry_box): def add_task(): if task_entry_box.value: set_tasks(lambda v: v + [Task(task_entry_box.value)]) @@ -60,11 +61,11 @@ def add_task(): lambda v: [task for task in v if not task.done] ), ) - return add_task, add_task_button, clear_tasks_button + return add_task_button, clear_tasks_button @app.cell -def __(add_task_button, clear_tasks_button, mo, task_entry_box): +def _(add_task_button, clear_tasks_button, mo, task_entry_box): mo.hstack( [task_entry_box, add_task_button, clear_tasks_button], justify="start" ) @@ -72,7 +73,7 @@ def __(add_task_button, clear_tasks_button, mo, task_entry_box): @app.cell -def __(Task, get_tasks, mo, set_tasks): +def _(Task, get_tasks, mo, set_tasks): task_list = mo.ui.array( [mo.ui.checkbox(value=task.done, label=task.name) for task in get_tasks()], label="tasks", @@ -84,20 +85,22 @@ def __(Task, get_tasks, mo, set_tasks): @app.cell -def __(mo, task_list): +def _(mo, task_list): mo.as_html(task_list) if task_list.value else mo.md("No tasks! 🎉") return @app.cell -def __(): +def _(): import marimo as mo + return (mo,) @app.cell -def __(): +def _(): from dataclasses import dataclass + return (dataclass,) diff --git a/examples/outputs/accordion.py b/examples/outputs/accordion.py new file mode 100644 index 00000000000..fd54aeb486d --- /dev/null +++ b/examples/outputs/accordion.py @@ -0,0 +1,29 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + mo.accordion( + { + "Door 1": mo.md("Nothing!"), + "Door 2": mo.md("Nothing!"), + "Door 3": mo.md( + "![goat](https://images.unsplash.com/photo-1524024973431-2ad916746881)" + ), + } + ) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/outputs/audio.py b/examples/outputs/audio.py new file mode 100644 index 00000000000..3af9c6c48f8 --- /dev/null +++ b/examples/outputs/audio.py @@ -0,0 +1,6 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "marimo", +# ] +# /// diff --git a/examples/outputs/basic_markdown.py b/examples/outputs/basic_markdown.py new file mode 100644 index 00000000000..7218c3f0624 --- /dev/null +++ b/examples/outputs/basic_markdown.py @@ -0,0 +1,28 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + Create a "Markdown" cell by clicking the `Markdown` button below, + or through the cell action menu. + + Markdown is represented as Python under-the-hood, using the `mo.md()` + function — so you'll need to import marimo as mo into your notebook + first! + """) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/outputs/capture_console_outputs.py b/examples/outputs/capture_console_outputs.py new file mode 100644 index 00000000000..c36a30f67fc --- /dev/null +++ b/examples/outputs/capture_console_outputs.py @@ -0,0 +1,24 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + with mo.capture_stdout() as output: + print("Hello, world") + + mo.md(output.getvalue()) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/outputs/cell_output.py b/examples/outputs/cell_output.py new file mode 100644 index 00000000000..32feb918887 --- /dev/null +++ b/examples/outputs/cell_output.py @@ -0,0 +1,36 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(mo): + mo.md(""" + The last expression of a cell is its visual output. This output + appears above the cell when editing a notebook, with notebook code + serving as a "caption" for the output. Outputs can be configured + to appear below cells in the user settings. + + If running + a notebook as an app, the output is the visual representation + of the cell (code is hidden by default). + """) + return + + +@app.cell +def _(): + "Hello, world!" + return + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +if __name__ == "__main__": + app.run() diff --git a/examples/outputs/conditional_output.py b/examples/outputs/conditional_output.py new file mode 100644 index 00000000000..717b91485ad --- /dev/null +++ b/examples/outputs/conditional_output.py @@ -0,0 +1,77 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + checkbox = mo.ui.checkbox() + checkbox + return (checkbox,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + Use inline if expressions to conditionally show a value + """) + return + + +@app.cell +def _(checkbox): + "Checkbox is checked" if checkbox.value else "Checkbox is not checked" + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + The following cell would **not** show anything, since an if statement does + not have a value: + + ```python + # Intentionally demonstrates that if statements don't display expressions + # Using _ to suppress the lint warning while keeping the example + if checkbox.value: + mo.md("Checkbox is checked") + else: + mo.md("Checkbox is not checked") + ``` + """) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + A value of `None` produces the empty output: + """) + return + + +@app.cell +def _(checkbox): + checkbox + return + + +@app.cell +def _(checkbox, mo): + _output = None + if checkbox.value: + _output = mo.md("Checkbox is checked.") + _output + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/outputs/console_outputs.py b/examples/outputs/console_outputs.py new file mode 100644 index 00000000000..a669fc2cbb9 --- /dev/null +++ b/examples/outputs/console_outputs.py @@ -0,0 +1,27 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + print("This is a console output") + print("Notice that it's below the cell.") + print("You can configure where outputs show up in your user configuration.") + + mo.md( + "This is a cell output. Console outputs show up below a cell; cell outputs show up above." + ) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/outputs/dataframes.py b/examples/outputs/dataframes.py new file mode 100644 index 00000000000..739bdcafbff --- /dev/null +++ b/examples/outputs/dataframes.py @@ -0,0 +1,56 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "marimo", +# "vega-datasets==0.9.0", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(): + from vega_datasets import data + + return (data,) + + +@app.cell +def _(data): + df = data.cars() + return (df,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + marimo has a rich dataframe viewer built-in: + + - built-in global search + - per-column sorting and filtering + - per-column histograms + - download filtered views + - paginate through the whole dataframe + """) + return + + +@app.cell +def _(df): + df + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/outputs/live_raster.py b/examples/outputs/live_raster.py new file mode 100644 index 00000000000..29e7f3be859 --- /dev/null +++ b/examples/outputs/live_raster.py @@ -0,0 +1,95 @@ +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "anywidget==0.9.21", +# "marimo>=0.20.2", +# "traitlets==5.14.3", +# ] +# /// +import marimo + +__generated_with = "unknown" +app = marimo.App(width="medium") + + +@app.cell(hide_code=True) +def _(CounterWidget): + CounterWidget(count=42) + return + + +@app.cell +def _(anywidget, os, traitlets): + class CounterWidget(anywidget.AnyWidget): + _esm = """ + export default async () => { + let hostName = null; + + return { + initialize({ model }) { + // This message gets handled by _handle_custom_msg on the Python side + model.send({ event: "requestHostName" }); + }, + + render({ model, el }) { + let count = () => model.get("count"); + let btn = document.createElement("button"); + btn.classList.add("counter-button"); + btn.innerHTML = `Initializing...`; + + // Set proper HTML content once message arrives from Python connection + model.on("msg:custom", (msg, buffers) => { + hostName = msg.response; + btn.innerHTML = `count is ${count()} from ${hostName} host`; + }); + + btn.addEventListener("click", () => { + model.set("count", count() + 1); + model.save_changes(); + }); + + model.on("change:count", () => { + btn.innerHTML = + hostName + ? `count is ${count()} from ${hostName} host` + : `Initializing...`; + }); + + el.appendChild(btn); + }, + }; + }; + """ + _css = """ + .counter-button { + background: #387262; + border: 0; + border-radius: 10px; + padding: 10px 50px; + color: white; + } + """ + count = traitlets.Int(0).tag(sync=True) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.on_msg(self._handle_custom_msg) + + def _handle_custom_msg(self, *args, **kwargs): + self.send({"response": os.name}) + + return (CounterWidget,) + + +@app.cell(hide_code=True) +def _(): + import marimo as mo + import anywidget + import traitlets + import os + + return anywidget, os, traitlets + + +if __name__ == "__main__": + app.run() diff --git a/examples/outputs/plots.py b/examples/outputs/plots.py new file mode 100644 index 00000000000..76241568108 --- /dev/null +++ b/examples/outputs/plots.py @@ -0,0 +1,90 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "marimo", +# "matplotlib==3.10.1", +# "numpy==2.2.4", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(): + import matplotlib.pyplot as plt + + return (plt,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + Output a plot object, such as an axis or figure, to see the plot. + """) + return + + +@app.cell +def _(plt): + import numpy as np + + x = np.linspace(0, 10) + plt.plot(x, x**2) + plt.gca() + return (x,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + Calling `show()` methods displays the plot in the console area, which can be + helpful for debugging because console outputs do not show up in the "app" preview. + """) + return + + +@app.cell +def _(plt, x): + plt.plot(x, x**3) + plt.show() + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + Wrap an `Axes` in `mo.ui.matplotlib` to enable reactive selections. + Click-drag for box selection, shift-drag for lasso selection. + """) + return + + +@app.cell +def _(mo, np, plt): + _x = np.random.randn(200) + _y = np.random.randn(200) + plt.scatter(_x, _y) + scatter_fig = mo.ui.matplotlib(plt.gca()) + scatter_fig + return (scatter_fig,) + + +@app.cell +def _(scatter_fig): + scatter_fig.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/outputs/progress_bar.py b/examples/outputs/progress_bar.py new file mode 100644 index 00000000000..21c1d007eb4 --- /dev/null +++ b/examples/outputs/progress_bar.py @@ -0,0 +1,54 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + import asyncio + + return asyncio, mo + + +@app.cell +def _(mo): + rerun = mo.ui.button(label="Rerun") + rerun + return (rerun,) + + +@app.cell +async def _(asyncio, mo, rerun): + rerun + for _ in mo.status.progress_bar( + range(10), + title="Loading", + subtitle="Please wait", + show_eta=True, + show_rate=True, + ): + await asyncio.sleep(0.5) + return + + +@app.cell +def _(mo): + rerun_slow = mo.ui.button(label="Rerun Slow") + rerun_slow + return (rerun_slow,) + + +@app.cell +async def _(asyncio, mo, rerun_slow): + rerun_slow + for _ in mo.status.progress_bar( + range(2), title="Loading", subtitle="Please wait", show_eta=True, show_rate=True + ): + await asyncio.sleep(12) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/outputs/showing_multiple_outputs.py b/examples/outputs/showing_multiple_outputs.py new file mode 100644 index 00000000000..ec3f73eb45a --- /dev/null +++ b/examples/outputs/showing_multiple_outputs.py @@ -0,0 +1,24 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + for i in range(3): + mo.output.append(mo.md(f"$i = {i}$")) + + mo.output.append(mo.md("Completed iteration.")) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/outputs/spinner.py b/examples/outputs/spinner.py new file mode 100644 index 00000000000..1308bec6b8f --- /dev/null +++ b/examples/outputs/spinner.py @@ -0,0 +1,35 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + rerun = mo.ui.button(label="Rerun") + rerun + return (rerun,) + + +@app.cell +async def _(mo, rerun): + import asyncio + + rerun + with mo.status.spinner(title="Loading...") as _spinner: + await asyncio.sleep(1) + _spinner.update("Almost done") + await asyncio.sleep(1) + _spinner.update("Done") + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/outputs/stacks.py b/examples/outputs/stacks.py new file mode 100644 index 00000000000..98bbd4e9978 --- /dev/null +++ b/examples/outputs/stacks.py @@ -0,0 +1,56 @@ +import marimo + +__generated_with = "0.19.11" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + t = mo.ui.date() + n = mo.ui.slider(1, 10) + return n, t + + +@app.cell +def _(mo, n, t): + mo.hstack([t, n], justify="start") + return + + +@app.cell +def _(mo, n, t): + mo.vstack([t, n]) + return + + +@app.cell +def _(mo): + mo.hstack( + [ + mo.stat(value=1, bordered=True), + mo.stat(value=2, bordered=True), + mo.stat(value=3, bordered=True), + ], + widths="equal", + ) + return + + +@app.cell +def _(mo): + text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Name faucibus risus in feugiat pharetra. Praesent vel ex nibh. " + q = mo.vstack([mo.md(text)] * 5) + s = mo.vstack([mo.md(str(0.5))] * 5, align="end", justify="space-between") + mo.hstack([q, s], widths=[5, 1]) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/running_as_a_script/sharing_arguments.py b/examples/running_as_a_script/sharing_arguments.py new file mode 100644 index 00000000000..7ba35798f71 --- /dev/null +++ b/examples/running_as_a_script/sharing_arguments.py @@ -0,0 +1,73 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(): + import argparse + + return (argparse,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + This notebook shows how to parametrize a notebook with optional command-line arguments. + + Run the notebook with + + ```bash + marimo edit sharing_arguments.py + ``` + + or + + ```bash + marimo edit sharing_arguments.py -- -learning_rate=1e-3 + ``` + + (Note the `--` separating the filename from the arguments.) + + or + + ```bash + python sharing_arguments.py -learning_rate=1e-3 + ``` + + See help for the notebook's arguments with + + ```python + python sharing_arguments.py --help + ``` + """) + return + + +@app.cell +def _(mo): + default = mo.ui.number(1000, step=100) + default + return (default,) + + +@app.cell +def _(argparse, default): + parser = argparse.ArgumentParser() + + parser.add_argument("-iterations", default=default.value) + args = parser.parse_args() + print(args.iterations) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/running_as_a_script/textual_app.py b/examples/running_as_a_script/textual_app.py new file mode 100644 index 00000000000..67600a3266b --- /dev/null +++ b/examples/running_as_a_script/textual_app.py @@ -0,0 +1,57 @@ +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "marimo", +# "textual==3.2.0", +# ] +# /// +""" +Marimo wrapper for the base Textual app example. +Provides a basic clock widget to show the current time. +run with `python textual_app.py` +""" + +import marimo + +__generated_with = "0.15.5" +app = marimo.App() + +with app.setup: + import marimo as mo + from datetime import datetime + + from textual.app import App, ComposeResult + from textual.widgets import Digits + + +@app.class_definition +class ClockApp(App): + CSS = """ + Screen { align: center middle; } + Digits { width: auto; } + """ + + def compose(self) -> ComposeResult: + yield Digits("") + + def on_ready(self) -> None: + self.update_clock() + self.set_interval(1, self.update_clock) + + def update_clock(self) -> None: + clock = datetime.now().time() + self.query_one(Digits).update(f"{clock:%T}") + + +@app.cell +def _(): + app = ClockApp() + if mo.app_meta().mode == "script": + app.run() + # App gives back a basic repr + app + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/running_as_a_script/with_argparse.py b/examples/running_as_a_script/with_argparse.py new file mode 100644 index 00000000000..a2f62af8b2b --- /dev/null +++ b/examples/running_as_a_script/with_argparse.py @@ -0,0 +1,58 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(): + import argparse + + return (argparse,) + + +@app.cell +def _(argparse): + parser = argparse.ArgumentParser( + description="This notebook shows how to use argparse with marimo", + ) + + parser.add_argument("filename") + parser.add_argument("-c", "--count", type=int) + parser.add_argument("-v", "--verbose", action="store_true") + return (parser,) + + +@app.cell +def _(mo, parser): + def parse_args(): + if mo.running_in_notebook(): + # set default values for the command-line arguments when running as a notebook + filename = "your default value" + count = 42 + verbose = True + else: + args = parser.parse_args() + filename = args.filename + count = args.count + verbose = args.verbose + return filename, count, verbose + + return (parse_args,) + + +@app.cell +def _(parse_args): + print(parse_args()) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/running_as_a_script/with_simple_parsing.py b/examples/running_as_a_script/with_simple_parsing.py new file mode 100644 index 00000000000..821eedc5a02 --- /dev/null +++ b/examples/running_as_a_script/with_simple_parsing.py @@ -0,0 +1,72 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "marimo", +# "simple-parsing==0.1.7", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(): + import simple_parsing + + return + + +@app.cell +def _(): + from dataclasses import dataclass + from simple_parsing import ArgumentParser + + parser = ArgumentParser() + parser.add_argument("--foo", type=int, default=123, help="foo help") + + + @dataclass + class Options: + """Help string for this group of command-line arguments.""" + + log_dir: str # Help string for a required str argument + learning_rate: float = 1e-4 # Help string for a float argument + + parser.add_arguments(Options, dest="options") + return Options, parser + + +@app.cell +def _(Options, mo, parser): + from dataclasses import fields + + def parse_args(): + if mo.running_in_notebook(): + # set default values for the command-line arguments when running as a notebook + return "foo default", Options("logs/", 1e-4) + else: + args = parser.parse_args() + return args.foo, args.options + + return (parse_args,) + + +@app.cell +def _(parse_args): + foo, options = parse_args() + print(foo, options) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/running_cells/async_await.py b/examples/running_cells/async_await.py new file mode 100644 index 00000000000..0434e962244 --- /dev/null +++ b/examples/running_cells/async_await.py @@ -0,0 +1,29 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return + + +@app.function +async def wait_for(seconds): + import asyncio + print(f"Waiting for {seconds} seconds ...") + await asyncio.sleep(seconds) + print("Done!") + + +@app.cell +async def _(): + await wait_for(1) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/running_cells/basics.py b/examples/running_cells/basics.py new file mode 100644 index 00000000000..ea0ce5be6f4 --- /dev/null +++ b/examples/running_cells/basics.py @@ -0,0 +1,47 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + marimo knows how your cells are related, and can automatically update + outputs like a spreadsheet. This eliminates hidden state and hidden bugs, accelerates data exploration, + and makes it possible for marimo to run your notebooks as scripts and web apps. + For expensive notebooks, you can [turn this + behavior off](https://docs.marimo.io/guides/expensive_notebooks/) via the notebook footer. + + Try updating the values of variables below and see what happens! You can also try deleting a cell. + """) + return + + +@app.cell +def _(): + x = 0 + return (x,) + + +@app.cell +def _(): + y = 1 + return + + +@app.cell +def _(x): + x + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/running_cells/debugging.py b/examples/running_cells/debugging.py new file mode 100644 index 00000000000..d80e33b5261 --- /dev/null +++ b/examples/running_cells/debugging.py @@ -0,0 +1,20 @@ +import marimo + +__generated_with = "0.15.5" +app = marimo.App() + + +@app.function +def foo(x): + breakpoint() + return 1 / x + + +@app.cell +def _(): + foo(1) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/running_cells/in_memory_cache.py b/examples/running_cells/in_memory_cache.py new file mode 100644 index 00000000000..e398551cd5d --- /dev/null +++ b/examples/running_cells/in_memory_cache.py @@ -0,0 +1,47 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(app_title="In Memory Cache") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + @mo.cache + def sleep_for(t: int): + import time + + print("Sleeping") + time.sleep(t) + return t + + return (sleep_for,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + Use `mo.cache` to cache the outputs of expensive functions. The first + time the function is called with unseen arguments, it will execute and + return the computed value. Subsequent calls with the same arguments will + return cached results. + + Experiment with the invocation below to get a feel for how this works. + """) + return + + +@app.cell +def _(sleep_for): + sleep_for(1) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/running_cells/multiple_definitions.py b/examples/running_cells/multiple_definitions.py new file mode 100644 index 00000000000..b39bc770b9b --- /dev/null +++ b/examples/running_cells/multiple_definitions.py @@ -0,0 +1,82 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "marimo", +# "matplotlib==3.10.1", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ## Use local variables + + Variables prefixed with an underscore are local to a cell, and can be redefined. + """) + return + + +@app.cell +def _(): + for _i in range(3): + print(_i) + return + + +@app.cell +def _(): + for _i in range(4, 6): + print(_i) + return + + +@app.cell +def _(): + # _i is not defined in this cell + _i + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ## Wrap code in functions + + Wrap cells in functions to minimize the number of temporary globals you introduce. + """) + return + + +@app.cell +def _(): + import matplotlib.pyplot as plt + + return (plt,) + + +@app.cell +def _(plt): + def _(): + fig, ax = plt.subplots() + plt.plot([1, 2]) + return ax + + _() + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/running_cells/persistent_cache.py b/examples/running_cells/persistent_cache.py new file mode 100644 index 00000000000..60005e5f7ee --- /dev/null +++ b/examples/running_cells/persistent_cache.py @@ -0,0 +1,47 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(app_title="In Memory Cache") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + @mo.persistent_cache + def sleep_for(t: int): + import time + + print("Sleeping") + time.sleep(t) + return t + + return (sleep_for,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + Use `mo.persistent_cache` to cache the outputs of expensive computations to persistent storage. The first + time the function is called with unseen arguments, it will execute and + return the computed value. Subsequent calls with the same arguments will + return cached results. + + Experiment with the invocation below to get a feel for how this works. + """) + return + + +@app.cell +def _(sleep_for): + sleep_for(1) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/sql/README.md b/examples/sql/README.md index b9c19611c4c..5d3d3887d8e 100644 --- a/examples/sql/README.md +++ b/examples/sql/README.md @@ -28,14 +28,14 @@ comprehensive guide on duckdb. The requirements of each notebook are serialized in them as a top-level comment. Here are the steps to open an example notebook: -1. [Install marimo](https://docs.marimo.io/getting_started/index.html#installation) -2. [Install `uv`](https://github.com/astral-sh/uv/?tab=readme-ov-file#installation) -3. Open an example with `marimo edit --sandbox `. - +1. [Install `uv`](https://github.com/astral-sh/uv/?tab=readme-ov-file#installation) +2. Open an example with `uvx marimo edit --sandbox ` > [!TIP] > The [`--sandbox` flag](https://docs.marimo.io/guides/editor_features/package_management.html) opens the notebook in an isolated virtual environment, > automatically installing the notebook's dependencies 📦 -You can also open notebooks without `uv`, with just `marimo edit `; -however, you'll need to install the requirements yourself. +You can also open notebooks without `uv`, in which case you'll need to +manually [install marimo](https://docs.marimo.io/getting_started/index.html#installation) +first. Then run `marimo edit `; however, you'll also need to +install the requirements yourself. diff --git a/examples/sql/connect_to_motherduck.py b/examples/sql/connect_to_motherduck.py index e5d777e0d36..c0a3b9b766e 100644 --- a/examples/sql/connect_to_motherduck.py +++ b/examples/sql/connect_to_motherduck.py @@ -3,33 +3,32 @@ # dependencies = [ # "altair==5.4.1", # "duckdb==1.1.0", +# "polars==1.18.0", +# "pyarrow==18.1.0", # "marimo", -# "pandas==2.2.3", # ] # /// import marimo -__generated_with = "0.9.10" +__generated_with = "0.17.4" app = marimo.App(width="medium") @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - # MotherDuck 🧡 marimo +def _(mo): + mo.md(r""" + # MotherDuck 🧡 marimo - Throughout this notebook, we will explore using [MotherDuck](https://motherduck.com) inside marimo. If you’re new to marimo, check out our [GitHub](https://github.com/marimo-team/marimo) repo: marimo is free and open source. + Throughout this notebook, we will explore using [MotherDuck](https://motherduck.com) inside marimo. If you’re new to marimo, check out our [GitHub](https://github.com/marimo-team/marimo) repo: marimo is free and open source. - _You can expand the code of any cells to see how the output are being created._ - """ - ) + _You can expand the code of any cells to see how the output are being created._ + """) return @app.cell(hide_code=True) -def __(__file__, md_token, mo): +def _(md_token, mo): callout = mo.md(f""" There is no **MotherDuck** token found in your environment. To set one up, go to the [MotherDuck's settings page](https://app.motherduck.com/settings/general), create a token, and copy it below. And re-run this notebook: @@ -43,27 +42,29 @@ def __(__file__, md_token, mo): mo.output.replace( mo.accordion({"Tired of logging in to MotherDuck?": callout}) ) - return (callout,) + return @app.cell(hide_code=True) -def __(): +def _(): import os md_token = os.environ.get("motherduck_token") or os.environ.get( "MOTHERDUCK_TOKEN" ) - return md_token, os + return (md_token,) @app.cell(hide_code=True) -def __(mo): - mo.md("""Let's attach a remote MotherDuck database using `md:`""") +def _(mo): + mo.md(""" + Let's attach a remote MotherDuck database using `md:` + """) return @app.cell -def __(): +def _(): import duckdb import marimo as mo @@ -72,28 +73,28 @@ def __(): ) # or add your own md instance # duckdb.sql(f"ATTACH IF NOT EXISTS 'md:sample_data'") - return duckdb, mo, sample_data + return duckdb, mo @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - !!! tip "Explore data sources" - If you open the "Explore data sources" panel on the left side bar (3rd icon), you will see all your tables including any news ones we will create below - """ - ) +def _(mo): + mo.md(""" + !!! tip "Explore data sources" + If you open the "Explore data sources" panel on the left side bar (3rd icon), you will see all your tables including any news ones we will create below + """) return @app.cell -def __(mo): - mo.md(r"""## Let's make some queries 🦆""") +def _(mo): + mo.md(r""" + ## Let's make some queries 🦆 + """) return @app.cell -def __(mo): +def _(mo): most_shared_websites = mo.sql( f""" -- Most shared websites @@ -115,7 +116,7 @@ def __(mo): @app.cell -def __(hacker_news, mo, ranked_stories, sample_data): +def _(mo): most_commented_stories_each_month = mo.sql( f""" -- Most Commented Stories Each Month @@ -151,23 +152,21 @@ def __(hacker_news, mo, ranked_stories, sample_data): -- Which can be used in Python to create charts """ ) - return (most_commented_stories_each_month,) + return @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - ## Let's make some charts 📈 +def _(mo): + mo.md(r""" + ## Let's make some charts 📈 - Now that we have made some queries and named the results, we can chart those resulting dataframes in Python, using our favorite charting libraries (e.g [altair](https://altair-viz.github.io/), [matplotlib](https://matplotlib.org/), or [plotly](https://plotly.com/)). - """ - ) + Now that we have made some queries and named the results, we can chart those resulting dataframes in Python, using our favorite charting libraries (e.g [altair](https://altair-viz.github.io/), [matplotlib](https://matplotlib.org/), or [plotly](https://plotly.com/)). + """) return @app.cell -def __(most_shared_websites): +def _(most_shared_websites): import altair as alt chart = ( @@ -184,23 +183,21 @@ def __(most_shared_websites): ) chart - return alt, chart + return (alt,) @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - ## Adding reactivity ⚡ +def _(mo): + mo.md(r""" + ## Adding reactivity ⚡ - We can also parameterize our SQL using marimo UI elements. This not only makes our SQL reactive, but also any downstream logic, including our charts. - """ - ) + We can also parameterize our SQL using marimo UI elements. This not only makes our SQL reactive, but also any downstream logic, including our charts. + """) return @app.cell -def __(MONTHS, duckdb, hacker_news, mo, sample_data): +def _(MONTHS, duckdb, mo): month_select = mo.ui.multiselect( MONTHS, label="Month", @@ -211,17 +208,17 @@ def __(MONTHS, duckdb, hacker_news, mo, sample_data): """ SELECT DISTINCT type as 'HN Type' FROM sample_data.hn.hacker_news - WHERE score NOT NULL AND descendants NOT NULL + WHERE score IS NOT NULL AND descendants IS NOT NULL LIMIT 10; """ ).df() hn_type_select = mo.ui.dropdown.from_series(hn_types["HN Type"], value="story") - return hn_type_select, hn_types, month_select + return hn_type_select, month_select @app.cell(hide_code=True) -def __(hn_type_select, mo, month_select): +def _(hn_type_select, mo, month_select): month_list = ",".join([str(month) for month in month_select.value]) mo.hstack( [ @@ -234,14 +231,7 @@ def __(hn_type_select, mo, month_select): @app.cell(hide_code=True) -def __( - hacker_news, - hn_type_select, - mo, - month_list, - ranked_stories, - sample_data, -): +def _(hn_type_select, mo, month_list): most_monthly_voted = mo.sql( f""" -- Most monthly voted @@ -265,8 +255,7 @@ def __( AND MONTH(timestamp) in ({month_list}) AND - descendants NOT NULl - + descendants IS NOT NULL ) SELECT @@ -286,7 +275,7 @@ def __( @app.cell(hide_code=True) -def __(alt, hn_type_select, most_monthly_voted): +def _(alt, hn_type_select, most_monthly_voted): _chart = ( alt.Chart(most_monthly_voted) .mark_circle() @@ -312,20 +301,22 @@ def __(alt, hn_type_select, most_monthly_voted): @app.cell(hide_code=True) -def __(mo): - mo.md("""## Additional Reactivity ⚡⚡""") +def _(mo): + mo.md(""" + ## Additional Reactivity ⚡⚡ + """) return @app.cell -def __(mo): +def _(mo): search_input = mo.ui.text(label="Search for keywords", value="duckdb") search_input return (search_input,) @app.cell -def __(hacker_news, mo, sample_data, search_value): +def _(mo, search_value): keyword_results = mo.sql( f""" SELECT @@ -343,13 +334,13 @@ def __(hacker_news, mo, sample_data, search_value): @app.cell(hide_code=True) -def __(search_input): +def _(search_input): search_value = search_input.value return (search_value,) @app.cell(hide_code=True) -def __(alt, keyword_results, mo, search_value): +def _(alt, keyword_results, mo, search_value): if keyword_results.is_empty(): mo.stop(True, f"No results for {search_value}") @@ -385,7 +376,7 @@ def __(alt, keyword_results, mo, search_value): @app.cell(hide_code=True) -def __(): +def _(): MONTHS = { "January": 1, "February": 2, diff --git a/examples/sql/connect_to_persistent_db.py b/examples/sql/connect_to_persistent_db.py index e1a02674cbb..ac4f6cef9bb 100644 --- a/examples/sql/connect_to_persistent_db.py +++ b/examples/sql/connect_to_persistent_db.py @@ -1,34 +1,37 @@ import marimo -__generated_with = "0.9.16" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell -def __(): +def _(): import marimo as mo + return (mo,) @app.cell(hide_code=True) -def __(mo): - mo.md("""Connect to duckdb [persistent storage](https://duckdb.org/docs/connect/overview.html#persistent-database) using the `ATTACH` command:""") +def _(mo): + mo.md(""" + Connect to duckdb [persistent storage](https://duckdb.org/docs/connect/overview.html#persistent-database) using the `ATTACH` command: + """) return @app.cell -def __(mo): +def _(mo): _df = mo.sql( f""" ATTACH 'test.db' as test; SHOW ALL TABLES; """ ) - return (test,) + return @app.cell -def __(mo, test, test_table): +def _(mo): _df = mo.sql( f""" SELECT * FROM test.test_table; diff --git a/examples/sql/connect_to_postgres.py b/examples/sql/connect_to_postgres.py index 13520413a58..8eecb734a01 100644 --- a/examples/sql/connect_to_postgres.py +++ b/examples/sql/connect_to_postgres.py @@ -3,35 +3,34 @@ # dependencies = [ # "duckdb==1.1.1", # "marimo", -# "pandas==2.2.3", +# "polars==1.18.0", +# "pyarrow==18.1.0", # "requests==2.32.3", # ] # /// import marimo -__generated_with = "0.9.1" +__generated_with = "0.17.2" app = marimo.App(width="medium") @app.cell -def __(mo): - mo.md( - r""" - # Connect to Postgres +def _(mo): + mo.md(r""" + # Connect to Postgres - You can use marimo's SQL cells to read from and write to Postgres databases. + You can use marimo's SQL cells to read from and write to Postgres databases. - The first step is to attach a Postgres database, which we do below. + The first step is to attach a Postgres database, which we do below. - For advanced usage, see [duckdb's documentation](https://duckdb.org/docs/extensions/postgres). - """ - ) + For advanced usage, see [duckdb's documentation](https://duckdb.org/docs/extensions/postgres). + """) return @app.cell(hide_code=True) -def __(): +def _(): import marimo as mo @@ -49,11 +48,11 @@ def download_sample_data(): download_sample_data() - return download_sample_data, mo + return (mo,) @app.cell(hide_code=True) -def __(mo): +def _(mo): mo.accordion( { "Tip: Creating SQL Cells": mo.md( @@ -73,15 +72,15 @@ def __(mo): @app.cell -def __(): +def _(): import os PASSWORD = os.getenv("PGPASSWORD", "mysecretpassword") - return PASSWORD, os + return (PASSWORD,) @app.cell -def __(PASSWORD, mo): +def _(PASSWORD, mo): _df = mo.sql( f""" -- Boilerplate: detach the database so this cell works when you re-run it @@ -102,18 +101,16 @@ def __(PASSWORD, mo): @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - Once the database is attached, you can query it with SQL. Note that this involves copying data from Postgres SQL and - executing it in duckdb. See later sections of this example on how to execute queries directly in Postgres. - """ - ) +def _(mo): + mo.md(r""" + Once the database is attached, you can query it with SQL. Note that this involves copying data from Postgres SQL and + executing it in duckdb. See later sections of this example on how to execute queries directly in Postgres. + """) return @app.cell -def __(db, mo, test_table): +def _(mo): _df = mo.sql( f""" -- Query your tables! This assumes a database with schema public and a sample table called test_table. @@ -124,7 +121,7 @@ def __(db, mo, test_table): @app.cell(hide_code=True) -def __(mo): +def _(mo): mo.md( f""" You can explore the schemas of all your tables at a glance in the **data sources panel**: click @@ -135,19 +132,17 @@ def __(mo): @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - ## Copy data from Postgres to duckdb +def _(mo): + mo.md(r""" + ## Copy data from Postgres to duckdb - To prevent duckdb from continuously re-reading tables from PostgresSQL, you can copy the PostgresSQL databases into DuckDB. Note that this will consume your system's RAM. - """ - ) + To prevent duckdb from continuously re-reading tables from PostgreSQL, you can copy the PostgreSQL databases into DuckDB. Note that this will consume your system's RAM. + """) return @app.cell -def __(db, duckdb_table, mo, test_table): +def _(mo): _df = mo.sql( f""" CREATE OR REPlACE TABLE duckdb_table AS FROM db.public.test_table; @@ -155,23 +150,21 @@ def __(db, duckdb_table, mo, test_table): SELECT * FROM duckdb_table; """ ) - return (duckdb_table,) + return @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - ## Execute queries directly in PostgresSQL +def _(mo): + mo.md(r""" + ## Execute queries directly in PostgreSQL - Run queries directly in PostgresSQL using duckdb's `postgres_query` function. In some cases this may be faster than executing queries in duckdb. - """ - ) + Run queries directly in PostgreSQL using duckdb's `postgres_query` function. In some cases this may be faster than executing queries in duckdb. + """) return @app.cell -def __(mo): +def _(mo): _df = mo.sql( f""" SELECT * FROM postgres_query('db', 'SELECT * FROM test_table'); diff --git a/examples/sql/connect_to_sqlite.py b/examples/sql/connect_to_sqlite.py index 5e20883d6ed..c9860c7ae27 100644 --- a/examples/sql/connect_to_sqlite.py +++ b/examples/sql/connect_to_sqlite.py @@ -3,35 +3,35 @@ # dependencies = [ # "duckdb==1.1.1", # "marimo", -# "pandas==2.2.3", +# "polars==1.18.0", +# "pyarrow==18.1.0", # "requests==2.32.3", +# "sqlglot==26.7.0", # ] # /// import marimo -__generated_with = "0.9.1" +__generated_with = "0.17.4" app = marimo.App(width="medium") @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - # Connect to SQLite +def _(mo): + mo.md(r""" + # Connect to SQLite - You can use marimo's SQL cells to read from and write to SQLite databases. + You can use marimo's SQL cells to read from and write to SQLite databases. - The first step is to attach a SQLite database. We attach to a sample database in a read-only mode below. + The first step is to attach a SQLite database. We attach to a sample database in a read-only mode below. - For advanced usage, see [duckdb's documentation](https://duckdb.org/docs/extensions/sqlite). - """ - ) + For advanced usage, see [duckdb's documentation](https://duckdb.org/docs/extensions/sqlite). + """) return @app.cell(hide_code=True) -def __(): +def _(): import marimo as mo @@ -49,11 +49,11 @@ def download_sample_data(): download_sample_data() - return download_sample_data, mo + return (mo,) @app.cell(hide_code=True) -def __(mo): +def _(mo): mo.accordion( { "Tip: Creating SQL Cells": mo.md( @@ -73,7 +73,7 @@ def __(mo): @app.cell -def __(INFORMATION_SCHEMA, mo): +def _(mo): _df = mo.sql( f""" -- Boilerplate: detach the database so this cell works when you re-run it @@ -86,17 +86,19 @@ def __(INFORMATION_SCHEMA, mo): SELECT table_name FROM INFORMATION_SCHEMA.TABLES where table_catalog == 'chinook'; """ ) - return (chinook,) + return @app.cell(hide_code=True) -def __(mo): - mo.md(r"""Once the database is attached, you can query it with SQL. For example, the next cell computes the average track length of each composer in the chinook database.""") +def _(mo): + mo.md(r""" + Once the database is attached, you can query it with SQL. For example, the next cell computes the average track length of each composer in the chinook database. + """) return @app.cell -def __(chinook, mo, track): +def _(mo): _df = mo.sql( f""" SELECT composer, MEAN(Milliseconds) as avg_track_ms from chinook.track GROUP BY composer ORDER BY avg_track_ms DESC; @@ -106,7 +108,7 @@ def __(chinook, mo, track): @app.cell(hide_code=True) -def __(mo): +def _(mo): mo.md( f""" You can explore the schemas of all your tables at a glance in the **data sources panel**: click diff --git a/examples/sql/duckdb_example.py b/examples/sql/duckdb_example.py new file mode 100644 index 00000000000..46792f73809 --- /dev/null +++ b/examples/sql/duckdb_example.py @@ -0,0 +1,55 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "altair==5.5.0", +# "polars[pyarrow]==1.27.1", +# "marimo[sql]", +# "duckdb==1.2.2", +# "sqlglot==26.13.0", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium", sql_output="polars") + + +@app.cell +def _(): + import marimo as mo + import altair as alt + + return alt, mo + + +@app.cell +def _(mo): + digits = mo.ui.slider(label="Digits", start=100, stop=10000, step=200) + digits + return (digits,) + + +@app.cell +def _(digits, mo): + result = mo.sql( + f""" + CREATE TABLE random_data AS + SELECT i AS id, RANDOM() AS random_value, + FROM range({digits.value}) AS t(i); + + SELECT * FROM random_data; + """ + ) + return (result,) + + +@app.cell +def _(alt, result): + # Plot the data using polars and altair + result.plot.bar(x=alt.X("random_value").bin(), y="count()") + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/sql/histograms.py b/examples/sql/histograms.py index 19143bb5177..3e9ea731660 100644 --- a/examples/sql/histograms.py +++ b/examples/sql/histograms.py @@ -12,46 +12,45 @@ import marimo -__generated_with = "0.9.4" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell -def __(mo): - mo.md( - r""" - # SQL Histograms +def _(mo): + mo.md(r""" + # SQL Histograms - This notebook shows how to create a histogram of a column using built-in duckdb aggregate functions. - """ - ) + This notebook shows how to create a histogram of a column using built-in duckdb aggregate functions. + """) return @app.cell -def __(): +def _(): import marimo as mo import duckdb import polars import pyarrow import altair as alt - return alt, duckdb, mo, polars, pyarrow + + return alt, mo @app.cell -def __(): +def _(): URL = "hf://datasets/scikit-learn/Fish/Fish.csv" return (URL,) @app.cell(hide_code=True) -def __(URL, mo): +def _(URL, mo): mo.md(rf"""First we read the dataset at: **{URL}**""") return @app.cell -def __(URL, dataset, mo): +def _(URL, mo): dataset = mo.sql( f""" CREATE OR REPLACE VIEW dataset AS @@ -60,11 +59,11 @@ def __(URL, dataset, mo): FROM dataset """ ) - return (dataset,) + return @app.cell -def __(dataset, mo): +def _(dataset, mo): column = mo.ui.dropdown( dataset.columns, value=dataset.columns[0], label="Choose a column" ) @@ -73,7 +72,7 @@ def __(dataset, mo): @app.cell -def __(column, mo): +def _(column, mo): histogram = mo.sql( f""" -- Use the duckdb histogram function @@ -84,13 +83,15 @@ def __(column, mo): @app.cell -def __(mo): - mo.md(r"""Now we will take the histogram result and plot it using [Altair](https://altair-viz.github.io/).""") +def _(mo): + mo.md(r""" + Now we will take the histogram result and plot it using [Altair](https://altair-viz.github.io/). + """) return @app.cell -def __(alt, histogram): +def _(alt, histogram): ( alt.Chart(histogram) .mark_bar() diff --git a/examples/sql/misc/database_explorer.py b/examples/sql/misc/database_explorer.py index a2bd409f7c3..8d45b837f99 100644 --- a/examples/sql/misc/database_explorer.py +++ b/examples/sql/misc/database_explorer.py @@ -3,39 +3,42 @@ # dependencies = [ # "duckdb==1.1.1", # "marimo", +# "numpy==2.2.6", # "pandas==2.2.3", +# "polars==1.18.0", +# "pyarrow==18.1.0", +# "sqlglot==26.19.0", # ] # /// import marimo -__generated_with = "0.9.1" +__generated_with = "0.19.7" app = marimo.App(width="full") @app.cell -def __(): +def _(): import os import duckdb import marimo as mo + return duckdb, mo, os @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - # Database explorer +def _(mo): + mo.md(r""" + # Database explorer - This notebook lets you explore the contents of a database. Start by providing a database URL. - """ - ) + This notebook lets you explore the contents of a database. Start by providing a database URL. + """) return @app.cell -def __(mo, os): +def _(mo, os): database_url = mo.ui.text( label="Database URL", full_width=True, @@ -46,11 +49,9 @@ def __(mo, os): @app.cell -def __(database_url): - import duckdb - +def _(database_url, duckdb): if database_url.value: - duckdb.sql( + _ = duckdb.sql( f""" INSTALL postgres; LOAD postgres; @@ -59,23 +60,25 @@ def __(database_url): ATTACH DATABASE '{database_url.value}' AS my_db (TYPE postgres, READ_ONLY); """ ) - return duckdb, my_db + return @app.cell -def __(duckdb): +def _(duckdb): duckdb.sql("SHOW DATABASES").show() return @app.cell(hide_code=True) -def __(mo): - mo.md(r"""## Tables""") +def _(mo): + mo.md(r""" + ## Tables + """) return @app.cell -def __(mo): +def _(mo): _df = mo.sql( f""" SHOW ALL TABLES; @@ -85,13 +88,15 @@ def __(mo): @app.cell(hide_code=True) -def __(mo): - mo.md(r"""## Other meta table functions""") +def _(mo): + mo.md(r""" + ## Other meta table functions + """) return @app.cell -def __(): +def _(): FUNCTIONS = [ "duckdb_columns()", # columns "duckdb_constraints()", # constraints @@ -114,7 +119,7 @@ def __(): @app.cell -def __(FUNCTIONS, mo): +def _(FUNCTIONS, mo): function = mo.ui.dropdown( label="Dropdown", options=FUNCTIONS, @@ -125,7 +130,7 @@ def __(FUNCTIONS, mo): @app.cell -def __(_, function, mo): +def _(function, mo): _df = mo.sql( f""" SELECT * FROM {function.value} WHERE database_name == 'my_db' @@ -135,13 +140,15 @@ def __(_, function, mo): @app.cell(hide_code=True) -def __(mo): - mo.md(r"""## Interact with your tables""") +def _(mo): + mo.md(r""" + ## Interact with your tables + """) return @app.cell -def __(duckdb, duckdb_tables, mo): +def _(duckdb, mo): tables = duckdb.execute( """ SELECT table_name FROM duckdb_tables() WHERE internal = False; @@ -149,11 +156,11 @@ def __(duckdb, duckdb_tables, mo): ).df() table_names = list(tables["table_name"]) mo.accordion({f"Found {len(table_names)} tables": table_names}) - return table_names, tables + return (table_names,) @app.cell -def __(mo, table_names): +def _(mo, table_names): mo.stop(not table_names) table_select = mo.ui.dropdown( label="Table", @@ -172,14 +179,14 @@ def __(mo, table_names): @app.cell -def __(mo, table_select): +def _(mo, table_select): mo.stop(not table_select.value) table_select_value = table_select.value return (table_select_value,) @app.cell -def __(limit, mo, table_select_value): +def _(limit, mo, table_select_value): selected_table = mo.sql( f""" select * from my_db.{table_select_value} LIMIT {limit.value}; @@ -189,7 +196,7 @@ def __(limit, mo, table_select_value): @app.cell -def __(mo, selected_table): +def _(mo, selected_table): mo.ui.data_explorer(selected_table) return diff --git a/examples/sql/misc/electric_vehicles.py b/examples/sql/misc/electric_vehicles.py index d3b450c4b46..ea716441a12 100644 --- a/examples/sql/misc/electric_vehicles.py +++ b/examples/sql/misc/electric_vehicles.py @@ -2,30 +2,31 @@ # requires-python = ">=3.9" # dependencies = [ # "altair==5.4.1", +# "duckdb==1.1.3", # "marimo", +# "polars==1.18.0", +# "pyarrow==18.1.0", # ] # /// import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - # Electric Vehicle Population Data +def _(mo): + mo.md(r""" + # Electric Vehicle Population Data - > This dataset shows the Battery Electric Vehicles (BEVs) and Plug-in Hybrid Electric Vehicles (PHEVs) that are currently registered through Washington State Department of Licensing (DOL). - """ - ) + > This dataset shows the Battery Electric Vehicles (BEVs) and Plug-in Hybrid Electric Vehicles (PHEVs) that are currently registered through Washington State Department of Licensing (DOL). + """) return @app.cell -def __(evs, mo): +def _(mo): evs = mo.sql( f""" create or replace table evs as @@ -33,38 +34,38 @@ def __(evs, mo): select * from evs """ ) - return (evs,) + return @app.cell -def __(mo, years): +def _(mo, years): all_years = years["Model Year"] year_select = mo.ui.multiselect.from_series(years["Model Year"]) - return all_years, year_select + return (year_select,) @app.cell -def __(cities, mo): +def _(cities, mo): all_cities = cities["City"] city_select = mo.ui.multiselect.from_series(cities["City"]) - return all_cities, city_select + return (city_select,) @app.cell -def __(makes, mo): +def _(makes, mo): all_makes = makes["Make"] make_select = mo.ui.multiselect.from_series(makes["Make"]) - return all_makes, make_select + return (make_select,) @app.cell -def __(city_select, make_select, mo, year_select): +def _(city_select, make_select, mo, year_select): mo.hstack([year_select, city_select, make_select], justify="space-between") return @app.cell -def __(alt, grouped_by_city, mo): +def _(alt, grouped_by_city, mo): _chart = ( alt.Chart(grouped_by_city) .mark_bar() @@ -80,7 +81,7 @@ def __(alt, grouped_by_city, mo): @app.cell -def __(alt, grouped_by_make, mo): +def _(alt, grouped_by_make, mo): _chart = ( alt.Chart(grouped_by_make) .mark_bar() @@ -96,19 +97,21 @@ def __(alt, grouped_by_make, mo): @app.cell -def __(chart1, chart2, mo): +def _(chart1, chart2, mo): mo.hstack([chart1, chart2], widths="equal") return @app.cell -def __(mo): - mo.md(r"""## Appendix""") +def _(mo): + mo.md(r""" + ## Appendix + """) return @app.cell -def __(evs, mo): +def _(evs, mo): years = mo.sql( f""" SELECT DISTINCT CAST(evs."Model Year" AS VARCHAR) AS "Model Year" FROM evs; @@ -118,7 +121,7 @@ def __(evs, mo): @app.cell -def __(evs, mo): +def _(evs, mo): cities = mo.sql( f""" SELECT DISTINCT CAST(evs."City" AS VARCHAR) AS "City" FROM evs WHERE "City" != 'null'; @@ -128,7 +131,7 @@ def __(evs, mo): @app.cell -def __(evs, mo): +def _(evs, mo): makes = mo.sql( f""" SELECT DISTINCT CAST(evs."Make" AS VARCHAR) AS "Make" FROM evs; @@ -138,15 +141,7 @@ def __(evs, mo): @app.cell -def __( - cast_to_ints, - city_select, - evs, - make_select, - mo, - sql_list, - year_select, -): +def _(cast_to_ints, city_select, evs, make_select, mo, sql_list, year_select): grouped_by_city = mo.sql( f""" SELECT COUNT(*) AS "count", "City", "Model Year" @@ -166,15 +161,7 @@ def __( @app.cell -def __( - cast_to_ints, - city_select, - evs, - make_select, - mo, - sql_list, - year_select, -): +def _(cast_to_ints, city_select, evs, make_select, mo, sql_list, year_select): grouped_by_make = mo.sql( f""" SELECT COUNT(*) AS "count", "Make", "Model Year" @@ -194,7 +181,7 @@ def __( @app.cell -def __(): +def _(): def sql_list(column, items): if not items: return "True == True" @@ -208,14 +195,16 @@ def as_literal(v): def cast_to_ints(items): return [int(i) for i in items] - return as_literal, cast_to_ints, sql_list + + return cast_to_ints, sql_list @app.cell -def __(): +def _(): # Imports import marimo as mo import altair as alt + return alt, mo diff --git a/examples/sql/misc/sql_cars.py b/examples/sql/misc/sql_cars.py index a1856897702..ddaa54de69c 100644 --- a/examples/sql/misc/sql_cars.py +++ b/examples/sql/misc/sql_cars.py @@ -1,29 +1,31 @@ # /// script # requires-python = ">=3.9" # dependencies = [ -# "altair==5.4.1", -# "duckdb==1.1.1", +# "altair>=5.4.1", +# "duckdb>=1.1.1", # "marimo", -# "pandas==2.2.3", -# "vega-datasets==0.9.0", +# "polars>=1.18.0", +# "pyarrow>=18.1.0", +# "vega-datasets>=0.9.0", # ] # /// + import marimo -__generated_with = "0.7.14" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell -def __(data): +def _(data): # Load the cars dataset cars_df = data.cars() cars_df["Year"] = cars_df["Year"].apply(lambda x: x.year) - return cars_df, + return (cars_df,) @app.cell -def __(cars_df, mo): +def _(cars_df, mo): _df = mo.sql( f""" CREATE OR REPLACE TABLE cars AS SELECT * FROM cars_df; @@ -33,7 +35,7 @@ def __(cars_df, mo): @app.cell -def __(cars_df, mo): +def _(cars_df, mo): origin = mo.ui.dropdown.from_series(cars_df["Origin"]) year_range = mo.ui.range_slider.from_series(cars_df["Year"], show_value=True) top_n = mo.ui.number(value=5, start=1, stop=50, label="Top N Cars") @@ -42,23 +44,23 @@ def __(cars_df, mo): @app.cell -def __(origin): +def _(origin): origin_filter = ( f"AND Origin = '{origin.value}'" if origin.value != None else "" ) - return origin_filter, + return (origin_filter,) @app.cell -def __(mo, origin, top_n): +def _(mo, origin, top_n): mo.md( - f"""##Top {top_n.value} Cars {f"in {origin.value}" if origin.value != None else ""} """ + f"""##Top {top_n.value} Cars {f"in {origin.value}" if origin.value != None else ""}""" ) return @app.cell -def __(mo, origin_filter, top_n, year_range): +def _(mo, origin_filter, top_n, year_range): _df = mo.sql( f""" SELECT Name, Year, Origin, Horsepower, Miles_per_Gallon, @@ -74,13 +76,15 @@ def __(mo, origin_filter, top_n, year_range): @app.cell -def __(mo): - mo.md("""### Breakdown by Origin""") +def _(mo): + mo.md(""" + ### Breakdown by Origin + """) return @app.cell -def __(cars, mo, year_range): +def _(cars, mo, year_range): _df = mo.sql( f""" WITH ranked_cars AS ( @@ -106,7 +110,7 @@ def __(cars, mo, year_range): @app.cell -def __(alt, duckdb, mo, year_range): +def _(alt, duckdb, mo, year_range): _query = f""" SELECT Year, AVG(Horsepower) as Avg_Horsepower, @@ -138,11 +142,11 @@ def __(alt, duckdb, mo, year_range): ) ) mo.ui.altair_chart(_chart, chart_selection=None) - return base, line1, line2 + return @app.cell(hide_code=True) -def __(alt, duckdb, mo, year_range): +def _(alt, duckdb, mo, year_range): _query = f""" SELECT Horsepower, Miles_per_Gallon, Origin FROM cars @@ -164,18 +168,18 @@ def __(alt, duckdb, mo, year_range): chart = mo.ui.altair_chart(_chart) chart - return chart, + return (chart,) @app.cell -def __(chart, mo): +def _(chart, mo): mo.stop(chart.value.empty, mo.callout("Select cars from the chart above.")) selected_cars = chart.value - return selected_cars, + return (selected_cars,) @app.cell(hide_code=True) -def __(aggs, aggs_selected, mo): +def _(aggs, aggs_selected, mo): def title_case(title): return title.title().replace("_", " ") @@ -199,11 +203,11 @@ def diff(column): for column in aggs.columns ] ) - return diff, title_case + return @app.cell(hide_code=True) -def __(cars, mo, selected_cars): +def _(cars, mo, selected_cars): aggs_selected = mo.sql(""" SELECT COUNT(*) as count, @@ -231,13 +235,13 @@ def __(cars, mo, selected_cars): @app.cell -def __(): +def _(): import marimo as mo - import pandas as pd import duckdb import altair as alt from vega_datasets import data - return alt, data, duckdb, mo, pd + + return alt, data, duckdb, mo if __name__ == "__main__": diff --git a/examples/sql/parametrizing_sql_queries.py b/examples/sql/parametrizing_sql_queries.py index 130b6669bfe..7d64ffcd26d 100644 --- a/examples/sql/parametrizing_sql_queries.py +++ b/examples/sql/parametrizing_sql_queries.py @@ -3,84 +3,84 @@ # dependencies = [ # "duckdb==1.1.1", # "marimo", -# "pandas==2.2.3", +# "polars==1.18.0", +# "pyarrow==18.1.0", # "vega-datasets==0.9.0", # ] # /// import marimo -__generated_with = "0.9.1" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell -def __(): +def _(): import marimo as mo + return (mo,) @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - # Parametrizing SQL Queries +def _(mo): + mo.md(""" + # Parametrizing SQL Queries - This notebook shows parametrize SQL queries with Python values, using Python f-string interpolation. + This notebook shows parametrize SQL queries with Python values, using Python f-string interpolation. - First, we create a dataframe called `df`. - """ - ) + First, we create a dataframe called `df`. + """) return @app.cell -def __(): +def _(): from vega_datasets import data df = data.iris() df - return data, df + return (df,) @app.cell(hide_code=True) -def __(mo): - mo.md(r"""Next, we create a dropdown that selects the iris species.""") +def _(mo): + mo.md(r""" + Next, we create a dropdown that selects the iris species. + """) return @app.cell -def __(mo): +def _(mo): species_dropdown = mo.ui.dropdown(["setosa", "versicolor", "virginica"], value="setosa") species_dropdown return (species_dropdown,) @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - Next, we **create a SQL cell** that filters the table to the selected species. - - Notice that we can reference the Python variable `species_dropdown` in our query - using **curly braces**. This is because marimo represents SQL queries as Python - f-strings. - """ - ) +def _(mo): + mo.md(""" + Next, we **create a SQL cell** that filters the table to the selected species. + + Notice that we can reference the Python variable `species_dropdown` in our query + using **curly braces**. This is because marimo represents SQL queries as Python + f-strings. + """) return @app.cell(hide_code=True) -def __(mo): +def _(mo): mo.accordion( { "Tip: Creating SQL Cells": mo.md( f""" Create a SQL cell in one of two ways: - + 1. Click the {mo.icon("lucide:database")} `SQL` button at the **bottom of your notebook** 2. **Right-click** the {mo.icon("lucide:circle-plus")} button to the **left of a cell**, and choose `SQL`. - + In the SQL cell, you can query dataframes in your notebook as if they were tables — just reference them by name. """ @@ -91,7 +91,7 @@ def __(mo): @app.cell -def __(df, mo, species_dropdown): +def _(df, mo, species_dropdown): result = mo.sql( f""" SELECT * FROM df where species == '{species_dropdown.value}' @@ -101,23 +101,21 @@ def __(df, mo, species_dropdown): @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - The query output is returned to Python as a dataframe (Polars if you have it installed, Pandas otherwise). +def _(mo): + mo.md(r""" + The query output is returned to Python as a dataframe (Polars if you have it installed, Pandas otherwise). - Choose the dataframe name via the **output variable** input in the bottom-left - of the cell. If the name starts with an underscore, it won't be made available - to other cells. In this case, we've named the output `result`. + Choose the dataframe name via the **output variable** input in the bottom-left + of the cell. If the name starts with an underscore, it won't be made available + to other cells. In this case, we've named the output `result`. - Try changing the selected species in the `species_dropdown`, and watch how the query result changes. - """ - ) + Try changing the selected species in the `species_dropdown`, and watch how the query result changes. + """) return @app.cell -def __(result): +def _(result): result return diff --git a/examples/sql/querying_dataframes.py b/examples/sql/querying_dataframes.py index e60055ba313..2134b133a1c 100644 --- a/examples/sql/querying_dataframes.py +++ b/examples/sql/querying_dataframes.py @@ -3,54 +3,54 @@ # dependencies = [ # "duckdb==1.1.1", # "marimo", -# "pandas==2.2.3", +# "polars==1.18.0", +# "pyarrow==18.1.0", # "vega-datasets==0.9.0", # ] # /// import marimo -__generated_with = "0.9.1" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell -def __(): +def _(): import marimo as mo + return (mo,) @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - # Querying dataframes +def _(mo): + mo.md(""" + # Querying dataframes - This notebook shows how to use SQL to query Python dataframes. + This notebook shows how to use SQL to query Python dataframes. - First, we create a dataframe called `df`. - """ - ) + First, we create a dataframe called `df`. + """) return @app.cell -def __(): +def _(): from vega_datasets import data df = data.iris() df.head() - return data, df + return (df,) @app.cell(hide_code=True) -def __(mo): +def _(mo): mo.md( f""" Next, we **create a SQL cell**. - + Create a SQL cell in one of two ways: - + 1. Click the {mo.icon("lucide:database")} `SQL` button at the **bottom of your notebook** 2. **Right-click** the {mo.icon("lucide:circle-plus")} button to the **left of a cell**, and choose `SQL`. @@ -61,7 +61,7 @@ def __(mo): @app.cell -def __(df, mo): +def _(df, mo): result = mo.sql( f""" SELECT species, mean(petalLength) as meanPetalLength FROM df GROUP BY species ORDER BY meanPetalLength @@ -71,21 +71,19 @@ def __(df, mo): @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - The query output is returned to Python as a dataframe (Polars if you have it installed, Pandas otherwise). +def _(mo): + mo.md(r""" + The query output is returned to Python as a dataframe (Polars if you have it installed, Pandas otherwise). - Choose the dataframe name via the **output variable** input in the bottom-left of the cell. If the name starts with an underscore, it won't be made available to other cells. + Choose the dataframe name via the **output variable** input in the bottom-left of the cell. If the name starts with an underscore, it won't be made available to other cells. - In this case, we've named the output `result`. - """ - ) + In this case, we've named the output `result`. + """) return @app.cell -def __(result): +def _(result): result return diff --git a/examples/sql/read_csv.py b/examples/sql/read_csv.py index 4a818546457..cce3d71d00a 100644 --- a/examples/sql/read_csv.py +++ b/examples/sql/read_csv.py @@ -3,46 +3,47 @@ # dependencies = [ # "duckdb==1.1.1", # "marimo", -# "pandas==2.2.3", +# "polars==1.18.0", +# "pyarrow==18.1.0", # "vega-datasets==0.9.0", # ] # /// import marimo -__generated_with = "0.9.1" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - # Read CSV +def _(mo): + mo.md(""" + # Read CSV - This notebook shows how to read a CSV file from a local file or a URL into an in-memory table. - """ - ) + This notebook shows how to read a CSV file from a local file or a URL into an in-memory table. + """) return @app.cell(hide_code=True) -def __(): +def _(): import marimo as mo - import pandas as pd + import polars as pl - pd.DataFrame({"A": [1, 2, 3], "B": ["a", "b", "c"]}).to_csv("data.csv") - return mo, pd + pl.DataFrame({"A": [1, 2, 3], "B": ["a", "b", "c"]}).write_csv("data.csv") + return (mo,) @app.cell(hide_code=True) -def __(mo): - mo.md("""Reading from a local CSV is as easy as `SELECT * from "data.csv"`, where `data.csv` is the path to your local file (or a URL to a CSV file).""") +def _(mo): + mo.md(""" + Reading from a local CSV is as easy as `SELECT * from "data.csv"`, where `data.csv` is the path to your local file (or a URL to a CSV file). + """) return @app.cell(hide_code=True) -def __(mo): +def _(mo): mo.accordion( { "Tip: Creating SQL Cells": mo.md( @@ -62,19 +63,20 @@ def __(mo): @app.cell -def __(data, mo): +def _(mo): result = mo.sql( f""" -- Tip: you can also specify the data files using a glob, such as '/path/to/*.csv' -- or '/path/**/to/*.csv' SELECT * FROM "data.csv" - """, output=False + """, + output=False ) return (result,) @app.cell(hide_code=True) -def __(mo): +def _(mo): mo.accordion( { "Tip: Query output": mo.md( @@ -92,35 +94,33 @@ def __(mo): @app.cell -def __(result): +def _(result): result return @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - ## Create an in-memory table from a CSV file +def _(mo): + mo.md(r""" + ## Create an in-memory table from a CSV file - You can also create a table from a CSV file, so you can easily query it in subsequent cells. This table will appear in marimo's data sources panel. - """ - ) + You can also create a table from a CSV file, so you can easily query it in subsequent cells. This table will appear in marimo's data sources panel. + """) return @app.cell -def __(data, mo): +def _(mo): _df = mo.sql( f""" CREATE TABLE myTable AS SELECT * FROM "data.csv" """ ) - return (myTable,) + return @app.cell -def __(mo, myTable): +def _(mo, mytable): _df = mo.sql( f""" SELECT * FROM myTable @@ -130,14 +130,18 @@ def __(mo, myTable): @app.cell(hide_code=True) -def __(mo): - mo.md(r"""## Advanced usage""") +def _(mo): + mo.md(r""" + ## Advanced usage + """) return @app.cell(hide_code=True) -def __(mo): - mo.md(r"""To customize how your CSV is read, including specifying the delimiter type, use [duckdb's `read_csv` function](https://duckdb.org/docs/data/csv/overview.html).""") +def _(mo): + mo.md(r""" + To customize how your CSV is read, including specifying the delimiter type, use [duckdb's `read_csv` function](https://duckdb.org/docs/data/csv/overview.html). + """) return diff --git a/examples/sql/read_json.py b/examples/sql/read_json.py index 13f7be1377a..68a986cce8d 100644 --- a/examples/sql/read_json.py +++ b/examples/sql/read_json.py @@ -3,57 +3,53 @@ # dependencies = [ # "duckdb==1.1.1", # "marimo", -# "pandas==2.2.3", -# "pyarrow==17.0.0", +# "polars==1.18.0", +# "pyarrow==18.1.0", # "vega-datasets==0.9.0", # ] # /// import marimo -__generated_with = "0.9.1" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - # Read JSON +def _(mo): + mo.md(""" + # Read JSON - This notebook shows how to read a JSON file from a local file or a URL into an in-memory table. - """ - ) + This notebook shows how to read a JSON file from a local file or a URL into an in-memory table. + """) return @app.cell(hide_code=True) -def __(): +def _(): import marimo as mo - import pandas as pd + import polars as pl - pd.DataFrame({"A": [1, 2, 3], "B": ["a", "b", "c"]}).to_json("data.json", orient="records") - return mo, pd + pl.DataFrame({"A": [1, 2, 3], "B": ["a", "b", "c"]}).write_json("data.json") + return (mo,) @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - Reading from a JSON file is as easy as +def _(mo): + mo.md(""" + Reading from a JSON file is as easy as - ```sql - SELECT * from 'data.json' - ``` + ```sql + SELECT * from 'data.json' + ``` - where `data.json` is the path or URL to your json file. - """ - ) + where `data.json` is the path or URL to your json file. + """) return @app.cell(hide_code=True) -def __(mo): +def _(mo): mo.accordion( { "Tip: Creating SQL Cells": mo.md( @@ -73,19 +69,20 @@ def __(mo): @app.cell -def __(data, mo): +def _(mo): result = mo.sql( f""" -- Tip: you can also specify the data files using a glob, such as '/path/to/*.json' -- or '/path/**/to/*.json' SELECT * FROM 'data.json' - """, output=False + """, + output=False, ) return (result,) @app.cell(hide_code=True) -def __(mo): +def _(mo): mo.accordion( { "Tip: Query output": mo.md( @@ -103,35 +100,33 @@ def __(mo): @app.cell -def __(result): +def _(result): result return @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - ## Create an in-memory table from a JSON file +def _(mo): + mo.md(r""" + ## Create an in-memory table from a JSON file - You can also create a table from a JSON file, so you can easily query it in subsequent cells. This table will appear in marimo's data sources panel. - """ - ) + You can also create a table from a JSON file, so you can easily query it in subsequent cells. This table will appear in marimo's data sources panel. + """) return @app.cell -def __(data, mo): +def _(mo): _df = mo.sql( f""" CREATE OR REPLACE TABLE myTable AS SELECT * FROM 'data.json' """ ) - return (myTable,) + return @app.cell -def __(mo, myTable): +def _(mo, mytable): _df = mo.sql( f""" SELECT * FROM myTable @@ -141,14 +136,18 @@ def __(mo, myTable): @app.cell(hide_code=True) -def __(mo): - mo.md(r"""## Advanced usage""") +def _(mo): + mo.md(r""" + ## Advanced usage + """) return @app.cell(hide_code=True) -def __(mo): - mo.md(r"""To customize how your json file is read, use [duckdb's `read_json` function](https://duckdb.org/docs/data/json/overview.html).""") +def _(mo): + mo.md(r""" + To customize how your json file is read, use [duckdb's `read_json` function](https://duckdb.org/docs/data/json/overview.html). + """) return diff --git a/examples/sql/read_parquet.py b/examples/sql/read_parquet.py index a39cb211d50..c45662f34bb 100644 --- a/examples/sql/read_parquet.py +++ b/examples/sql/read_parquet.py @@ -3,47 +3,47 @@ # dependencies = [ # "duckdb==1.1.1", # "marimo", -# "pandas==2.2.3", -# "pyarrow==17.0.0", +# "polars==1.18.0", +# "pyarrow==18.1.0", # "vega-datasets==0.9.0", # ] # /// import marimo -__generated_with = "0.9.1" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - # Read Parquet +def _(mo): + mo.md(""" + # Read Parquet - This notebook shows how to read a Parquet file from a local file or a URL into an in-memory table. - """ - ) + This notebook shows how to read a Parquet file from a local file or a URL into an in-memory table. + """) return @app.cell(hide_code=True) -def __(): +def _(): import marimo as mo - import pandas as pd + import polars as pl - pd.DataFrame({"A": [1, 2, 3], "B": ["a", "b", "c"]}).to_parquet("data.parquet") - return mo, pd + pl.DataFrame({"A": [1, 2, 3], "B": ["a", "b", "c"]}).write_parquet("data.parquet") + return (mo,) @app.cell(hide_code=True) -def __(mo): - mo.md("""Reading from a Parquet file is as easy as `SELECT * from "data.parquet"`, where `data.parquet` is the path or URL to your parquet file.""") +def _(mo): + mo.md(""" + Reading from a Parquet file is as easy as `SELECT * from "data.parquet"`, where `data.parquet` is the path or URL to your parquet file. + """) return @app.cell(hide_code=True) -def __(mo): +def _(mo): mo.accordion( { "Tip: Creating SQL Cells": mo.md( @@ -63,7 +63,7 @@ def __(mo): @app.cell -def __(data, mo): +def _(mo): result = mo.sql( f""" -- Tip: you can also specify the data files using a glob, such as '/path/to/*.parquet' @@ -75,7 +75,7 @@ def __(data, mo): @app.cell(hide_code=True) -def __(mo): +def _(mo): mo.accordion( { "Tip: Query output": mo.md( @@ -93,35 +93,33 @@ def __(mo): @app.cell -def __(result): +def _(result): result return @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - ## Create an in-memory table from a Parquet file +def _(mo): + mo.md(r""" + ## Create an in-memory table from a Parquet file - You can also create a table from a Parquet file, so you can easily query it in subsequent cells. This table will appear in marimo's data sources panel. - """ - ) + You can also create a table from a Parquet file, so you can easily query it in subsequent cells. This table will appear in marimo's data sources panel. + """) return @app.cell -def __(data, mo): +def _(mo): _df = mo.sql( f""" CREATE OR REPLACE TABLE myTable AS SELECT * FROM 'data.parquet' """ ) - return (myTable,) + return @app.cell -def __(mo, myTable): +def _(mo, mytable): _df = mo.sql( f""" SELECT * FROM myTable @@ -131,14 +129,18 @@ def __(mo, myTable): @app.cell(hide_code=True) -def __(mo): - mo.md(r"""## Advanced usage""") +def _(mo): + mo.md(r""" + ## Advanced usage + """) return @app.cell(hide_code=True) -def __(mo): - mo.md(r"""To customize how your parquet file is read, use [duckdb's `read_parquet` function](https://duckdb.org/docs/data/parquet/overview.html).""") +def _(mo): + mo.md(r""" + To customize how your parquet file is read, use [duckdb's `read_parquet` function](https://duckdb.org/docs/data/parquet/overview.html). + """) return diff --git a/examples/storage/general.py b/examples/storage/general.py new file mode 100644 index 00000000000..07acafd8ed6 --- /dev/null +++ b/examples/storage/general.py @@ -0,0 +1,77 @@ +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "fsspec==2026.2.0", +# "marimo>=0.19.9", +# "obstore==0.8.2", +# "python-dotenv==1.2.1", +# "requests==2.32.5", +# "s3fs==2026.2.0", +# ] +# /// + +import marimo + +__generated_with = "0.19.11" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + from fsspec.implementations.local import LocalFileSystem + from fsspec.implementations.github import GithubFileSystem + import s3fs + import obstore + import os + from dotenv import load_dotenv + + load_dotenv() + return GithubFileSystem, LocalFileSystem, os, s3fs + + +@app.cell +def _(LocalFileSystem): + local = LocalFileSystem(asynchronous=True) + return + + +@app.cell +def _(os): + from obstore.store import S3Store + + access_key_id = os.environ.get("CLOUDFLARE_ACCESS_KEY_ID") + secret_access_key = os.environ.get("CLOUDFLARE_SECRET_ACCESS_KEY") + url = os.environ.get("CLOUDFLARE_MARIMO_URL") + store = S3Store.from_url( + f"{url}/marimo-artifacts", + access_key_id=access_key_id, + secret_access_key=secret_access_key, + ) + return (url,) + + +@app.cell +def _(GithubFileSystem): + marimo_repo = GithubFileSystem(org="marimo-team", repo="marimo") + return (marimo_repo,) + + +@app.cell +def _(marimo_repo): + print(marimo_repo.read_text("github://AGENTS.md")) + return + + +@app.cell +def _(os, s3fs, url): + s3_client = s3fs.S3FileSystem( + endpoint_url=url, + key=os.getenv("CLOUDFLARE_ACCESS_KEY_ID"), + secret=os.getenv("CLOUDFLARE_SECRET_ACCESS_KEY"), + ) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/storage/hf_storage.py b/examples/storage/hf_storage.py new file mode 100644 index 00000000000..68ef678455a --- /dev/null +++ b/examples/storage/hf_storage.py @@ -0,0 +1,83 @@ +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "huggingface-hub==1.24.0", +# "marimo>=0.23.15", +# "polars==1.43.0", +# ] +# /// + +# Copyright 2026 Marimo. All rights reserved. + +import marimo + +__generated_with = "0.23.16" +app = marimo.App(width="medium") + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + # Hugging Face Hub in Remote Storage (`HfApi`) + + Create an [`HfApi`](https://huggingface.co/docs/huggingface_hub/package_reference/hf_api) client + and marimo will discover it in the **Remote Storage** panel. Browse your datasets, + models, spaces, and buckets — then read files with `hf://` URLs in Polars or pandas. + """) + return + + +@app.cell +def _(): + from huggingface_hub import HfApi + + hf = HfApi() + return (hf,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + ## List files in a dataset repo + + Expand **Hugging Face Hub** in Remote Storage, or list from code: + """) + return + + +@app.cell +def _(hf): + list( + hf.list_repo_tree( + "scikit-learn/Fish", + repo_type="dataset", + ) + ) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + ## Read the file directly via `hf://` + """) + return + + +@app.cell +def _(pl): + df = pl.read_csv("hf://datasets/scikit-learn/Fish/Fish.csv") + df + return + + +@app.cell +def _(): + import marimo as mo + import polars as pl + + return mo, pl + + +if __name__ == "__main__": + app.run() diff --git a/examples/testing/README.md b/examples/testing/README.md new file mode 100644 index 00000000000..5dd490673e1 --- /dev/null +++ b/examples/testing/README.md @@ -0,0 +1,11 @@ +# Testing 🧪 + +These basic examples show how to use test marimo notebooks. + +## Testing with pytest + +Run `pytest test_with_pytest.py`. + +## Testing with doctests + +See `running_doctests.py` diff --git a/examples/testing/running_doctests.py b/examples/testing/running_doctests.py new file mode 100644 index 00000000000..5db918964db --- /dev/null +++ b/examples/testing/running_doctests.py @@ -0,0 +1,47 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.function +def euclid_mcd(a: int, b: int) -> int: + """Return the MCD between positive a, b. + >>> euclid_mcd(42, 24) + 6 + >>> euclid_mcd(24, 42) + 6 + >>> euclid_mcd(42, 42) + 42 + """ + assert a > 0 + assert b > 0 + if a < b: + a, b = b, a + if (a != b): + r = a - b + return euclid_mcd(b, r) + return a + + +@app.cell +def _(mo): + # Include a reference to each function to test + euclid_mcd + + import doctest + + failures, success = doctest.testmod(verbose=True) + mo.md(f"Success: {success}, Failures: {failures}") + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/testing/test_with_pytest.py b/examples/testing/test_with_pytest.py new file mode 100644 index 00000000000..3e816ec83c7 --- /dev/null +++ b/examples/testing/test_with_pytest.py @@ -0,0 +1,25 @@ +import marimo + +__generated_with = "0.23.9" +app = marimo.App() + + +@app.function +def inc(x): + return x + 1 + + +@app.cell +def test_answer(): + assert inc(3) == 5, "This test fails" + return + + +@app.cell +def test_sanity(): + assert inc(3) == 4, "This test passes" + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/third_party/README.md b/examples/third_party/README.md index 88546c2950e..1145bb14aba 100644 --- a/examples/third_party/README.md +++ b/examples/third_party/README.md @@ -11,13 +11,14 @@ These examples showcase how to use various third-party packages in marimo. The requirements of each notebook are serialized in them as a top-level comment. Here are the steps to open an example notebook: -1. [Install marimo](https://docs.marimo.io/getting_started/index.html#installation) -2. [Install `uv`](https://github.com/astral-sh/uv/?tab=readme-ov-file#installation) -3. Open an example with `marimo edit --sandbox `. +1. [Install `uv`](https://github.com/astral-sh/uv/?tab=readme-ov-file#installation) +2. Open an example with `uvx marimo edit --sandbox ` > [!TIP] > The [`--sandbox` flag](https://docs.marimo.io/guides/editor_features/package_management.html) opens the notebook in an isolated virtual environment, > automatically installing the notebook's dependencies 📦 -You can also open notebooks without `uv`, with just `marimo edit `; -however, you'll need to install the requirements yourself. +You can also open notebooks without `uv`, in which case you'll need to +manually [install marimo](https://docs.marimo.io/getting_started/index.html#installation) +first. Then run `marimo edit `; however, you'll also need to +install the requirements yourself. diff --git a/examples/third_party/aframe/aframe_example.py b/examples/third_party/aframe/aframe_example.py index 654322a0364..fe46f2ef567 100644 --- a/examples/third_party/aframe/aframe_example.py +++ b/examples/third_party/aframe/aframe_example.py @@ -1,11 +1,11 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App() @app.cell -def __(Aframe, xyz): +def _(Aframe, xyz): a = Aframe() a.set_scene( background="color: #ECECEC; fog: type: exponential; color: #AAA; density: 0.05" @@ -48,15 +48,16 @@ def __(Aframe, xyz): @app.cell -def __(a, mo): +def _(a, mo): mo.iframe(a.generate()) return @app.cell -def __(): +def _(): import marimo as mo from aframe import Aframe, xyz + return Aframe, mo, xyz diff --git a/examples/third_party/anywidget/reactive_quak.py b/examples/third_party/anywidget/reactive_quak.py index cc0aebd5a43..5929e98491c 100644 --- a/examples/third_party/anywidget/reactive_quak.py +++ b/examples/third_party/anywidget/reactive_quak.py @@ -11,28 +11,31 @@ import marimo -__generated_with = "0.8.2" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell -def __(): +def _(): import marimo as mo import polars as pl import quak + return mo, pl, quak @app.cell -def __(mo, pl, quak): - df = pl.read_parquet("https://github.com/uwdata/mosaic/raw/main/data/athletes.parquet") +def _(mo, pl, quak): + df = pl.read_csv( + "https://raw.githubusercontent.com/uwdata/mosaic/main/data/athletes.csv" + ) widget = mo.ui.anywidget(quak.Widget(df)) widget - return df, widget + return (widget,) @app.cell -def __(grouped_selection, mo): +def _(grouped_selection, mo): import altair as alt @@ -46,20 +49,25 @@ def __(grouped_selection, mo): .transform_filter(alt.datum.rank < 10) .properties(height=400) ) - return alt, + return @app.cell -def __(widget): +def _(widget): selection = widget.data().df() - return selection, + return (selection,) @app.cell -def __(selection): +def _(selection): selection["count"] = 1 - grouped_selection = selection[["nationality", "count"]].groupby(["nationality"]).agg("count").reset_index() - return grouped_selection, + grouped_selection = ( + selection[["nationality", "count"]] + .groupby(["nationality"]) + .agg("count") + .reset_index() + ) + return (grouped_selection,) if __name__ == "__main__": diff --git a/examples/third_party/anywidget/tldraw_colorpicker.py b/examples/third_party/anywidget/tldraw_colorpicker.py index 90729fb0c8f..3a024823227 100644 --- a/examples/third_party/anywidget/tldraw_colorpicker.py +++ b/examples/third_party/anywidget/tldraw_colorpicker.py @@ -10,12 +10,12 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.15.5" app = marimo.App(width="medium") @app.cell -def __(): +def _(): import matplotlib.pyplot as plt import numpy as np plt.style.use('_mpl-gallery') @@ -31,22 +31,22 @@ def __(): @app.cell -def __(): +def _(): from tldraw import ReactiveColorPicker import marimo as mo widget = mo.ui.anywidget(ReactiveColorPicker()) - return ReactiveColorPicker, mo, widget + return mo, widget @app.cell -def __(mo, np, opacity, plt, sizes, widget, x, y): +def _(mo, np, opacity, plt, sizes, widget, x, y): fig, ax = plt.subplots() fig.set_size_inches(3, 3) ax.set(xlim=(0, 8), xticks=np.arange(1, 8), ylim=(0, 8), yticks=np.arange(1, 8)) ax.scatter(x, y, s=sizes*5, color=widget.color or None, alpha=opacity) mo.hstack([widget, plt.gca()], justify="start", widths=[1, 1]) - return ax, fig + return if __name__ == "__main__": diff --git a/examples/third_party/chroma/multimodal_retrieval.py b/examples/third_party/chroma/multimodal_retrieval.py new file mode 100644 index 00000000000..73381b58ce4 --- /dev/null +++ b/examples/third_party/chroma/multimodal_retrieval.py @@ -0,0 +1,295 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "chromadb==1.0.4", +# "datasets==3.5.0", +# "marimo", +# "matplotlib==3.10.1", +# "numpy==2.2.4", +# "open-clip-torch==2.32.0", +# "pillow==11.1.0", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + # Multimodal Retrieval + + Chroma supports multimodal collections, i.e. collections which contain, and can be queried by, multiple modalities of data. + + This notebook shows an example of how to create and query a collection with both text and images, using Chroma's built-in features. + """) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ## Dataset + + We us a small subset of the [coco object detection dataset](https://huggingface.co/datasets/detection-datasets/coco), hosted on HuggingFace. + + We download a small fraction of all the images in the dataset locally, and use it to create a multimodal collection. + """) + return + + +@app.cell +def _(): + import os + + from datasets import load_dataset + from matplotlib import pyplot as plt + + return load_dataset, os + + +@app.cell +def _(load_dataset, mo): + with mo.status.spinner(title="Loading dataset"): + dataset = load_dataset( + path="detection-datasets/coco", + name="default", + split="train", + streaming=True, + ) + + N_IMAGES = 20 + return N_IMAGES, dataset + + +@app.cell +def _(N_IMAGES, dataset, mo, os): + # Write the images to a folder + IMAGE_FOLDER = "images" + os.makedirs(IMAGE_FOLDER, exist_ok=True) + i = 0 + all_images = [] + with mo.status.spinner(title="Loading images"): + for row in dataset.take(N_IMAGES): + image = row["image"] + all_images.append(image) + image.save(f"images/{i}.jpg") + i += 1 + return IMAGE_FOLDER, all_images + + +@app.cell(hide_code=True) +def _(mo): + img_width = mo.ui.slider( + label="Image width", start=100, stop=300, step=10, debounce=True + ) + img_width + return (img_width,) + + +@app.cell(hide_code=True) +def _(all_images, img_width, mo): + import io + + + def as_image(src): + img_byte_arr = io.BytesIO() + src.save(img_byte_arr, format=src.format or "PNG") + img_byte_arr.seek(0) + return mo.image(img_byte_arr, width=img_width.value) + + + mo.hstack( + [as_image(_img) for _img in all_images[10:]], + wrap=True, + ) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ## Ingesting multimodal data + + Chroma supports multimodal collections by referencing external URIs for data types other than text. + All you have to do is specify a data loader when creating the collection, and then provide the URI for each entry. + + For this example, we are only adding images, though you can also add text. + """) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ### Creating a multi-modal collection + + First we create the default Chroma client. + """) + return + + +@app.cell +def _(): + import chromadb + + client = chromadb.Client() + return (client,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + Next we specify an embedding function and a data loader. + + The built-in `OpenCLIPEmbeddingFunction` works with both text and image data. The `ImageLoader` is a simple data loader that loads images from a local directory. + """) + return + + +@app.cell +def _(): + from chromadb.utils.data_loaders import ImageLoader + from chromadb.utils.embedding_functions import OpenCLIPEmbeddingFunction + + embedding_function = OpenCLIPEmbeddingFunction() + image_loader = ImageLoader() + return embedding_function, image_loader + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + We create a collection with the embedding function and data loader. + """) + return + + +@app.cell +def _(IMAGE_FOLDER, client, embedding_function, image_loader, os): + collection = client.create_collection( + name="multimodal_collection", + embedding_function=embedding_function, + data_loader=image_loader, + get_or_create=True, + ) + + # Get the uris to the images + image_uris = sorted( + [ + os.path.join(IMAGE_FOLDER, image_name) + for image_name in os.listdir(IMAGE_FOLDER) + ] + ) + ids = [str(i) for i in range(len(image_uris))] + + collection.add(ids=ids, uris=image_uris) + return (collection,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ### Adding multi-modal data + + We add image data to the collection using the image URIs. The data loader and embedding functions we specified earlier will ingest data from the provided URIs automatically. + """) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ## Querying a multi-modal collection + + We can query the collection using text as normal, since the `OpenCLIPEmbeddingFunction` works with both text and images. + """) + return + + +@app.cell(hide_code=True) +def _(mo): + query = mo.ui.text_area(label="Query with text", full_width=True).form( + bordered=False + ) + mo.vstack([query, mo.md("Try: *animal* or *vehicle*")]) + return (query,) + + +@app.cell +def _(collection, mo, query): + mo.stop(not query.value) + _retrieved = collection.query( + query_texts=[query.value], include=["data"], n_results=3 + ) + + [mo.image(img, height=200) for img in _retrieved["data"][0]] + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + /// admonition | One more thing! + We can also query by images directly, by using the `query_images` field in the `collection.query` method. + /// + """) + return + + +@app.cell +def _(collection, mo, selected_image): + mo.stop(not selected_image.value) + import numpy as np + from PIL import Image + + query_image = np.array(Image.open(selected_image.path())) + selected = mo.as_html(mo.image(query_image)) + + _retrieved = collection.query( + query_images=[query_image], include=["data"], n_results=5 + ) + results = [mo.image(_img) for _img in _retrieved["data"][0][1:]] + return results, selected + + +@app.cell(hide_code=True) +def _(IMAGE_FOLDER, mo): + selected_image = mo.ui.file_browser(IMAGE_FOLDER, multiple=False) + selected_image + return (selected_image,) + + +@app.cell(hide_code=True) +def _(mo, results, selected): + mo.hstack( + [ + mo.vstack([mo.md("## Selected"), selected]), + mo.vstack([mo.md("## Similar"), *results]), + ], + widths="equal", + gap=4, + ) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + This example was adapted from [multimodal_retrieval.ipynb](https://github.com/chroma-core/chroma/blob/main/examples/multimodal/multimodal_retrieval.ipynb), using `marimo convert`. + """) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/third_party/cvxpy/regularization_and_sparsity.py b/examples/third_party/cvxpy/regularization_and_sparsity.py index 4ff8a0e31ff..d3b0a65f276 100644 --- a/examples/third_party/cvxpy/regularization_and_sparsity.py +++ b/examples/third_party/cvxpy/regularization_and_sparsity.py @@ -10,63 +10,63 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App() @app.cell(hide_code=True) -def __(mo): - mo.md("""# Regularization and Sparsity""") +def _(mo): + mo.md(""" + # Regularization and Sparsity + """) return @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - This app shows how the choice of regularization in a least squares regression - problem can affect the sparsity of solutions. - - We will use CVXPY to solve the problem - - \[ - \begin{equation*} - \begin{array}{ll} - \text{minimize} & \|A x - b\|_2^2 + \lambda \|x \|_p \\ - \end{array} - \end{equation*} - \] - - where $A \in \mathbf{R}^{m \times n}$ and $b \in \mathbf{R}^{m}$ are problem - data, $x \in \mathbf{R}^n$ is the optimization variable, and - $\lambda > 0$ is - a scalar that controls the strength of the regularization. - - Let's experiment how solutions to this problem differ for $p=1$, - - \[ - \|x\|_1 = |x_1| + |x_2| + \cdots + |x_n|, - \] - - and $p=2$, - - \[ - \|x\|_2 = \sqrt{x_1^2 + x_2^2 + \cdots + x_n^2}. - \] - """ - ) +def _(mo): + mo.md(r""" + This app shows how the choice of regularization in a least squares regression + problem can affect the sparsity of solutions. + + We will use CVXPY to solve the problem + + \[ + \begin{equation*} + \begin{array}{ll} + \text{minimize} & \|A x - b\|_2^2 + \lambda \|x \|_p \\ + \end{array} + \end{equation*} + \] + + where $A \in \mathbf{R}^{m \times n}$ and $b \in \mathbf{R}^{m}$ are problem + data, $x \in \mathbf{R}^n$ is the optimization variable, and + $\lambda > 0$ is + a scalar that controls the strength of the regularization. + + Let's experiment how solutions to this problem differ for $p=1$, + + \[ + \|x\|_1 = |x_1| + |x_2| + \cdots + |x_n|, + \] + + and $p=2$, + + \[ + \|x\|_2 = \sqrt{x_1^2 + x_2^2 + \cdots + x_n^2}. + \] + """) return @app.cell -def __(): +def _(): m = 100 n = 20 return m, n @app.cell -def __(m, n, np): +def _(m, n, np): np.random.seed(0) A = np.random.randn(m, n) b = np.random.randn(m) @@ -74,14 +74,14 @@ def __(m, n, np): @app.cell -def __(A, b, sparse_solver): +def _(A, b, sparse_solver): l2_solver = sparse_solver(A, b, p=2) l1_solver = sparse_solver(A, b, p=1) return l1_solver, l2_solver @app.cell -def __(functools, l1_solver, l2_solver, np, sparsity_parameter): +def _(functools, l1_solver, l2_solver, np, sparsity_parameter): x_min_max= [np.inf, -np.inf] @functools.cache @@ -89,31 +89,33 @@ def solve(lambd): return l2_solver(lambd), l1_solver(lambd) x_l2, x_l1 = solve(sparsity_parameter.value) - return solve, x_l1, x_l2, x_min_max + return x_l1, x_l2, x_min_max @app.cell(hide_code=True) -def __(mo): - mo.md("""## Parameter selection""") +def _(mo): + mo.md(""" + ## Parameter selection + """) return @app.cell(hide_code=True) -def __(mo): +def _(mo): sparsity_parameter = mo.ui.slider(0, 10, step=0.1) mo.md( f""" - Choose the regularization strength $\lambda$: {sparsity_parameter} + Choose the regularization strength $\\lambda$: {sparsity_parameter} """) return (sparsity_parameter,) @app.cell(hide_code=True) -def __(mo, n, number_of_zeros, sparsity_parameter, x_l1, x_l2): +def _(mo, n, number_of_zeros, sparsity_parameter, x_l1, x_l2): ( mo.md( """ - **$\lambda$ = 0.** + **$\\lambda$ = 0.** No regularization is applied. The solutions are the same. """ @@ -121,15 +123,15 @@ def __(mo, n, number_of_zeros, sparsity_parameter, x_l1, x_l2): if sparsity_parameter.value == 0 else mo.md( f""" - **$\lambda$ = {sparsity_parameter.value}.** + **$\\lambda$ = {sparsity_parameter.value}.** - Watch how the fraction of entries of $x$ near $0$ changes as $\lambda$ + Watch how the fraction of entries of $x$ near $0$ changes as $\\lambda$ increases. - **$p=1$**: {number_of_zeros(x_l1) / n * 100:.02f}% of the entries of + **$p=1$**: {number_of_zeros(x_l1) / n * 100:.02f}% of the entries of $x$ are extremely close to $0$. - **$p=2$**: {number_of_zeros(x_l2) / n * 100:.02f}% of the entries of + **$p=2$**: {number_of_zeros(x_l2) / n * 100:.02f}% of the entries of $x$ are extremely close to $0$. """ ) @@ -138,42 +140,41 @@ def __(mo, n, number_of_zeros, sparsity_parameter, x_l1, x_l2): @app.cell -def __(cdf, plt, x_l1, x_l2): +def _(cdf, plt, x_l1, x_l2): cdf_figure, cdf_axs = plt.subplots(2, 1, sharex=True) cdf(x_l1, cdf_axs[0]).set_title("$p=1$") cdf(x_l2, cdf_axs[1]).set_title("$p=2$") plt.tight_layout() cdf_figure - return cdf_axs, cdf_figure + return @app.cell -def __(mo): - mo.md( - """ - ## Sparsity - - The $\ell_1$ norm, when used as a regularizer, encourages solutions - to be _sparse_: to have many zeros and only a few nonzeros. - - A sparse regressor (if it is a good model) encodes which featuers - are important for making predictions, and which are not: If a component - of $x$ is $0$, then the corresponding feature or measurement - must not be important in making predictions. - """ - ) +def _(mo): + mo.md(r""" + ## Sparsity + + The $\ell_1$ norm, when used as a regularizer, encourages solutions + to be _sparse_: to have many zeros and only a few nonzeros. + + A sparse regressor (if it is a good model) encodes which features + are important for making predictions, and which are not: If a component + of $x$ is $0$, then the corresponding feature or measurement + must not be important in making predictions. + """) return @app.cell -def __(np): +def _(np): def number_of_zeros(x): return np.isclose(x, 0).sum() + return (number_of_zeros,) @app.cell -def __(np, x_min_max): +def _(np, x_min_max): def cdf(x, ax): heights = np.arange(1, x.size+1) / x.size xs = np.sort(x) @@ -186,11 +187,12 @@ def cdf(x, ax): ax.set_xlabel('$x_i$') ax.set_ylabel('fraction of components') return ax + return (cdf,) @app.cell -def __(cp): +def _(cp): def sparse_solver(A, b, p): x = cp.Variable(A.shape[1]) lambd = cp.Parameter(nonneg=True) @@ -204,26 +206,30 @@ def solve(v): problem.solve() return x.value return solve + return (sparse_solver,) @app.cell -def __(): +def _(): import cvxpy as cp import matplotlib.pyplot as plt import numpy as np + return cp, np, plt @app.cell -def __(): +def _(): import functools + return (functools,) @app.cell -def __(): +def _(): import marimo as mo + return (mo,) diff --git a/examples/third_party/cvxpy/signals/app.py b/examples/third_party/cvxpy/signals/app.py index c9bc7f464af..1209907f823 100644 --- a/examples/third_party/cvxpy/signals/app.py +++ b/examples/third_party/cvxpy/signals/app.py @@ -1,44 +1,42 @@ import marimo -__generated_with = "0.1.4" +__generated_with = "0.19.7" app = marimo.App() @app.cell -def __(mo): - mo.md("# Signal Decomposition") +def _(mo): + mo.md(""" + # Signal Decomposition + """) return @app.cell -def __(mo): - mo.md( - """ - This app is a hands-on introduction to _signal decomposition_, an - age-old problem about breaking down a complex signal, also known as a - time series, into the sum of simpler interpretable ones. - """ - ) +def _(mo): + mo.md(""" + This app is a hands-on introduction to _signal decomposition_, an + age-old problem about breaking down a complex signal, also known as a + time series, into the sum of simpler interpretable ones. + """) return @app.cell -def __(mo): - mo.md( - """ - The simpler signals that come out of a decomposition are called - _components_. When doing a signal decomposition, we have to specify - two things: +def _(mo): + mo.md(""" + The simpler signals that come out of a decomposition are called + _components_. When doing a signal decomposition, we have to specify + two things: - 1. How many components do we want? - 2. What kinds of components, or "component classes", do we want? - """ - ) + 1. How many components do we want? + 2. What kinds of components, or "component classes", do we want? + """) return @app.cell -def __(complib, mo): +def _(complib, mo): component_options = [ complib.Components.TREND_LINE, complib.Components.PERIODIC, @@ -61,25 +59,25 @@ def __(complib, mo): component_radio, get_component_radio_tracker, other_component_radio, - set_component_radio_tracker, ) @app.cell -def __(component_options, get_component_radio_tracker): +def _(component_options, get_component_radio_tracker): def user_tried_all_components(): return len(get_component_radio_tracker()) == len(component_options) - return user_tried_all_components, + + return (user_tried_all_components,) @app.cell -def __(component_radio, other_component_radio): +def _(component_radio, other_component_radio): radios = [component_radio, other_component_radio] - return radios, + return (radios,) @app.cell -def __(intro_problem, mo): +def _(intro_problem, mo): intro = intro_problem.IntroProblem() mo.md( @@ -91,11 +89,11 @@ def __(intro_problem, mo): {mo.as_html(intro.plot())} """ ) - return intro, + return (intro,) @app.cell -def __(get_show_third_component, mo): +def _(get_show_third_component, mo): _n_components = "2" if not get_show_third_component() else "3" _three_component_text = " and third " if get_show_third_component() else "" @@ -122,7 +120,7 @@ def __(get_show_third_component, mo): @app.cell -def __(get_show_third_component, intro, mo, radios): +def _(get_show_third_component, intro, mo, radios): # Show radios ( mo.hstack( @@ -143,7 +141,7 @@ def __(get_show_third_component, intro, mo, radios): @app.cell -def __(get_show_third_component, intro, mo, radios): +def _(get_show_third_component, intro, mo, radios): # Plot 3-component decomposition ( None @@ -163,7 +161,7 @@ def __(get_show_third_component, intro, mo, radios): @app.cell -def __(explainer, get_show_third_component, mo, radios): +def _(explainer, get_show_third_component, mo, radios): # Component explainer callout ( mo.md(explainer.explainer(radios[0].value)).callout(kind="neutral") @@ -174,7 +172,7 @@ def __(explainer, get_show_third_component, mo, radios): @app.cell -def __(mo): +def _(mo): get_show_third_component, set_show_third_component = mo.state(False) add_component_button = mo.ui.button( @@ -189,12 +187,11 @@ def __(mo): add_component_button, get_show_third_component, remove_component_button, - set_show_third_component, ) @app.cell -def __( +def _( add_component_button, get_show_third_component, mo, @@ -222,7 +219,7 @@ def __( @app.cell -def __(): +def _(): class StickyBool: value = False @@ -235,11 +232,11 @@ def __bool__(self): solved_ever = StickyBool() - return StickyBool, solved_ever + return (solved_ever,) @app.cell -def __(complib, radios, solved_ever): +def _(complib, radios, solved_ever): _chosen_components = set([r.value for r in radios]) @@ -255,11 +252,11 @@ def __init__(self, sticky_bool): ) if solved.now: solved_ever.set() - return Solved, solved + return (solved,) @app.cell -def __(get_show_third_component, mo, solved): +def _(get_show_third_component, mo, solved): # Solved callout ( mo.md( @@ -290,7 +287,7 @@ def __(get_show_third_component, mo, solved): @app.cell -def __(mo, solved): +def _(mo, solved): ( mo.md( """**Heads up!** @@ -311,7 +308,7 @@ def __(mo, solved): @app.cell -def __( +def _( get_show_third_component, mo, remove_component_button, @@ -340,13 +337,15 @@ def __( @app.cell -def __(mo): - mo.md("## Part 2: More Decompositions") +def _(mo): + mo.md(""" + ## Part 2: More Decompositions + """) return @app.cell -def __(mo, problems): +def _(mo, problems): selected_problem = mo.ui.dropdown( { problems.MaunaLoa.name(): problems.MaunaLoa, @@ -357,11 +356,11 @@ def __(mo, problems): }, label="Choose a signal:", ) - return selected_problem, + return (selected_problem,) @app.cell -def __(mo, selected_problem, solved): +def _(mo, selected_problem, solved): # Solve part 1 callout ( mo.md( @@ -390,14 +389,14 @@ def __(mo, selected_problem, solved): @app.cell -def __(mo): +def _(mo): data_uploader = mo.ui.file(filetypes=[".csv"], kind="area") csv_has_header = mo.ui.checkbox(value=True) return csv_has_header, data_uploader @app.cell -def __(csv_has_header, data_uploader, mo): +def _(csv_has_header, data_uploader, mo): def read_uploaded_csv(): from io import BytesIO import pandas as pd @@ -430,11 +429,12 @@ def show_csv_parameters(): """ else: return "" + return column_name, read_uploaded_csv, show_csv_parameters @app.cell -def __(data_uploader, mo, problems, selected_problem, show_csv_parameters): +def _(data_uploader, mo, problems, selected_problem, show_csv_parameters): mo.stop(selected_problem.value != problems.CustomDataProblem) mo.md( @@ -453,7 +453,7 @@ def __(data_uploader, mo, problems, selected_problem, show_csv_parameters): @app.cell -def __( +def _( column_name, data_uploader, mo, @@ -508,7 +508,7 @@ def _construct_problem(problem_class): @app.cell -def __(mo, set_k): +def _(mo, set_k): add_button = mo.ui.button( on_change=lambda _: set_k(lambda v: v + 1), label="Add a component", @@ -522,13 +522,7 @@ def __(mo, set_k): @app.cell -def __( - complib, - get_k, - get_selected_components, - mo, - set_selected_components, -): +def _(complib, get_k, get_selected_components, mo, set_selected_components): def _get_default_component_value(index): if index >= len(get_selected_components()): return None @@ -547,17 +541,11 @@ def _get_default_component_value(index): component_array = mo.ui.array( _dropdowns, label="Components", on_change=set_selected_components ) - return component_array, + return (component_array,) @app.cell -def __( - complib, - component_array, - get_selected_params, - mo, - set_selected_params, -): +def _(complib, component_array, get_selected_params, mo, set_selected_params): component_params = mo.ui.dictionary( { f"{i}": complib.parameter_controls( @@ -569,11 +557,11 @@ def __( label="Parameters", on_change=set_selected_params, ) - return component_params, + return (component_params,) @app.cell -def __(mo, problem): +def _(mo, problem): mo.stop(problem is None) mo.md(f"### {problem.name()}") @@ -581,7 +569,7 @@ def __(mo, problem): @app.cell -def __(mo, problem): +def _(mo, problem): mo.stop(problem is None) problem.description() @@ -589,7 +577,7 @@ def __(mo, problem): @app.cell -def __(add_button, mo, problem, remove_button): +def _(add_button, mo, problem, remove_button): mo.stop(problem is None) mo.md( @@ -601,7 +589,7 @@ def __(add_button, mo, problem, remove_button): @app.cell -def __(component_array, component_params, mo, problem): +def _(component_array, component_params, mo, problem): mo.stop(problem is None) mo.hstack([component_array, component_params]) @@ -609,7 +597,7 @@ def __(component_array, component_params, mo, problem): @app.cell -def __( +def _( complib, component_array, component_params, @@ -648,11 +636,11 @@ def _get_default_aggregate_component_value(key, index): label="Aggregates", on_change=set_selected_aggregate_components, ) - return aggregates, + return (aggregates,) @app.cell -def __( +def _( aggregates, complib, get_selected_aggregate_params, @@ -679,11 +667,11 @@ def __( ) (mo.hstack([aggregates, aggregate_params]) if aggregates.value else None) - return aggregate_params, defaults + return (aggregate_params,) @app.cell -def __(aggregate_params, aggregates, component_params): +def _(aggregate_params, aggregates, component_params): def _rollup_aggregate_params(aggregates, aggregate_params, params_dict): params_dict = params_dict.copy() for component_key, components in aggregates.items(): @@ -699,26 +687,26 @@ def _rollup_aggregate_params(aggregates, aggregate_params, params_dict): rolled_up_params = _rollup_aggregate_params( aggregates.value, aggregate_params.value, component_params.value ) - return rolled_up_params, + return (rolled_up_params,) @app.cell -def __(component_array): +def _(component_array): noise_component_selected = component_array.value[0] is not None - return noise_component_selected, + return (noise_component_selected,) @app.cell -def __(component_array, noise_component_selected, problem): +def _(component_array, noise_component_selected, problem): should_compute_decomposition = ( noise_component_selected and sum(1 for v in component_array.value if v is not None) >= 2 ) and problem is not None - return should_compute_decomposition, + return (should_compute_decomposition,) @app.cell -def __( +def _( component_array, mo, noise_component_selected, @@ -755,7 +743,7 @@ def _feedback(): @app.cell -def __(complib, problem, problems): +def _(complib, problem, problems): def construct_components(names, parameters): center_periodic = isinstance(problem, problems.MaunaLoa) return list( @@ -767,21 +755,23 @@ def construct_components(names, parameters): ], ) ) - return construct_components, + + return (construct_components,) @app.cell -def __(construct_components): +def _(construct_components): def decompose(problem, components, params): c = construct_components(components, params) f = problem.decompose(c) f.set_figwidth(6.4) return f - return decompose, + + return (decompose,) @app.cell -def __( +def _( component_array, decompose, mo, @@ -809,13 +799,13 @@ def _do_decomposition(): @app.cell -def __(complib, mo): +def _(complib, mo): explainer_choice = mo.ui.dropdown(complib.COMPONENT_LIBRARY) - return explainer_choice, + return (explainer_choice,) @app.cell -def __(explainer_choice, mo, solved): +def _(explainer_choice, mo, solved): mo.stop(not solved.ever) mo.md( @@ -829,7 +819,7 @@ def __(explainer_choice, mo, solved): @app.cell -def __(explainer, explainer_choice, mo): +def _(explainer, explainer_choice, mo): mo.md( explainer.explainer(explainer_choice.value) ).callout() if explainer_choice.value is not None else "" @@ -837,7 +827,7 @@ def __(explainer, explainer_choice, mo): @app.cell -def __(mo, solved): +def _(mo, solved): mo.stop(not solved.ever) mo.md( @@ -857,17 +847,18 @@ def __(mo, solved): @app.cell -def __(): +def _(): import gfosd import gfosd.components as gfc import numpy as np import marimo as mo import matplotlib.pyplot as plt - return gfc, gfosd, mo, np, plt + + return mo, plt @app.cell -def __(): +def _(): import importlib import modules.components as complib import modules.intro_problem as intro_problem @@ -879,7 +870,7 @@ def __(): _ = importlib.reload(explainer) _ = importlib.reload(problems) _ = importlib.reload(intro_problem) - return complib, explainer, importlib, intro_problem, problems + return complib, explainer, intro_problem, problems if __name__ == "__main__": diff --git a/examples/third_party/cvxpy/signals/examples.py b/examples/third_party/cvxpy/signals/examples.py index f0a247e82b7..5feb0954fc3 100644 --- a/examples/third_party/cvxpy/signals/examples.py +++ b/examples/third_party/cvxpy/signals/examples.py @@ -1,24 +1,30 @@ import marimo -__generated_with = "0.0.1a0" +__generated_with = "0.19.7" app = marimo.App() @app.cell -def __(mo): - mo.md("# Examples") +def _(mo): + mo.md(""" + # Examples + """) return @app.cell def _(mo): - mo.md("## Real Data") + mo.md(""" + ## Real Data + """) return @app.cell def _(mo): - mo.md("### NOAA CO2") + mo.md(""" + ### NOAA CO2 + """) return @@ -30,7 +36,7 @@ def _(dataloaders, mo, plt): co2_df.plot(y="average", x="decimal", ax=plt.gca()) plt.tight_layout() mo.tree([co2_df.head(2), plt.gca()], label="data") - return co2_df, + return (co2_df,) @app.cell @@ -40,7 +46,7 @@ def _(co2_df, mo, solutions): mo.tree( [co2_problem.plot_decomposition(figsize=(6, 5))], label="decomposition" ) - return co2_problem, + return (co2_problem,) @app.cell @@ -56,7 +62,9 @@ def _(co2_df, co2_problem, mo, plt): @app.cell def _(mo): - mo.md("### Solar power generation") + mo.md(""" + ### Solar power generation + """) return @@ -72,7 +80,7 @@ def _(dataloaders, mo, plt): ], label="data", ) - return pv_df, + return (pv_df,) @app.cell @@ -83,18 +91,22 @@ def _(mo, pv_df, solutions): [pv_problem.plot_decomposition(exponentiate=True, figsize=(6, 5))], label="decomposition", ) - return pv_problem, + return @app.cell def _(mo): - mo.md("## Synthetic Data") + mo.md(""" + ## Synthetic Data + """) return @app.cell def _(mo): - mo.md("### Basic changepoint detection") + mo.md(""" + ### Basic changepoint detection + """) return @@ -111,7 +123,9 @@ def _(dataloaders, mo, plt): @app.cell def _(mo): - mo.md("**Exact, nonconvex version**") + mo.md(""" + **Exact, nonconvex version** + """) return @@ -127,12 +141,14 @@ def _(bcd_X_real, bcd_y, mo, solutions): ], label="decomposition", ) - return bcd_problem_ncvx, + return @app.cell def _(mo): - mo.md("**Heuristic, convex version**") + mo.md(""" + **Heuristic, convex version** + """) return @@ -144,12 +160,14 @@ def _(bcd_X_real, bcd_y, mo, solutions): [bcd_problem.plot_decomposition(X_real=bcd_X_real, figsize=(6, 5))], "decomposition", ) - return bcd_problem, + return @app.cell def _(mo): - mo.md("### Harder changepoint detection") + mo.md(""" + ### Harder changepoint detection + """) return @@ -175,12 +193,14 @@ def _(hcd_X_real, hcd_y, mo, solutions): [hcd_problem.plot_decomposition(X_real=hcd_X_real, figsize=(6, 5))], "decomposition", ) - return hcd_problem, + return @app.cell def _(mo): - mo.md("### Synthetic soiling data") + mo.md(""" + ### Synthetic soiling data + """) return @@ -200,7 +220,7 @@ def _(dataloaders, mo, plt): plt.legend(loc=3) plt.title(_name) mo.tree([plt.gca()], label="data") - return ss_df, + return (ss_df,) @app.cell @@ -212,24 +232,26 @@ def _(mo, np, plt, solutions, ss_df): _fig.axes[1].set_ylim(_avg - 2.5, _avg + 2.5) plt.tight_layout() mo.tree([plt.gca()], "decomposition") - return ss_problem, + return @app.cell -def __(): +def _(): import matplotlib.pyplot as plt import numpy as np import pandas as pd import modules.dataloaders as dataloaders import modules.solutions as solutions - return dataloaders, np, pd, plt, solutions + + return dataloaders, np, plt, solutions @app.cell def _(): import marimo as mo - return mo, + + return (mo,) if __name__ == "__main__": diff --git a/examples/third_party/cvxpy/smallest_enclosing_circle.py b/examples/third_party/cvxpy/smallest_enclosing_circle.py index ded8bbaae46..df60757d0f9 100644 --- a/examples/third_party/cvxpy/smallest_enclosing_circle.py +++ b/examples/third_party/cvxpy/smallest_enclosing_circle.py @@ -10,31 +10,29 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App() @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - # Smallest Enclosing Circle +def _(mo): + mo.md(""" + # Smallest Enclosing Circle - This program computes the circle of smallest radius that encloses a given - randomly sampled set of circles. This is a generalization of the - [smallest-circle problem](https://en.wikipedia.org/wiki/Smallest-circle_problem). + This program computes the circle of smallest radius that encloses a given + randomly sampled set of circles. This is a generalization of the + [smallest-circle problem](https://en.wikipedia.org/wiki/Smallest-circle_problem). - We solve this problem using [CVXPY](https://www.cvxpy.org), a Python library for specifying and - solving convex optimization problems. + We solve this problem using [CVXPY](https://www.cvxpy.org), a Python library for specifying and + solving convex optimization problems. - _Use the slider below to choose the number of circles to sample:_ - """ - ) + _Use the slider below to choose the number of circles to sample:_ + """) return @app.cell(hide_code=True) -def __(mo): +def _(mo): number_of_circles = mo.ui.slider( 1, 15, value=3, label='Number of circles') number_of_circles @@ -42,7 +40,7 @@ def __(mo): @app.cell(hide_code=True) -def __(mo, number_of_circles): +def _(mo, number_of_circles): resample_button = mo.ui.button(label='Click this button') mo.md( f""" @@ -54,7 +52,7 @@ def __(mo, number_of_circles): @app.cell(hide_code=True) -def __(np): +def _(np): def generate_circles(number_of_circles): circles = [] for i in range(number_of_circles): @@ -62,11 +60,12 @@ def generate_circles(number_of_circles): r_i = np.abs(np.random.randn()) circles.append((c_i, r_i)) return circles + return (generate_circles,) @app.cell(hide_code=True) -def __(generate_circles, number_of_circles, resample_button): +def _(generate_circles, number_of_circles, resample_button): resample_button circles = generate_circles(number_of_circles.value) @@ -74,13 +73,13 @@ def __(generate_circles, number_of_circles, resample_button): @app.cell -def __(circles, smallest_enclosing_circle): +def _(circles, smallest_enclosing_circle): center, radius = smallest_enclosing_circle(circles) return center, radius @app.cell -def __(center, circles, plot_circle, plt, radius): +def _(center, circles, plot_circle, plt, radius): for (c_i, r_i) in circles: plot_circle(c_i, r_i, color='gray') plot_circle(center, radius, color='green', label='smallest enclosing circle') @@ -89,70 +88,71 @@ def __(center, circles, plot_circle, plt, radius): plt.legend(loc='upper right') plt.gcf().set_size_inches((6, 6)) plt.gca() - return c_i, r_i + return @app.cell -def __(plt): +def _(plt): def plot_circle(center, radius, ax=None, **kwargs): ax = plt.gca() if ax is None else ax ax.add_patch(plt.Circle(center, radius, fill=False, **kwargs)) return ax + return (plot_circle,) @app.cell -def __(mo): - mo.md("""## The solution method""") +def _(mo): + mo.md(""" + ## The solution method + """) return @app.cell -def __(mo): - mo.md( - r""" - We can write down a convex optimization problem whose solution gives us - the smallest circle enclosing the $n$ given circles. Once we do this, - we can just code up the problem in CVXPY to obtain a solution. - - Here's the problem: - - We seek a circle, parameterized by a center $c = (x, y) \in \mathbf{R}^2$ - and a radius $r \in \mathbf{R}$ satisfying - - \[ - \begin{equation*} - \begin{array}{ll} - \text{minimize} & r \\ - \text{subject to } & \|c - c_i\|_2 + r_i \leq r, \quad i=1, \ldots, n, - \end{array} - \end{equation*} - \] - - where $c_1, \ldots, c_n$ and $r_1, \ldots, r_n$ are the centers and radii - of the $n$ given circles. - - And here's the code: - - ```python3 - def smallest_enclosing_circle(circles): - radius = cp.Variable() - center = cp.Variable(2) - constraints = [ - cp.norm(center - c_i) + r_i <= radius - for (c_i, r_i) in circles - ] - objective = cp.Minimize(radius) - cp.Problem(objective, constraints).solve() - return (center.value, radius.value) - ``` - """ - ) +def _(mo): + mo.md(r""" + We can write down a convex optimization problem whose solution gives us + the smallest circle enclosing the $n$ given circles. Once we do this, + we can just code up the problem in CVXPY to obtain a solution. + + Here's the problem: + + We seek a circle, parameterized by a center $c = (x, y) \in \mathbf{R}^2$ + and a radius $r \in \mathbf{R}$ satisfying + + \[ + \begin{equation*} + \begin{array}{ll} + \text{minimize} & r \\ + \text{subject to } & \|c - c_i\|_2 + r_i \leq r, \quad i=1, \ldots, n, + \end{array} + \end{equation*} + \] + + where $c_1, \ldots, c_n$ and $r_1, \ldots, r_n$ are the centers and radii + of the $n$ given circles. + + And here's the code: + + ```python3 + def smallest_enclosing_circle(circles): + radius = cp.Variable() + center = cp.Variable(2) + constraints = [ + cp.norm(center - c_i) + r_i <= radius + for (c_i, r_i) in circles + ] + objective = cp.Minimize(radius) + cp.Problem(objective, constraints).solve() + return (center.value, radius.value) + ``` + """) return @app.cell -def __(cp): +def _(cp): def smallest_enclosing_circle(circles): radius = cp.Variable() center = cp.Variable(2) @@ -163,20 +163,23 @@ def smallest_enclosing_circle(circles): objective = cp.Minimize(radius) cp.Problem(objective, constraints).solve() return (center.value, radius.value) + return (smallest_enclosing_circle,) @app.cell -def __(): +def _(): import matplotlib.pyplot as plt import numpy as np import cvxpy as cp + return cp, np, plt @app.cell -def __(): +def _(): import marimo as mo + return (mo,) diff --git a/examples/third_party/databricks/databricks_connect.py b/examples/third_party/databricks/databricks_connect.py new file mode 100644 index 00000000000..9b66e0e0bb3 --- /dev/null +++ b/examples/third_party/databricks/databricks_connect.py @@ -0,0 +1,54 @@ +# /// script +# requires-python = "==3.10" +# dependencies = [ +# "marimo", +# "narwhals==1.37.0", +# "databricks-connect>=16.1.0", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + # Using `databcricks_connect` + """) + return + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(): + from databricks.connect import DatabricksSession + + # This step requires `databricks auth login --host` + spark = DatabricksSession.builder.serverless().getOrCreate() + return (spark,) + + +@app.cell +def _(spark): + df_taxi = spark.read.table("samples.nyctaxi.trips") + type(df_taxi) + return (df_taxi,) + + +@app.cell +def _(df_taxi): + df_taxi + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/third_party/duckdb/duckdb_example.py b/examples/third_party/duckdb/duckdb_example.py index 3099fd68e78..b25fe78422e 100644 --- a/examples/third_party/duckdb/duckdb_example.py +++ b/examples/third_party/duckdb/duckdb_example.py @@ -10,25 +10,23 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App(width="full") @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - # 🤗 Hugging Face dataset search and exploration +def _(mo): + mo.md(""" + # 🤗 Hugging Face dataset search and exploration - This notebook allows you to search and explore the datasets available on Hugging Face. - First you can search for a dataset using the filters provided. Then you can select a dataset and explore the parquet files that are available for that dataset. Finally you can use the SQL editor to query the parquet files and the dataframe editor to explore the results of your query. - """ - ) + This notebook allows you to search and explore the datasets available on Hugging Face. + First you can search for a dataset using the filters provided. Then you can select a dataset and explore the parquet files that are available for that dataset. Finally you can use the SQL editor to query the parquet files and the dataframe editor to explore the results of your query. + """) return @app.cell(hide_code=True) -def __(fetch_hugging_face_datasets, mo, pd): +def _(fetch_hugging_face_datasets, mo, pd): # Load the datasets with mo.status.spinner( title="Loading datasets from Hugging Face", @@ -40,7 +38,7 @@ def __(fetch_hugging_face_datasets, mo, pd): @app.cell(hide_code=True) -def __(datasets, duckdb, print_month_year): +def _(datasets, duckdb): # Extract info from the datasets all_tags = ( duckdb.sql("""SELECT DISTINCT tags FROM datasets WHERE tags IS NOT NULL""") @@ -56,7 +54,7 @@ def __(datasets, duckdb, print_month_year): @app.cell(hide_code=True) -def __(all_tags, dataset_date_range, dataset_length, mo): +def _(all_tags, dataset_date_range, dataset_length, mo): # Create stats cards stats = mo.hstack( [ @@ -80,7 +78,7 @@ def __(all_tags, dataset_date_range, dataset_length, mo): @app.cell -def __(all_tags, mo, stats): +def _(all_tags, mo, stats): # Search filters search_filter = mo.ui.text_area( label="Search", @@ -98,7 +96,7 @@ def __(all_tags, mo, stats): @app.cell(hide_code=True) -def __(datasets, duckdb, mo, search_filter, tag_filter): +def _(datasets, duckdb, mo, search_filter, tag_filter): # Filter and display the datasets in a table datasets _fields = [ @@ -133,7 +131,7 @@ def __(datasets, duckdb, mo, search_filter, tag_filter): @app.cell -def __(table): +def _(table): selected_dataset = ( table.value.iloc[0] if table.value is not None and len(table.value) @@ -143,7 +141,7 @@ def __(table): @app.cell -def __(mo, selected_dataset): +def _(mo, selected_dataset): mo.stop(selected_dataset is None) mo.md( @@ -163,7 +161,7 @@ def __(mo, selected_dataset): @app.cell -def __(load_hugging_face_dataset, mo, selected_dataset): +def _(load_hugging_face_dataset, mo, selected_dataset): # Load the selected dataset's parquet files mo.stop(selected_dataset is None) @@ -183,7 +181,7 @@ def __(load_hugging_face_dataset, mo, selected_dataset): @app.cell -def __(selected_parquet): +def _(selected_parquet): has_selected_file = ( selected_parquet.value is not None and len(selected_parquet.value) > 0 ) @@ -192,7 +190,7 @@ def __(selected_parquet): @app.cell -def __(duckdb, mo, url): +def _(duckdb, mo, url): # Load the selected parquet into duckdb mo.stop(not url) @@ -232,7 +230,7 @@ def __(duckdb, mo, url): @app.cell -def __(has_selected_file, mo, render_sql_editor): +def _(has_selected_file, mo, render_sql_editor): mo.stop(not has_selected_file) sql_editor = render_sql_editor() @@ -256,7 +254,7 @@ def __(has_selected_file, mo, render_sql_editor): @app.cell -def __(has_selected_file, mo, render_sql_results, sql_editor): +def _(has_selected_file, mo, render_sql_results, sql_editor): mo.stop(not has_selected_file) render_sql_results(sql_editor) @@ -264,7 +262,7 @@ def __(has_selected_file, mo, render_sql_results, sql_editor): @app.cell -def __(datasets, mo, render_dataframe_editor): +def _(datasets, mo, render_dataframe_editor): mo.stop(len(datasets) == 0) df_editor = render_dataframe_editor() @@ -281,11 +279,11 @@ def __(datasets, mo, render_dataframe_editor): df_editor, ] ) - return (df_editor,) + return @app.cell -def __(con, mo, url): +def _(con, mo, url): # UI renderers @@ -313,21 +311,20 @@ def render_dataframe_editor(limit=500): data = con.sql(f"SELECT * FROM '{url}' LIMIT {limit}").df() df_editor = mo.ui.dataframe(data) return df_editor + return render_dataframe_editor, render_sql_editor, render_sql_results -@app.cell -def __(): - # Utils +@app.function +# Utils - def print_month_year(date): - return date.strftime("%b %Y") - return (print_month_year,) +def print_month_year(date): + return date.strftime("%b %Y") @app.cell -def __(functools, json, pd, requests): +def _(functools, json, pd, requests): # Cached requests @@ -347,17 +344,19 @@ def load_hugging_face_dataset(dataset_id): j = r.json() urls = [f["url"] for f in j["parquet_files"]] return urls + return fetch_hugging_face_datasets, load_hugging_face_dataset @app.cell -def __(): +def _(): import requests import json import functools import marimo as mo import duckdb import pandas as pd + return duckdb, functools, json, mo, pd, requests diff --git a/examples/third_party/great_tables/great_tables_example.py b/examples/third_party/great_tables/great_tables_example.py index 768fa830f25..8c80526b67d 100644 --- a/examples/third_party/great_tables/great_tables_example.py +++ b/examples/third_party/great_tables/great_tables_example.py @@ -1,32 +1,30 @@ # /// script # requires-python = ">=3.9" # dependencies = [ -# "great-tables==0.11.1", +# "great-tables==0.17.0", # "marimo", -# "polars==1.8.2", +# "polars==1.30.0", # ] # /// import marimo -__generated_with = "0.8.19" +__generated_with = "0.17.2" app = marimo.App(width="medium") @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - # [Great-tables](https://github.com/posit-dev/great-tables) + marimo +def _(mo): + mo.md(r""" + # [Great-tables](https://github.com/posit-dev/great-tables) + marimo - Adapted from https://github.com/machow/coffee-sales-data - """ - ) + Adapted from https://github.com/machow/coffee-sales-data + """) return @app.cell -def __(__file__): +def _(): import polars as pl import polars.selectors as cs import os @@ -38,17 +36,17 @@ def __(__file__): coffee_sales = pl.DataFrame.deserialize( Path(current_dir) / "coffee-sales.json", format="json" ) - return GT, Path, coffee_sales, cs, current_dir, loc, mo, os, pl, style + return GT, coffee_sales, cs, loc, mo, pl, style @app.cell -def __(coffee_sales, mo): +def _(coffee_sales, mo): revenue = mo.ui.range_slider.from_series(coffee_sales["revenue_dollars"]) return (revenue,) @app.cell(hide_code=True) -def __(mo, revenue): +def _(mo, revenue): mo.hstack( [revenue, f"${revenue.value[0]:,.0f} - ${revenue.value[1]:,.0f}"] ).left() @@ -56,18 +54,18 @@ def __(mo, revenue): @app.cell(hide_code=True) -def __(coffee_sales, revenue): +def _(coffee_sales, revenue): lower = revenue.value[0] upper = revenue.value[1] filterered_coffee_sales = coffee_sales.filter( (coffee_sales["revenue_dollars"] >= lower) & (coffee_sales["revenue_dollars"] <= upper) ) - return filterered_coffee_sales, lower, upper + return (filterered_coffee_sales,) @app.cell(hide_code=True) -def __(GT, cs, filterered_coffee_sales, loc, pl, style): +def _(GT, cs, filterered_coffee_sales, loc, pl, style): sel_rev = cs.starts_with("revenue") sel_prof = cs.starts_with("profit") @@ -111,7 +109,7 @@ def __(GT, cs, filterered_coffee_sales, loc, pl, style): ) coffee_table - return coffee_table, sel_prof, sel_rev + return if __name__ == "__main__": diff --git a/examples/third_party/huggingface/chatbot.py b/examples/third_party/huggingface/chatbot.py index c57c0fadfe3..2e77b9c56a2 100644 --- a/examples/third_party/huggingface/chatbot.py +++ b/examples/third_party/huggingface/chatbot.py @@ -8,26 +8,27 @@ import marimo -__generated_with = "0.9.14" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell -def __(): +def _(): import marimo as mo import os from huggingface_hub import InferenceClient + return InferenceClient, mo, os @app.cell -def __(): +def _(): MODEL_NAME = "HuggingFaceH4/zephyr-7b-beta" return (MODEL_NAME,) @app.cell(hide_code=True) -def __(MODEL_NAME, mo): +def _(MODEL_NAME, mo): mo.md(f""" # Chat with **{MODEL_NAME}** """) @@ -35,7 +36,7 @@ def __(MODEL_NAME, mo): @app.cell -def __(max_tokens, mo, system_message, temperature, top_p): +def _(max_tokens, mo, system_message, temperature, top_p): mo.hstack( [ system_message, @@ -46,17 +47,17 @@ def __(max_tokens, mo, system_message, temperature, top_p): @app.cell -def __(mo, respond): +def _(mo, respond): chat = mo.ui.chat( model=respond, prompts=["Tell me a joke.", "What is the square root of {{number}}?"], ) chat - return (chat,) + return @app.cell -def __(InferenceClient, MODEL_NAME, os): +def _(InferenceClient, MODEL_NAME, os): """ For more information on `huggingface_hub` Inference API support, please check the docs: https://huggingface.co/docs/huggingface_hub/v0.26.2/en/guides/inference """ @@ -69,11 +70,11 @@ def __(InferenceClient, MODEL_NAME, os): MODEL_NAME, token=hf_token, ) - return client, hf_token + return (client,) @app.cell -def __(client, mo): +def _(client, mo): # Create UI controls system_message = mo.ui.text_area( value="You are a friendly Chatbot.", @@ -137,7 +138,7 @@ def respond(messages: list[mo.ai.ChatMessage], config): response = client.chat_completion( chat_messages, - max_tokens=max_tokens.value, + max_completion_tokens=max_tokens.value, temperature=temperature.value, top_p=top_p.value, stream=False, @@ -145,15 +146,8 @@ def respond(messages: list[mo.ai.ChatMessage], config): # You can return strings, markdown, charts, tables, dataframes, and more. return response.choices[0].message.content - return max_tokens, respond, system_message, temperature, top_p - -@app.cell -def __(): - # If you need to do anything _reactively_ to the chat messages, - # you can access the chat messages using the `chat.value` attribute. - # chat.value - return + return max_tokens, respond, system_message, temperature, top_p if __name__ == "__main__": diff --git a/examples/third_party/huggingface/text-to-image.py b/examples/third_party/huggingface/text-to-image.py index 4258e5d874c..da8c61c3f5d 100644 --- a/examples/third_party/huggingface/text-to-image.py +++ b/examples/third_party/huggingface/text-to-image.py @@ -13,12 +13,12 @@ import marimo -__generated_with = "0.9.14" +__generated_with = "0.15.5" app = marimo.App(width="medium") @app.cell(hide_code=True) -def __(): +def _(): import marimo as mo import numpy as np import random @@ -44,22 +44,19 @@ def __(): MAX_SEED, mo, model_repo_id, - np, random, torch, - tqdm, - transformers, ) @app.cell -def __(mo, model_repo_id): +def _(mo, model_repo_id): mo.md(f"""# HuggingFace Text-to-Image: **{model_repo_id}**""") return @app.cell(hide_code=True) -def __(): +def _(): examples = [ "Astronaut in a jungle, cold color palette, muted colors, detailed, 8k", "An astronaut riding a green horse", @@ -69,14 +66,7 @@ def __(): @app.cell -def __( - DiffusionPipeline, - MAX_SEED, - mo, - model_repo_id, - random, - torch, -): +def _(DiffusionPipeline, MAX_SEED, mo, model_repo_id, random, torch): device = "cuda" if torch.cuda.is_available() else "cpu" if torch.cuda.is_available(): @@ -119,17 +109,17 @@ def infer( mo.output.clear() # Clear loading tdqm - return device, infer, pipe, torch_dtype + return (infer,) @app.cell -def __(mo): +def _(mo): get_prompt, set_prompt = mo.state("") return get_prompt, set_prompt @app.cell -def __(get_prompt, mo, set_prompt): +def _(get_prompt, mo, set_prompt): prompt = mo.ui.text_area( placeholder="Enter your prompt", label="Prompt", @@ -141,7 +131,7 @@ def __(get_prompt, mo, set_prompt): @app.cell -def __(examples, mo, set_prompt): +def _(examples, mo, set_prompt): def _on_click(example): def handle(v): set_prompt(example) @@ -157,11 +147,11 @@ def handle(v): ) example_options = mo.vstack(buttons) - return buttons, example_options + return (example_options,) @app.cell -def __(MAX_IMAGE_SIZE, MAX_SEED, example_options, mo, prompt): +def _(MAX_IMAGE_SIZE, MAX_SEED, example_options, mo, prompt): run_button = mo.ui.run_button(label="Run", kind="success", full_width=True) negative_prompt = mo.ui.text_area( @@ -206,7 +196,6 @@ def __(MAX_IMAGE_SIZE, MAX_SEED, example_options, mo, prompt): # Layout the main interface mo.vstack([prompt, run_button, advanced_settings]) return ( - advanced_settings, guidance_scale, height, negative_prompt, @@ -219,13 +208,13 @@ def __(MAX_IMAGE_SIZE, MAX_SEED, example_options, mo, prompt): @app.cell -def __(mo): +def _(mo): get_image, set_image = mo.state(None) return get_image, set_image @app.cell -def __( +def _( guidance_scale, height, infer, @@ -257,7 +246,7 @@ def __( @app.cell -def __(get_image): +def _(get_image): get_image() return diff --git a/examples/third_party/ibis/ibis_example.py b/examples/third_party/ibis/ibis_example.py index 35b73173f6b..86b607b7569 100644 --- a/examples/third_party/ibis/ibis_example.py +++ b/examples/third_party/ibis/ibis_example.py @@ -9,32 +9,31 @@ import marimo -__generated_with = "0.9.14" +__generated_with = "0.19.7" app = marimo.App(width="full") @app.cell(hide_code=True) -def __(mo): - mo.md( - f""" +def _(mo): + mo.md(f""" # Using `Ibis` in `marimo` > Ibis is a Python data analysis library that allows for expressive, efficient, and scalable data manipulation and query processing. - """ - ) + """) return @app.cell -def __(): +def _(): import marimo as mo import ibis import altair as alt + return alt, ibis, mo @app.cell -def __(ibis): +def _(ibis): df = ibis.read_csv( "https://gist.githubusercontent.com/ritchie46/cac6b337ea52281aa23c049250a4ff03/raw/89a957ff3919d90e6ef2d34235e6bf22304f3366/pokemon.csv" ) @@ -42,7 +41,7 @@ def __(ibis): @app.cell -def __(df, mo): +def _(df, mo): # get all unique values values_1 = df["Type 1"].execute().tolist() values_2 = df["Type 2"].execute().tolist() @@ -59,11 +58,11 @@ def __(df, mo): ) mo.hstack([type_1_filter, type_2_filter]) - return type_1_filter, type_2_filter, values_1, values_2 + return type_1_filter, type_2_filter @app.cell -def __(alt, filtered, mo): +def _(alt, filtered, mo): # Convert Ibis table to pandas for Altair filtered_df = filtered.execute() @@ -83,11 +82,11 @@ def __(alt, filtered, mo): _chart, legend_selection=True, label="Attack vs Defense" ) chart - return chart, filtered_df + return (chart,) @app.cell -def __(df, type_1_filter, type_2_filter): +def _(df, type_1_filter, type_2_filter): filtered = df if type_1_filter.value: filtered = filtered.filter(df["Type 1"] == type_1_filter.value) @@ -97,7 +96,7 @@ def __(df, type_1_filter, type_2_filter): @app.cell -def __(chart, mo): +def _(chart, mo): mo.ui.table(chart.value, selection=None) return diff --git a/examples/third_party/leafmap/leafmap_example.py b/examples/third_party/leafmap/leafmap_example.py index 201ab220f17..76cdc57cb80 100644 --- a/examples/third_party/leafmap/leafmap_example.py +++ b/examples/third_party/leafmap/leafmap_example.py @@ -8,30 +8,29 @@ import marimo -__generated_with = "0.9.20" +__generated_with = "0.19.7" app = marimo.App() @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - # Using Leafmap +def _(mo): + mo.md(""" + # Using Leafmap - This example shows how to render a `leafmap.Map` in marimo; just output it like any other object. - """ - ) + This example shows how to render a `leafmap.Map` in marimo; just output it like any other object. + """) return @app.cell -def __(): +def _(): import leafmap + return (leafmap,) @app.cell -def __(leafmap): +def _(leafmap): m = leafmap.Map(center=(40, -100), zoom=4, height="400px") m.add_basemap("HYBRID") m.add_basemap("Esri.NatGeoWorldMap") @@ -41,12 +40,13 @@ def __(leafmap): attribution="Google", ) m - return (m,) + return @app.cell -def __(): +def _(): import marimo as mo + return (mo,) diff --git a/examples/third_party/matplotlib/mandelbrot.py b/examples/third_party/matplotlib/mandelbrot.py index a556ac3f45b..467ba0dbc76 100644 --- a/examples/third_party/matplotlib/mandelbrot.py +++ b/examples/third_party/matplotlib/mandelbrot.py @@ -9,44 +9,44 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App() @app.cell(hide_code=True) -def __(mo): - mo.md("""# The Mandelbrot Set""") +def _(mo): + mo.md(""" + # The Mandelbrot Set + """) return @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - This program computes uses an iterative algorithm to visualize the - [_Mandelbrot set_](https://mathworld.wolfram.com/MandelbrotSet.html), - the set of complex numbers $c$ for which the sequence defined by the - iteration - - \[ - z_n = z_{n-1}^2 + c, \quad z_0 = 0 - \] - - is bounded in absolute value. - - - In the visualization, every point not in the set is colored by the - number of iterations the algorithm required to disprove its membership in - the set. In any iteration, points in the darkest region may - be in the computed set; once the number of iterations is very high, we - can be confident that the dark region is the desired Mandelbrot set. - """ - ) +def _(mo): + mo.md(r""" + This program computes uses an iterative algorithm to visualize the + [_Mandelbrot set_](https://mathworld.wolfram.com/MandelbrotSet.html), + the set of complex numbers $c$ for which the sequence defined by the + iteration + + \[ + z_n = z_{n-1}^2 + c, \quad z_0 = 0 + \] + + is bounded in absolute value. + + + In the visualization, every point not in the set is colored by the + number of iterations the algorithm required to disprove its membership in + the set. In any iteration, points in the darkest region may + be in the computed set; once the number of iterations is very high, we + can be confident that the dark region is the desired Mandelbrot set. + """) return @app.cell(hide_code=True) -def __(mo, n_max): +def _(mo, n_max): mo.md( f""" You can play with the number of iterations to see when points are @@ -59,7 +59,7 @@ def __(mo, n_max): @app.cell(hide_code=True) -def __(mo, reset_plot_scale, x_offset, y_offset, zoom): +def _(mo, reset_plot_scale, x_offset, y_offset, zoom): mo.md( f""" **Plot controls.** @@ -75,7 +75,7 @@ def __(mo, reset_plot_scale, x_offset, y_offset, zoom): @app.cell -def __(compute_mandelbrot, n_max, x_offset, y_offset, zoom): +def _(compute_mandelbrot, n_max, x_offset, y_offset, zoom): compute_mandelbrot(n_max.value, 2., 601, 401, zoom=zoom.value, x_offset=x_offset.value, @@ -84,19 +84,19 @@ def __(compute_mandelbrot, n_max, x_offset, y_offset, zoom): @app.cell -def __(mo): +def _(mo): n_max = mo.ui.slider(2, 256, step=1, value=30) return (n_max,) @app.cell -def __(mo): +def _(mo): reset_plot_scale = mo.ui.button(label="Click to reset") return (reset_plot_scale,) @app.cell -def __(mo, reset_plot_scale): +def _(mo, reset_plot_scale): reset_plot_scale zoom = mo.ui.slider(1, 10, step=0.1) @@ -106,7 +106,7 @@ def __(mo, reset_plot_scale): @app.cell -def __(np, plt): +def _(np, plt): import functools @functools.cache @@ -135,19 +135,22 @@ def compute_mandelbrot(N_max, some_threshold, nx, ny, zoom=1, ) plt.colorbar() return plt.gca() - return compute_mandelbrot, functools + + return (compute_mandelbrot,) @app.cell -def __(): +def _(): import numpy as np import matplotlib.pyplot as plt + return np, plt @app.cell -def __(): +def _(): import marimo as mo + return (mo,) diff --git a/examples/third_party/matplotlib/surfaces.py b/examples/third_party/matplotlib/surfaces.py index 673e545d0b4..af2f3337427 100644 --- a/examples/third_party/matplotlib/surfaces.py +++ b/examples/third_party/matplotlib/surfaces.py @@ -9,24 +9,26 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App() @app.cell(hide_code=True) -def __(mo): - mo.md("""# Surfaces""") +def _(mo): + mo.md(""" + # Surfaces + """) return @app.cell -def __(plot_3d_surface, selected_function): +def _(plot_3d_surface, selected_function): ax = plot_3d_surface(selected_function) return (ax,) @app.cell -def __(ax, xlim, ylim, zlim): +def _(ax, xlim, ylim, zlim): ax.set_xlim(xlim.value) ax.set_ylim(ylim.value) ax.set_zlim(zlim.value) @@ -35,7 +37,7 @@ def __(ax, xlim, ylim, zlim): @app.cell(hide_code=True) -def __( +def _( a, b, function, @@ -103,13 +105,13 @@ def __( @app.cell -def __(function, function_options): +def _(function, function_options): selected_function = function_options[function.value] return (selected_function,) @app.cell -def __(paraboloid, saddle, sphere, torus): +def _(paraboloid, saddle, sphere, torus): function_options = { "paraboloid": paraboloid, "saddle": saddle, @@ -120,7 +122,7 @@ def __(paraboloid, saddle, sphere, torus): @app.cell -def __(mo): +def _(mo): function = mo.ui.dropdown( options=[ "paraboloid", @@ -133,7 +135,7 @@ def __(mo): @app.cell -def __(plt): +def _(plt): def plot_3d_surface(surface_function): fig = plt.figure() ax = fig.add_subplot(111, projection='3d') @@ -148,17 +150,20 @@ def plot_3d_surface(surface_function): fig.set_size_inches(7, 7) return ax + return (plot_3d_surface,) @app.cell -def __(mo): - mo.md("""### Controls""") +def _(mo): + mo.md(""" + ### Controls + """) return @app.cell -def __(function, mo): +def _(function, mo): if function.value == "paraboloid": _xlim, _ylim, _zlim = (-1, 1), (-1, 1), (0, 2) elif function.value == "saddle": @@ -195,7 +200,7 @@ def __(function, mo): @app.cell -def __(function, mo): +def _(function, mo): if function.value == "paraboloid": a = mo.ui.slider(1, 10) b = mo.ui.slider(1, 10) @@ -238,34 +243,37 @@ def __(function, mo): @app.cell -def __(grid, saddle_param_a, saddle_param_b): +def _(grid, saddle_param_a, saddle_param_b): def saddle(): x, y = grid(xlim=(-1, 1), ylim=(-1, 1)) return x, y, saddle_param_a.value*x**2 - saddle_param_b.value*y**4 + return (saddle,) @app.cell -def __(a, b, grid): +def _(a, b, grid): def paraboloid(): x, y = grid(xlim=(-1, 1), ylim=(-1, 1)) return x, y, a.value*x**2 / 2 + b.value*y**2/ 2 + return (paraboloid,) @app.cell -def __(grid, np, sphere_param_r): +def _(grid, np, sphere_param_r): def sphere(): theta, phi = grid(xlim=(0, 2*np.pi), ylim=(0, np.pi)) x = np.cos(theta)*np.sin(phi) y = np.sin(theta)*np.sin(phi) z = np.cos(phi) return sphere_param_r.value*x, sphere_param_r.value*y, sphere_param_r.value*z + return (sphere,) @app.cell -def __(grid, np, torus_param_a, torus_param_c): +def _(grid, np, torus_param_a, torus_param_c): def torus(): theta, phi = grid((0, 2 * np.pi), (0, 2 * np.pi)) center_radius = torus_param_c.value @@ -275,31 +283,35 @@ def torus(): y = (center_radius + tube_radius*np.cos(theta)) * np.sin(phi) z = tube_radius*np.sin(theta) return x, y, z + return (torus,) @app.cell -def __(np): +def _(np): def grid(xlim, ylim): xmin, xmax = xlim ymin, ymax = ylim x = np.linspace(xmin, xmax, 100) y = np.linspace(ymin, ymax, 100) return np.meshgrid(x, y) + return (grid,) @app.cell -def __(): +def _(): import matplotlib.pyplot as plt from mpl_toolkits.mplot3d import Axes3D import numpy as np - return Axes3D, np, plt + + return np, plt @app.cell -def __(): +def _(): import marimo as mo + return (mo,) diff --git a/examples/third_party/motherduck/embeddings/embeddings_explorer.py b/examples/third_party/motherduck/embeddings/embeddings_explorer.py index ad59d316abb..15825bcdeef 100644 --- a/examples/third_party/motherduck/embeddings/embeddings_explorer.py +++ b/examples/third_party/motherduck/embeddings/embeddings_explorer.py @@ -16,9 +16,17 @@ import marimo +__generated_with = "0.16.0" app = marimo.App(width="medium") +with app.setup: + # Use this notebook to follow along with the tutorial at + # https://motherduck.com/blog/MotherDuck-Visualize-Embeddings-Marimo/ + import marimo as mo -@app.cell -def __(): - return + + + + +if __name__ == "__main__": + app.run() diff --git a/examples/third_party/motherduck/embeddings/embeddings_explorer_final.py b/examples/third_party/motherduck/embeddings/embeddings_explorer_final.py index c2b3e689119..a57527476ce 100644 --- a/examples/third_party/motherduck/embeddings/embeddings_explorer_final.py +++ b/examples/third_party/motherduck/embeddings/embeddings_explorer_final.py @@ -16,12 +16,12 @@ import marimo -__generated_with = "0.9.20" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell -def __(): +def _(): # Data manipulation and database connections import polars as pl import duckdb @@ -38,21 +38,21 @@ def __(): import numpy as np from sklearn.decomposition import PCA - return PCA, alt, duckdb, hdbscan, mo, np, numba, pl, pyarrow, umap + return PCA, alt, hdbscan, mo, np, pl, umap @app.cell -def __(mo): +def _(mo): _df = mo.sql( """ ATTACH IF NOT EXISTS 'md:my_db' """ ) - return (my_db,) + return @app.cell -def __(mo, my_db): +def _(mo): _df = mo.sql( """ CREATE OR REPLACE TABLE my_db.demo_embedding AS @@ -63,11 +63,11 @@ def __(mo, my_db): LIMIT 50000; """ ) - return (demo_embedding,) + return @app.cell -def __(demo_embedding, mo, my_db): +def _(mo): embeddings = mo.sql( """ SELECT *, embedding(title) as text_embedding @@ -79,7 +79,7 @@ def __(demo_embedding, mo, my_db): @app.cell -def __(PCA, hdbscan, np, umap): +def _(PCA, hdbscan, np, umap): def umap_reduce(np_array): """ Reduce the dimensionality of the embeddings to 2D using @@ -117,17 +117,17 @@ def cluster_points(np_array, min_cluster_size=4, max_cluster_size=50): @app.cell -def __(cluster_points, embeddings, mo, umap_reduce): +def _(cluster_points, embeddings, mo, umap_reduce): with mo.status.spinner("Clustering points...") as _s: embeddings_array = embeddings["text_embedding"].to_numpy() hdb_labels = cluster_points(embeddings_array) _s.update("Reducing dimensionality...") embeddings_2d = umap_reduce(embeddings_array) - return embeddings_2d, embeddings_array, hdb_labels + return embeddings_2d, hdb_labels @app.cell -def __(embeddings, embeddings_2d, hdb_labels, pl): +def _(embeddings, embeddings_2d, hdb_labels, pl): data = embeddings.lazy() # Lazy evaluation for performance data = data.with_columns( text_embedding_2d_1=embeddings_2d[:, 0], @@ -145,7 +145,7 @@ def __(embeddings, embeddings_2d, hdb_labels, pl): @app.cell -def __(alt, data, mo): +def _(alt, data, mo): chart = ( alt.Chart(data) .mark_point() @@ -162,7 +162,7 @@ def __(alt, data, mo): @app.cell -def __(chart): +def _(chart): chart.value return diff --git a/examples/third_party/nvidia/nims.py b/examples/third_party/nvidia/nims.py new file mode 100644 index 00000000000..35e27448cf0 --- /dev/null +++ b/examples/third_party/nvidia/nims.py @@ -0,0 +1,135 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "marimo", +# "requests==2.32.3", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell(hide_code=True) +def _(mo): + default_api_token = ( + mo.query_params().get("token") or mo.cli_args().get("token") or "" + ) + api_token = mo.ui.text( + label="API Token", value=default_api_token, kind="password" + ) + return api_token, default_api_token + + +@app.cell +def _(api_token, default_api_token): + api_token if not default_api_token else None + api_token + return + + +@app.cell(hide_code=True) +def _(mo): + temperature = mo.ui.number( + value=0.2, start=0, stop=1, step=0.05, label="Temperature" + ) + top_p = mo.ui.number(value=0.7, start=0, stop=1, step=0.05, label="Top P") + mo.accordion({"Settings": mo.hstack([temperature, top_p])}) + return temperature, top_p + + +@app.cell +def _(mo): + uploaded_file = mo.ui.file(kind="area") + uploaded_file + return (uploaded_file,) + + +@app.cell +def _(mo, uploaded_file): + import base64 + import io + + mo.stop(not uploaded_file.value) + base64_encoded = base64.b64encode(uploaded_file.contents()).decode("utf-8") + return (base64_encoded,) + + +@app.cell +def _(base64_encoded, mo): + data_uri = f"data:image/png;base64,{base64_encoded}" + mo.Html(f'') + return (data_uri,) + + +@app.cell(hide_code=True) +def _(mo): + prompt = mo.ui.text_area( + label="Ask a question about the photo", + full_width=True, + placeholder="What is in this image?", + ).form(bordered=False) + prompt + return (prompt,) + + +@app.cell +def _(): + import requests + + url = "https://ai.api.nvidia.com/v1/vlm/nvidia/neva-22b" + return requests, url + + +@app.cell +def _(api_token, data_uri, mo, prompt, requests, temperature, top_p, url): + mo.stop(not prompt.value) + + payload = { + "messages": [ + { + "content": f'{prompt.value} {data_uri}" />', + "name": None, + "role": "user", + } + ], + "temperature": temperature.value, + "top_p": top_p.value, + "max_tokens": 1024, + "seed": 42, + "stream": False, + } + + headers = { + "authorization": f"Bearer {api_token.value}", + "accept": "application/json", + "content-type": "application/json", + } + + response = requests.post(url, json=payload, headers=headers) + res = response.json() + return (res,) + + +@app.cell +def _(mo, res): + try: + content = res["choices"][0]["message"]["content"] + except: + content = res + + mo.md(f"**Response:** \n{res}") + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/third_party/plotly/area_chart.py b/examples/third_party/plotly/area_chart.py new file mode 100644 index 00000000000..dc5bb6b0658 --- /dev/null +++ b/examples/third_party/plotly/area_chart.py @@ -0,0 +1,160 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "marimo", +# "pandas==2.3.3", +# "plotly==6.5.1", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + mo.md(""" + # Reactive Plotly Area Charts + + Use `mo.ui.plotly` to make area charts reactive. Select data by dragging + a box on the chart, and get the selected points in Python! + + Area charts are scatter traces with `fill='tozeroy'` or similar fill options. + """) + return + + +@app.cell +def _(): + import plotly.graph_objects as go + import pandas as pd + + return go, pd + + +@app.cell +def _(pd): + # Create sample data + data = pd.DataFrame( + { + "month": list(range(1, 13)), + "revenue": [45, 52, 48, 65, 72, 68, 80, 85, 78, 90, 95, 88], + "costs": [30, 35, 32, 40, 45, 42, 50, 48, 52, 55, 58, 54], + } + ) + data + return (data,) + + +@app.cell(hide_code=True) +def _(data, go, mo): + # 1. Basic area chart with fill='tozeroy' + fig1 = go.Figure() + fig1.add_trace( + go.Scatter( + x=data["month"], + y=data["revenue"], + fill="tozeroy", + mode="lines", + name="Revenue", + line=dict(color="#636EFA", width=2), + ) + ) + fig1.update_layout( + title="Monthly Revenue (Area Chart)", + xaxis_title="Month", + yaxis_title="Revenue ($1000s)", + ) + + area_chart = mo.ui.plotly(fig1) + area_chart + return (area_chart,) + + +@app.cell +def _(area_chart, mo): + mo.md(f""" + ## Basic Area Chart (fill='tozeroy') + + **Instructions:** Use the box select tool (in the toolbar) to select a range. + + ### Selected Points: + {area_chart.value} + + ### Selection Range: + {area_chart.ranges} + + ### Indices: + {area_chart.indices} + """) + return + + +@app.cell(hide_code=True) +def _(data, go, mo): + # 2. Stacked area chart + fig2 = go.Figure() + fig2.add_trace( + go.Scatter( + x=data["month"], + y=data["costs"], + fill="tozeroy", + stackgroup="one", + mode="lines", + name="Costs", + line=dict(color="#EF553B", width=2), + ) + ) + fig2.add_trace( + go.Scatter( + x=data["month"], + y=data["revenue"] - data["costs"], + fill="tonexty", + stackgroup="one", + mode="lines", + name="Profit", + line=dict(color="#00CC96", width=2), + ) + ) + fig2.update_layout( + title="Costs vs Profit (Stacked Area)", + xaxis_title="Month", + yaxis_title="Amount ($1000s)", + ) + + stacked_area = mo.ui.plotly(fig2) + stacked_area + return (stacked_area,) + + +@app.cell +def _(mo, stacked_area): + mo.md(f""" + ## Stacked Area Chart (stackgroup) + + **Instructions:** Use the box select tool to select a range. + Points from both areas will be returned! + + ### Selected Points: + {stacked_area.value} + + ### Number of selected points: + {len(stacked_area.value)} + + ### Selection Range: + {stacked_area.ranges} + """) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/third_party/plotly/bar_chart.py b/examples/third_party/plotly/bar_chart.py new file mode 100644 index 00000000000..b236a893780 --- /dev/null +++ b/examples/third_party/plotly/bar_chart.py @@ -0,0 +1,237 @@ +# /// script +# requires-python = ">=3.9" +# dependencies = [ +# "marimo", +# "plotly==6.5.1", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + import plotly.graph_objects as go + + return go, mo + + +@app.cell +def _(mo): + mo.md(""" + # Plotly Bar Chart Selection + + This example demonstrates reactive bar chart selections with `mo.ui.plotly`. + Select bars by clicking or dragging a box to see the selected data. + """) + return + + +@app.cell +def _(go, mo): + mo.md("## Simple Vertical Bar Chart") + + # Create a simple bar chart with categorical data + fig_simple = go.Figure( + data=go.Bar( + x=["Product A", "Product B", "Product C", "Product D", "Product E"], + y=[20, 35, 30, 25, 40], + marker_color="steelblue", + ) + ) + + fig_simple.update_layout( + title="Sales by Product", + xaxis_title="Product", + yaxis_title="Sales ($k)", + ) + + # Wrap with mo.ui.plotly to make it reactive + bar_chart = mo.ui.plotly(fig_simple) + return (bar_chart,) + + +@app.cell +def _(bar_chart, mo): + mo.md(f""" + ### Interactive Chart + + {bar_chart} + + ### Selected Bars + + Select bars by dragging a box over them: + + ```python + {bar_chart.value} + ``` + """) + return + + +@app.cell +def _(go, mo): + mo.md("## Stacked Bar Chart") + + # Create a stacked bar chart + fig_stacked = go.Figure() + fig_stacked.add_trace( + go.Bar( + x=["Q1", "Q2", "Q3", "Q4"], + y=[15, 20, 18, 22], + name="Product A", + marker_color="steelblue", + ) + ) + fig_stacked.add_trace( + go.Bar( + x=["Q1", "Q2", "Q3", "Q4"], + y=[10, 15, 12, 18], + name="Product B", + marker_color="lightcoral", + ) + ) + + fig_stacked.update_layout( + title="Quarterly Sales by Product", + xaxis_title="Quarter", + yaxis_title="Sales ($k)", + barmode="stack", + ) + + stacked_chart = mo.ui.plotly(fig_stacked) + return (stacked_chart,) + + +@app.cell +def _(mo, stacked_chart): + mo.md(f""" + ### Stacked Bar Chart + + {stacked_chart} + + **Note:** When you select a stacked bar, all segments at that position are returned! + + ### Selected Data + + ```python + {stacked_chart.value} + ``` + """) + return + + +@app.cell +def _(go, mo): + mo.md("## Grouped Bar Chart") + + # Create a grouped bar chart + fig_grouped = go.Figure() + fig_grouped.add_trace( + go.Bar( + x=["Jan", "Feb", "Mar", "Apr"], + y=[20, 25, 22, 28], + name="2024", + marker_color="steelblue", + ) + ) + fig_grouped.add_trace( + go.Bar( + x=["Jan", "Feb", "Mar", "Apr"], + y=[18, 23, 20, 25], + name="2025", + marker_color="lightcoral", + ) + ) + + fig_grouped.update_layout( + title="Monthly Sales Comparison", + xaxis_title="Month", + yaxis_title="Sales ($k)", + barmode="group", + ) + + grouped_chart = mo.ui.plotly(fig_grouped) + return (grouped_chart,) + + +@app.cell +def _(grouped_chart, mo): + mo.md(f""" + ### Grouped Bar Chart + + {grouped_chart} + + **Note:** When you select a category, all bars in that group are returned! + + ### Selected Data + + ```python + {grouped_chart.value} + ``` + """) + return + + +@app.cell +def _(go, mo): + mo.md("## Horizontal Bar Chart") + + # Create a horizontal bar chart + fig_horizontal = go.Figure( + data=go.Bar( + x=[30, 45, 35, 50, 40], + y=["Team A", "Team B", "Team C", "Team D", "Team E"], + orientation="h", + marker_color="mediumseagreen", + ) + ) + + fig_horizontal.update_layout( + title="Team Performance", + xaxis_title="Score", + yaxis_title="Team", + ) + + horizontal_chart = mo.ui.plotly(fig_horizontal) + return (horizontal_chart,) + + +@app.cell +def _(horizontal_chart, mo): + mo.md(f""" + ### Horizontal Bar Chart + + {horizontal_chart} + + ### Selected Data + + ```python + {horizontal_chart.value} + ``` + """) + return + + +@app.cell +def _(mo): + mo.md(""" + ## How It Works + + - **Selection**: Drag a box over bars to select them + - **Categorical axes**: Each bar spans from (index - 0.5) to (index + 0.5) + - **Stacked/Grouped**: All bars at a position are returned when that position is selected + - **Data format**: Returns a list of `{"x": value, "y": value, "curveNumber": trace_index}` + + This allows you to build reactive dashboards where selecting bars filters other + visualizations or displays detailed information about the selected data. + """) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/third_party/plotly/box_chart.py b/examples/third_party/plotly/box_chart.py new file mode 100644 index 00000000000..6c172e9cbc3 --- /dev/null +++ b/examples/third_party/plotly/box_chart.py @@ -0,0 +1,306 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "marimo", +# "pandas==2.3.3", +# "plotly==6.5.1", +# ] +# /// + +import marimo + +__generated_with = "0.20.2" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + import pandas as pd + import plotly.express as px + import plotly.graph_objects as go + + return go, mo, pd, px + + +@app.cell +def _(mo): + mo.md(""" + # Reactive Plotly Box Plot Selection + + This example demonstrates reactive box plot selections with `mo.ui.plotly`. + + Try: + - **Drag a box** over jittered sample points to select them + - **Click** a box element (median line, whisker, or box body) to select the + entire group + - Watch the table and stats below update from your selection + + The `customdata` field embeds the original row ID (`sample_id`) so selected + points map back to the source DataFrame without relying on raw indices. + """) + return + + +@app.cell +def _(pd): + rows = [ + ("ENG-01", "Engineering", "Day", "West", 72), + ("ENG-02", "Engineering", "Day", "West", 78), + ("ENG-03", "Engineering", "Day", "East", 81), + ("ENG-04", "Engineering", "Night", "East", 75), + ("ENG-05", "Engineering", "Night", "West", 84), + ("ENG-06", "Engineering", "Night", "Central", 89), + ("SAL-01", "Sales", "Day", "West", 61), + ("SAL-02", "Sales", "Day", "Central", 66), + ("SAL-03", "Sales", "Day", "East", 70), + ("SAL-04", "Sales", "Night", "West", 64), + ("SAL-05", "Sales", "Night", "East", 73), + ("SAL-06", "Sales", "Night", "Central", 77), + ("SUP-01", "Support", "Day", "East", 58), + ("SUP-02", "Support", "Day", "Central", 62), + ("SUP-03", "Support", "Day", "West", 68), + ("SUP-04", "Support", "Night", "East", 60), + ("SUP-05", "Support", "Night", "Central", 65), + ("SUP-06", "Support", "Night", "West", 71), + ] + df = pd.DataFrame(rows, columns=["sample_id", "team", "shift", "region", "score"]) + df["passed"] = df["score"] >= 75 + return (df,) + + +@app.cell +def _(): + def selected_rows(selection, data): + """Map a mo.ui.plotly selection back to rows in the source DataFrame.""" + empty = data.iloc[0:0].copy() + if not selection: + return empty + + # Prefer sample_id embedded via customdata. + # Box-body clicks embed sample_id as customdata[0], not a top-level key. + ids = [] + for row in selection: + if isinstance(row.get("sample_id"), str): + ids.append(row["sample_id"]) + else: + cd = row.get("customdata") + if isinstance(cd, (list, tuple)) and cd and isinstance(cd[0], str): + ids.append(cd[0]) + if ids: + return ( + data[data["sample_id"].isin(ids)] + .drop_duplicates("sample_id") + .sort_values("sample_id") + ) + + # Fall back to pointIndex + indices = sorted({ + row["pointIndex"] + for row in selection + if isinstance(row.get("pointIndex"), int) + and 0 <= row["pointIndex"] < len(data) + }) + if indices: + return data.iloc[indices].copy() + + return empty + + return (selected_rows,) + + +@app.cell +def _(df, go, mo): + mo.md("## Single-Trace Box Plot") + + fig_single = go.Figure( + data=go.Box( + x=df["team"], + y=df["score"], + customdata=df[["sample_id", "shift", "region", "passed"]], + boxpoints="all", + jitter=0.35, + pointpos=0, + marker=dict(size=10, opacity=0.8, color="#1f77b4"), + line=dict(color="#1f77b4"), + hovertemplate=( + "sample_id=%{customdata[0]}
" + "team=%{x}
" + "score=%{y}
" + "shift=%{customdata[1]}
" + "region=%{customdata[2]}
" + "passed=%{customdata[3]}" + ), + name="score", + ) + ) + fig_single.update_layout( + title="Score by Team — drag over points or click a box", + xaxis_title="Team", + yaxis_title="Score", + dragmode="select", + ) + + box_single = mo.ui.plotly(fig_single) + box_single + return (box_single,) + + +@app.cell +def _(box_single, df, mo, selected_rows): + _sel = selected_rows(box_single.value, df) + + _summary = ( + f"{len(_sel)} rows selected — " + f"avg score: {_sel['score'].mean():.1f} — " + f"pass rate: {_sel['passed'].mean():.0%}" + if not _sel.empty + else "No rows selected yet." + ) + + mo.md(f""" + ### Single-Trace Selection + + **{_summary}** + + **Indices:** {box_single.indices} + """) + return + + +@app.cell +def _(box_single, df, mo, selected_rows): + mo.ui.table(selected_rows(box_single.value, df)) + return + + +@app.cell +def _(df, mo, px): + mo.md("## Grouped Box Plot") + + fig_grouped = px.box( + df, + x="team", + y="score", + color="shift", + points="all", + custom_data=["sample_id", "shift", "region", "passed"], + title="Score by Team and Shift — drag to compare groups", + ) + fig_grouped.update_traces( + jitter=0.35, + pointpos=0, + hovertemplate=( + "sample_id=%{customdata[0]}
" + "team=%{x}
" + "score=%{y}
" + "shift=%{customdata[1]}
" + "region=%{customdata[2]}
" + "passed=%{customdata[3]}" + ), + ) + fig_grouped.update_layout(dragmode="select", xaxis_title="Team", yaxis_title="Score") + + box_grouped = mo.ui.plotly(fig_grouped) + box_grouped + return (box_grouped,) + + +@app.cell +def _(box_grouped, df, mo, selected_rows): + _sel_g = selected_rows(box_grouped.value, df) + + _summary_g = ( + f"{len(_sel_g)} rows — " + + ", ".join( + f"{team}: {count}" + for team, count in _sel_g.groupby("team").size().items() + ) + if not _sel_g.empty + else "No rows selected yet." + ) + + mo.md(f""" + ### Grouped Selection + + **{_summary_g}** + + **Indices:** {box_grouped.indices} + + **Range:** {box_grouped.ranges} + """) + return + + +@app.cell +def _(box_grouped, df, mo, selected_rows): + mo.ui.table(selected_rows(box_grouped.value, df)) + return + + +@app.cell +def _(df, go, mo): + mo.md("## Horizontal Box Plot") + + fig_horizontal = go.Figure( + data=go.Box( + x=df["score"], + y=df["team"], + orientation="h", + customdata=df[["sample_id", "shift", "region", "passed"]], + boxpoints="all", + jitter=0.35, + pointpos=0, + marker=dict(size=10, opacity=0.8, color="#2a9d8f"), + line=dict(color="#2a9d8f"), + hovertemplate=( + "sample_id=%{customdata[0]}
" + "team=%{y}
" + "score=%{x}
" + "shift=%{customdata[1]}
" + "region=%{customdata[2]}
" + "passed=%{customdata[3]}" + ), + name="score", + ) + ) + fig_horizontal.update_layout( + title="Score by Team (horizontal) — same data, transposed", + xaxis_title="Score", + yaxis_title="Team", + dragmode="select", + ) + + box_horizontal = mo.ui.plotly(fig_horizontal) + box_horizontal + return (box_horizontal,) + + +@app.cell +def _(box_horizontal, df, mo, selected_rows): + _sel_h = selected_rows(box_horizontal.value, df) + + _summary_h = ( + f"{len(_sel_h)} rows — avg score: {_sel_h['score'].mean():.1f}" + if not _sel_h.empty + else "No rows selected yet." + ) + + mo.md(f""" + ### Horizontal Selection + + **{_summary_h}** + + **Indices:** {box_horizontal.indices} + """) + return + + +@app.cell +def _(box_horizontal, df, mo, selected_rows): + mo.ui.table(selected_rows(box_horizontal.value, df)) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/third_party/plotly/funnel_chart.py b/examples/third_party/plotly/funnel_chart.py new file mode 100644 index 00000000000..af6f59140c2 --- /dev/null +++ b/examples/third_party/plotly/funnel_chart.py @@ -0,0 +1,215 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "marimo", +# "pandas==2.3.3", +# "plotly==6.5.1", +# ] +# /// + +import marimo + +__generated_with = "0.20.2" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + import pandas as pd + import plotly.express as px + import plotly.graph_objects as go + + return go, mo, pd, px + + +@app.cell +def _(mo): + mo.md(""" + # Reactive Plotly Funnel Chart Selection + + This example demonstrates reactive funnel chart selections with `mo.ui.plotly`. + + Funnel charts visualise drop-off at each stage of a sequential process (e.g. a + sales pipeline or marketing funnel). Each segment represents one stage; the bar + width shows the absolute count and the percent metrics show retention relative to + the initial, previous, and overall totals. + + Try: + - **Click** any funnel stage to select it — the table below updates instantly + - **Drag a box** (set `dragmode="select"`) over multiple stages to select a range + - Switch between the multi-trace and `FunnelArea` tabs to explore variants + """) + return + + +@app.cell +def _(pd): + pipeline_data = pd.DataFrame( + { + "stage": [ + "Leads", + "Qualified", + "Proposal", + "Negotiation", + "Closed Won", + ], + "count": [5000, 2800, 1400, 620, 310], + "region": ["Global"] * 5, + } + ) + return (pipeline_data,) + + +@app.cell +def _(mo, pipeline_data, px): + mo.md("## Single-Trace Funnel (`px.funnel`)") + + fig_funnel = px.funnel( + pipeline_data, + x="count", + y="stage", + title="Sales Pipeline — click a stage to inspect it", + labels={"count": "Leads", "stage": "Stage"}, + ) + fig_funnel.update_layout( + dragmode="select", + margin=dict(l=120), + ) + + funnel_plot = mo.ui.plotly(fig_funnel) + funnel_plot + return (funnel_plot,) + + +@app.cell +def _(funnel_plot, mo, pipeline_data): + _pts = funnel_plot.value + + if _pts: + _stage = _pts[0].get("y") or _pts[0].get("label") + _row = pipeline_data[pipeline_data["stage"] == _stage] + _pct_initial = _pts[0].get("percentInitial", 1.0) + _summary = ( + f"**{_stage}** — " + f"{_row['count'].iloc[0]:,} leads — " + f"{_pct_initial:.0%} retention from start" + ) + else: + _summary = "Click any funnel stage to inspect it." + + mo.md(f""" + ### Selected Stage + + {_summary} + + **Raw selection:** {funnel_plot.value} + """) + return + + +@app.cell +def _(mo, pd): + mo.md("## Multi-Trace Funnel — Regional Breakdown") + + regional = pd.DataFrame( + { + "stage": [ + "Leads", "Qualified", "Proposal", "Negotiation", "Closed Won", + "Leads", "Qualified", "Proposal", "Negotiation", "Closed Won", + ], + "count": [3000, 1600, 900, 380, 190, 2000, 1200, 500, 240, 120], + "region": ["North America"] * 5 + ["Europe"] * 5, + } + ) + return (regional,) + + +@app.cell +def _(mo, px, regional): + fig_regional = px.funnel( + regional, + x="count", + y="stage", + color="region", + title="Regional Pipeline — select stages to compare regions", + ) + fig_regional.update_layout(dragmode="select") + + regional_plot = mo.ui.plotly(fig_regional) + regional_plot + return (regional_plot,) + + +@app.cell +def _(mo, regional, regional_plot): + _pts = regional_plot.value + + if _pts: + _rows = regional[ + regional["stage"].isin( + [p.get("y") or p.get("label") for p in _pts if p] + ) + ] + _table = mo.ui.table(_rows.reset_index(drop=True)) + else: + _table = mo.ui.table(regional.iloc[0:0]) + + mo.vstack([ + mo.md(f"**{len(_pts)} stage(s) selected** | Indices: {regional_plot.indices}"), + _table, + ]) + return + + +@app.cell +def _(go, mo): + mo.md("## Funnel Area Chart (`go.Funnelarea`)") + + fig_area = go.Figure( + go.Funnelarea( + labels=["Awareness", "Interest", "Consideration", "Intent", "Purchase"], + values=[10000, 6000, 3000, 1200, 400], + textinfo="label+percent", + hovertemplate=( + "%{label}
" + "Count: %{value:,}
" + "Retention from start: %{percentInitial:.1%}
" + "Retention from previous: %{percentPrevious:.1%}" + "" + ), + ) + ) + fig_area.update_layout(title="Marketing Funnel Area — click a segment") + + area_plot = mo.ui.plotly(fig_area) + area_plot + return (area_plot,) + + +@app.cell +def _(area_plot, mo): + _pts = area_plot.value + + if _pts: + _p = _pts[0] + _detail = ( + f"**{_p.get('label')}** — " + f"{_p.get('value', 0):,} users — " + f"{_p.get('percentInitial', 1.0):.1%} of total" + ) + else: + _detail = "Click any segment to inspect it." + + mo.md(f""" + ### Selected Segment + + {_detail} + + **Raw selection:** {area_plot.value} + """) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/third_party/plotly/heatmap.py b/examples/third_party/plotly/heatmap.py new file mode 100644 index 00000000000..c1707938608 --- /dev/null +++ b/examples/third_party/plotly/heatmap.py @@ -0,0 +1,59 @@ +import marimo + +__generated_with = "0.18.4" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + import plotly.graph_objects as go + import numpy as np + + # 1. Create your heatmap data + z = np.random.rand(10, 10) * 100 + + # 2. Create the plotly figure + fig = go.Figure( + data=go.Heatmap( + z=z, + x=["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"], + y=[ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + "Mon2", + "Tue2", + "Wed2", + ], + colorscale="Viridis", + ) + ) + + # 3. Wrap it with mo.ui.plotly + heatmap = mo.ui.plotly(fig) + + heatmap + return heatmap, mo + + +@app.cell +def _(heatmap, mo): + # 4. Display it + mo.md(f""" + ## Sales Heatmap + + {heatmap} + + ### Selected Cells: + {heatmap.value} + """) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/third_party/plotly/histogram.py b/examples/third_party/plotly/histogram.py new file mode 100644 index 00000000000..7dd9746ff50 --- /dev/null +++ b/examples/third_party/plotly/histogram.py @@ -0,0 +1,190 @@ +# /// script +# requires-python = ">=3.9" +# dependencies = [ +# "marimo", +# "plotly==6.5.1", +# ] +# /// + +import marimo + +__generated_with = "0.20.2" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import random + + import marimo as mo + import plotly.graph_objects as go + + return go, mo, random + + +@app.cell +def _(mo): + mo.md(""" + # Reactive Plotly Histograms + + This example demonstrates reactive histogram selections with `mo.ui.plotly`. + + Select bins by dragging a box (or use lasso with Shift). Selections are + returned to Python as selected rows with `pointIndex` values. + """) + return + + +@app.cell +def _(go, mo, random): + mo.md("## Numeric Histogram") + + numeric_rng = random.Random(42) + values = [numeric_rng.gauss(0, 1.0) for _ in range(900)] + + numeric_fig = go.Figure( + data=go.Histogram( + x=values, + nbinsx=28, + marker_color="#1f77b4", + opacity=0.85, + name="numeric", + ) + ) + numeric_fig.update_layout( + title="Numeric Histogram", + xaxis_title="value", + yaxis_title="count", + dragmode="select", + ) + + numeric_hist = mo.ui.plotly(numeric_fig) + numeric_hist + return numeric_hist, values + + +@app.cell +def _(numeric_hist, values): + selected_rows = numeric_hist.value or [] + + # Show first selected original values by pointIndex. + mapped_original_values = [ + values[row["pointIndex"]] + for row in selected_rows[:10] + if isinstance(row.get("pointIndex"), int) + ] + + return mapped_original_values, selected_rows + + +@app.cell +def _(mapped_original_values, mo, numeric_hist, selected_rows): + mo.md(f""" + ### Numeric Selection Output + + **Selected rows:** {len(selected_rows)} + + **Selected indices:** {len(numeric_hist.indices)} + + **Selection range:** {numeric_hist.ranges} + + **First mapped original values (using pointIndex):** + {mapped_original_values} + """) + return + + +@app.cell +def _(go, mo, random): + mo.md("## Categorical Histogram") + + categorical_rng = random.Random(7) + categories = [ + categorical_rng.choice(["A", "B", "C", "D"]) for _ in range(500) + ] + + categorical_fig = go.Figure( + data=go.Histogram( + x=categories, + marker_color="#2ca02c", + opacity=0.85, + name="categorical", + ) + ) + categorical_fig.update_layout( + title="Categorical Histogram", + xaxis_title="category", + yaxis_title="count", + dragmode="select", + ) + + categorical_hist = mo.ui.plotly(categorical_fig) + categorical_hist + return (categorical_hist,) + + +@app.cell +def _(categorical_hist, mo): + mo.md(f""" + ### Categorical Selection Output + + **Selected rows:** {len(categorical_hist.value)} + + **Selected indices:** {len(categorical_hist.indices)} + + **Selection range:** {categorical_hist.ranges} + + **Preview:** + {categorical_hist.value[:10]} + """) + return + + +@app.cell +def _(go, mo, random): + mo.md("## Horizontal Histogram") + + horizontal_rng = random.Random(19) + y_values = [horizontal_rng.gauss(50, 12) for _ in range(800)] + + horizontal_fig = go.Figure( + data=go.Histogram( + y=y_values, + orientation="h", + nbinsy=24, + marker_color="#ff7f0e", + opacity=0.85, + name="horizontal", + ) + ) + horizontal_fig.update_layout( + title="Horizontal Histogram", + xaxis_title="count", + yaxis_title="value", + dragmode="select", + ) + + horizontal_hist = mo.ui.plotly(horizontal_fig) + horizontal_hist + return (horizontal_hist,) + + +@app.cell +def _(horizontal_hist, mo): + mo.md(f""" + ### Horizontal Selection Output + + **Selected rows:** {len(horizontal_hist.value)} + + **Selected indices:** {len(horizontal_hist.indices)} + + **Selection range:** {horizontal_hist.ranges} + + **Preview:** + {horizontal_hist.value[:10]} + """) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/third_party/plotly/image_selection.py b/examples/third_party/plotly/image_selection.py index e42fe107c1f..4dbc8315452 100644 --- a/examples/third_party/plotly/image_selection.py +++ b/examples/third_party/plotly/image_selection.py @@ -10,27 +10,30 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App(width="full") @app.cell -def __(): +def _(): import marimo as mo import plotly.graph_objects as go import plotly.express as px from skimage import io + return go, io, mo, px @app.cell(hide_code=True) -def __(mo): - mo.md("""#Example: Image with range selection""") +def _(mo): + mo.md(""" + #Example: Image with range selection + """) return @app.cell -def __(io, mo, px): +def _(io, mo, px): # Create figure img = io.imread("https://marimo.io/logo.png") _fig = px.imshow(img) @@ -38,11 +41,11 @@ def __(io, mo, px): # Wrap with marimo plot = mo.ui.plotly(_fig) - return img, plot + return (plot,) @app.cell -def __(mo, plot): +def _(mo, plot): mo.hstack( [ plot, @@ -57,13 +60,15 @@ def __(mo, plot): @app.cell -def __(mo): - mo.md("""#Example: Image with overlaid trace""") +def _(mo): + mo.md(""" + #Example: Image with overlaid trace + """) return @app.cell -def __(go, mo): +def _(go, mo): # Create figure _fig = go.Figure() @@ -92,7 +97,7 @@ def __(go, mo): @app.cell -def __(mo, plot2): +def _(mo, plot2): mo.hstack( [ plot2, diff --git a/examples/third_party/plotly/line_chart.py b/examples/third_party/plotly/line_chart.py new file mode 100644 index 00000000000..542e3cefa4b --- /dev/null +++ b/examples/third_party/plotly/line_chart.py @@ -0,0 +1,183 @@ +# /// script +# requires-python = ">=3.9" +# dependencies = [ +# "marimo", +# "pandas==2.3.3", +# "plotly==6.5.1", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + mo.md(""" + # Reactive Plotly Line Charts + + Use `mo.ui.plotly` to make line charts reactive. Select data by dragging + a box on the chart, and get the selected points in Python! + """) + return + + +@app.cell +def _(): + import plotly.graph_objects as go + import pandas as pd + + return go, pd + + +@app.cell +def _(pd): + # Create sample time series data + data = pd.DataFrame( + { + "month": list(range(1, 13)), + "sales": [45, 52, 48, 65, 72, 68, 80, 85, 78, 90, 95, 88], + "expenses": [30, 35, 32, 40, 45, 42, 50, 48, 52, 55, 58, 54], + } + ) + data + return (data,) + + +@app.cell(hide_code=True) +def _(data, go, mo): + # 1. Simple line chart + fig1 = go.Figure() + fig1.add_trace( + go.Scatter( + x=data["month"], + y=data["sales"], + mode="lines", + name="Sales", + line=dict(color="#636EFA", width=2), + ) + ) + fig1.update_layout( + title="Monthly Sales", xaxis_title="Month", yaxis_title="Sales ($1000s)" + ) + + line_chart = mo.ui.plotly(fig1) + line_chart + return (line_chart,) + + +@app.cell +def _(line_chart, mo): + mo.md(f""" + ## Simple Line Chart + + **Instructions:** Use the box select tool (in the toolbar) to select a range. + + ### Selected Points: + {line_chart.value} + + ### Selection Range: + {line_chart.ranges} + """) + return + + +@app.cell(hide_code=True) +def _(data, go, mo): + # 2. Line chart with markers + fig2 = go.Figure() + fig2.add_trace( + go.Scatter( + x=data["month"], + y=data["sales"], + mode="lines+markers", + name="Sales", + line=dict(color="#636EFA", width=2), + marker=dict(size=8), + ) + ) + fig2.update_layout( + title="Monthly Sales (with markers)", + xaxis_title="Month", + yaxis_title="Sales ($1000s)", + ) + + line_markers = mo.ui.plotly(fig2) + line_markers + return (line_markers,) + + +@app.cell +def _(line_markers, mo): + mo.md(f""" + ## Line Chart with Markers + + **Instructions:** Use the box select tool to select a range. + + ### Selected Points: + {line_markers.value} + """) + return + + +@app.cell(hide_code=True) +def _(data, go, mo): + # 3. Multiple lines + fig3 = go.Figure() + fig3.add_trace( + go.Scatter( + x=data["month"], + y=data["sales"], + mode="lines", + name="Sales", + line=dict(color="#636EFA", width=2), + ) + ) + fig3.add_trace( + go.Scatter( + x=data["month"], + y=data["expenses"], + mode="lines", + name="Expenses", + line=dict(color="#EF553B", width=2), + ) + ) + fig3.update_layout( + title="Sales vs Expenses", + xaxis_title="Month", + yaxis_title="Amount ($1000s)", + ) + + multi_line = mo.ui.plotly(fig3) + multi_line + return (multi_line,) + + +@app.cell +def _(mo, multi_line): + mo.md(f""" + ## Multiple Lines + + **Instructions:** Use the box select tool to select a range. + Points from both lines will be returned! + + ### Selected Points: + {multi_line.value} + + ### Number of selected points: + {len(multi_line.value)} + """) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/third_party/plotly/mapbox.py b/examples/third_party/plotly/mapbox.py index c5edb00c294..86a4ac0a633 100644 --- a/examples/third_party/plotly/mapbox.py +++ b/examples/third_party/plotly/mapbox.py @@ -9,44 +9,42 @@ import marimo -__generated_with = "0.9.4" +__generated_with = "0.19.7" app = marimo.App() @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - # Mapping Example +def _(mo): + mo.md(""" + # Mapping Example - This example uses Mapbox in `plotly.express` to build a scatter plot on a street map. The switch enables the satellite view. - """ - ) + This example uses Mapbox in `plotly.express` to build a scatter plot on a street map. The switch enables the satellite view. + """) return @app.cell(hide_code=True) -def __(mo): +def _(mo): view_button = mo.ui.switch(value=False) mo.hstack([mo.md("Satellite view:"), view_button], justify="start") return (view_button,) @app.cell -def __(get_map, mo, view_button): +def _(get_map, mo, view_button): f = mo.ui.plotly(get_map(satellite=view_button.value)) f return (f,) @app.cell -def __(f, mo): +def _(f, mo): mo.ui.table(f.value) return @app.cell -def __(px, us_cities): +def _(px, us_cities): def get_map(satellite): map = px.scatter_mapbox( us_cities, @@ -77,11 +75,12 @@ def get_map(satellite): map.update_layout(mapbox_style="open-street-map") map.update_layout(margin={"r": 0, "t": 0, "l": 0, "b": 0}) return map + return (get_map,) @app.cell -def __(pd): +def _(pd): us_cities = pd.read_csv( "https://raw.githubusercontent.com/plotly/datasets/master/us-cities-top-1k.csv" ) @@ -89,7 +88,7 @@ def __(pd): @app.cell -def __(): +def _(): import os import sys @@ -97,7 +96,8 @@ def __(): import plotly.express as px import marimo as mo - return mo, os, pd, px, sys + + return mo, pd, px if __name__ == "__main__": diff --git a/examples/third_party/plotly/pure_line_selection.py b/examples/third_party/plotly/pure_line_selection.py new file mode 100644 index 00000000000..3cdbfdd1ec9 --- /dev/null +++ b/examples/third_party/plotly/pure_line_selection.py @@ -0,0 +1,135 @@ +# /// script +# requires-python = ">=3.9" +# dependencies = [ +# "marimo", +# "plotly==6.5.1", +# ] +# /// + +import marimo + +__generated_with = "0.20.2" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + import plotly.graph_objects as go + + return go, mo + + +@app.cell +def _(mo): + mo.md(""" + # Reactive Plotly Pure Line Selection + + This example focuses on pure line traces (`mode="lines"`). + + Try: + - clicking a line point + - box selection + - lasso selection + + Then inspect `value`, `indices`, and `ranges` in Python. + """) + return + + +@app.cell +def _(go, mo): + mo.md("## Single Pure Line") + + _fig_single = go.Figure( + data=go.Scatter( + x=[0, 1, 2, 3, 4, 5, 6], + y=[0, 4, 2, 6, 3, 8, 5], + mode="lines", + name="line_a", + line={"color": "#636EFA", "width": 2}, + ) + ) + _fig_single.update_layout( + title="Click + Box/Lasso on Pure Line", + xaxis_title="x", + yaxis_title="y", + clickmode="event+select", + ) + + pure_line = mo.ui.plotly(_fig_single) + pure_line + return (pure_line,) + + +@app.cell +def _(mo, pure_line): + mo.md(f""" + ### Single Line Selection Output + + **Selected rows:** {len(pure_line.value)} + + **Selected indices:** {pure_line.indices} + + **Selection ranges:** {pure_line.ranges} + + **Preview:** + {pure_line.value[:10]} + """) + return + + +@app.cell +def _(go, mo): + mo.md("## Multiple Pure Lines") + + _fig_multi = go.Figure() + _fig_multi.add_trace( + go.Scatter( + x=[1, 2, 3, 4, 5], + y=[12, 15, 18, 14, 16], + mode="lines", + name="inside_box_line", + line={"color": "#00CC96", "width": 2}, + ) + ) + _fig_multi.add_trace( + go.Scatter( + x=[1, 2, 3, 4, 5], + y=[50, 60, 70, 65, 55], + mode="lines", + name="outside_box_line", + line={"color": "#EF553B", "width": 2}, + ) + ) + _fig_multi.update_layout( + title="XY Filtering Across Multiple Pure Lines", + xaxis_title="x", + yaxis_title="y", + clickmode="event+select", + ) + + multi_line = mo.ui.plotly(_fig_multi) + multi_line + return (multi_line,) + + +@app.cell +def _(mo, multi_line): + mo.md(f""" + ### Multi-Line Selection Output + + **Selected rows:** {len(multi_line.value)} + + **Selected indices:** {multi_line.indices} + + **Selection ranges:** {multi_line.ranges} + + **Preview:** + {multi_line.value[:10]} + """) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/third_party/plotly/scatter_map.py b/examples/third_party/plotly/scatter_map.py index 42d19aad03a..73bdfe94d13 100644 --- a/examples/third_party/plotly/scatter_map.py +++ b/examples/third_party/plotly/scatter_map.py @@ -6,33 +6,33 @@ # "plotly==5.24.1", # ] # /// + import marimo -__generated_with = "0.9.4" +__generated_with = "0.19.7" app = marimo.App(width="full") @app.cell -def __(): +def _(): import marimo as mo + return (mo,) @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - # Selectable scatter map - - This example shows how to overlay a scatter plot on a map using `Plotly`, and make the plot reactive using [`mo.ui.plotly`](https://docs.marimo.io/guides/working_with_data/plotting.html#plotly) — select plots in the scatter - plot and get them back in Python! - """ - ) +def _(mo): + mo.md(r""" + # Selectable scatter map + + This example shows how to overlay a scatter plot on a map using `Plotly`, and make the plot reactive using [`mo.ui.plotly`](https://docs.marimo.io/guides/working_with_data/plotting.html#plotly) — select plots in the scatter + plot and get them back in Python! + """) return @app.cell -def __(mo): +def _(mo): import plotly.express as px df = px.data.carshare() @@ -47,11 +47,11 @@ def __(mo): zoom=10, mapbox_style="carto-positron", )) - return df, fig, px + return (fig,) @app.cell -def __(fig, mo): +def _(fig, mo): mo.hstack([fig, fig.value], justify="start") return diff --git a/examples/third_party/plotly/strip_chart.py b/examples/third_party/plotly/strip_chart.py new file mode 100644 index 00000000000..762e8449d2d --- /dev/null +++ b/examples/third_party/plotly/strip_chart.py @@ -0,0 +1,310 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "marimo", +# "pandas==2.3.3", +# "plotly==6.5.1", +# ] +# /// + +import marimo + +__generated_with = "0.20.2" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + import pandas as pd + import plotly.express as px + import plotly.graph_objects as go + + return go, mo, pd, px + + +@app.cell +def _(mo): + mo.md(""" + # Reactive Plotly Strip Chart Selection + + This example demonstrates reactive strip chart selections with `mo.ui.plotly`. + + Strip charts (also called dot plots or jitter plots) show every individual data + point, making them ideal for small-to-medium datasets where you want to see the + full distribution without aggregation. + + Try: + - **Drag a box or lasso** over points to select a subset + - **Click** an individual point to select it + - Watch the table and stats below update in real time + + The `customdata` field embeds `sample_id` so selected points map back to the + source DataFrame without relying on raw indices. + """) + return + + +@app.cell +def _(pd): + rows = [ + ("ENG-01", "Engineering", "Day", "West", 72), + ("ENG-02", "Engineering", "Day", "West", 78), + ("ENG-03", "Engineering", "Day", "East", 81), + ("ENG-04", "Engineering", "Night", "East", 75), + ("ENG-05", "Engineering", "Night", "West", 84), + ("ENG-06", "Engineering", "Night", "Central", 89), + ("SAL-01", "Sales", "Day", "West", 61), + ("SAL-02", "Sales", "Day", "Central", 66), + ("SAL-03", "Sales", "Day", "East", 70), + ("SAL-04", "Sales", "Night", "West", 64), + ("SAL-05", "Sales", "Night", "East", 73), + ("SAL-06", "Sales", "Night", "Central", 77), + ("SUP-01", "Support", "Day", "East", 58), + ("SUP-02", "Support", "Day", "Central", 62), + ("SUP-03", "Support", "Day", "West", 68), + ("SUP-04", "Support", "Night", "East", 60), + ("SUP-05", "Support", "Night", "Central", 65), + ("SUP-06", "Support", "Night", "West", 71), + ] + df = pd.DataFrame(rows, columns=["sample_id", "team", "shift", "region", "score"]) + df["passed"] = df["score"] >= 75 + return (df,) + + +@app.cell +def _(): + def selected_rows(selection, data): + """Map a mo.ui.plotly selection back to rows in the source DataFrame.""" + empty = data.iloc[0:0].copy() + if not selection: + return empty + + # Prefer sample_id embedded via customdata. + # Fallback-path selections embed sample_id as customdata[0] (a + # list/tuple), not as a parsed top-level key. + ids = [] + for row in selection: + if isinstance(row.get("sample_id"), str): + ids.append(row["sample_id"]) + else: + cd = row.get("customdata") + if isinstance(cd, (list, tuple)) and cd and isinstance(cd[0], str): + ids.append(cd[0]) + if ids: + return ( + data[data["sample_id"].isin(ids)] + .drop_duplicates("sample_id") + .sort_values("sample_id") + ) + + # Fall back to pointIndex + indices = sorted({ + row["pointIndex"] + for row in selection + if isinstance(row.get("pointIndex"), int) + and 0 <= row["pointIndex"] < len(data) + }) + if indices: + return data.iloc[indices].copy() + + return empty + + return (selected_rows,) + + +@app.cell +def _(df, mo, px): + mo.md("## Strip Chart (px.strip)") + + fig_strip = px.strip( + df, + x="team", + y="score", + color="shift", + custom_data=["sample_id", "shift", "region", "passed"], + title="Score by Team — drag a box or click individual points", + labels={"score": "Score", "team": "Team", "shift": "Shift"}, + ) + fig_strip.update_traces( + marker=dict(size=10, opacity=0.8), + hovertemplate=( + "sample_id=%{customdata[0]}
" + "team=%{x}
" + "score=%{y}
" + "shift=%{customdata[1]}
" + "region=%{customdata[2]}
" + "passed=%{customdata[3]}" + ), + ) + fig_strip.update_layout(dragmode="select") + + strip_plot = mo.ui.plotly(fig_strip) + strip_plot + return (strip_plot,) + + +@app.cell +def _(df, mo, selected_rows, strip_plot): + _sel = selected_rows(strip_plot.value, df) + + _summary = ( + f"{len(_sel)} rows selected — " + f"avg score: {_sel['score'].mean():.1f} — " + f"pass rate: {_sel['passed'].mean():.0%}" + if not _sel.empty + else "No rows selected yet." + ) + + mo.md(f""" + ### Strip Chart Selection + + **{_summary}** + + **Indices:** {strip_plot.indices} + + **Range:** {strip_plot.ranges} + """) + return + + +@app.cell +def _(df, mo, selected_rows, strip_plot): + mo.ui.table(selected_rows(strip_plot.value, df)) + return + + +@app.cell +def _(df, go, mo): + mo.md("## Single-Trace Strip Chart (go.Box with boxpoints='all')") + + fig_single = go.Figure( + data=go.Box( + x=df["team"], + y=df["score"], + boxpoints="all", + jitter=0.4, + pointpos=0, + fillcolor="rgba(0,0,0,0)", + line=dict(color="rgba(0,0,0,0)"), + marker=dict(size=10, opacity=0.8, color="#e76f51"), + customdata=df[["sample_id", "shift", "region", "passed"]], + hovertemplate=( + "sample_id=%{customdata[0]}
" + "team=%{x}
" + "score=%{y}
" + "shift=%{customdata[1]}
" + "region=%{customdata[2]}
" + "passed=%{customdata[3]}" + ), + name="score", + ) + ) + fig_single.update_layout( + title="Score by Team (single trace) — select any points", + xaxis_title="Team", + yaxis_title="Score", + dragmode="select", + showlegend=False, + ) + + strip_single = mo.ui.plotly(fig_single) + strip_single + return (strip_single,) + + +@app.cell +def _(df, mo, selected_rows, strip_single): + _sel_s = selected_rows(strip_single.value, df) + + _summary_s = ( + f"{len(_sel_s)} rows — avg score: {_sel_s['score'].mean():.1f} — " + f"pass rate: {_sel_s['passed'].mean():.0%}" + if not _sel_s.empty + else "No rows selected yet." + ) + + mo.md(f""" + ### Single-Trace Selection + + **{_summary_s}** + + **Indices:** {strip_single.indices} + """) + return + + +@app.cell +def _(df, mo, selected_rows, strip_single): + mo.ui.table(selected_rows(strip_single.value, df)) + return + + +@app.cell +def _(df, go, mo): + mo.md("## Horizontal Strip Chart") + + fig_horizontal = go.Figure( + data=go.Box( + x=df["score"], + y=df["team"], + orientation="h", + boxpoints="all", + jitter=0.4, + pointpos=0, + fillcolor="rgba(0,0,0,0)", + line=dict(color="rgba(0,0,0,0)"), + marker=dict(size=10, opacity=0.8, color="#2a9d8f"), + customdata=df[["sample_id", "shift", "region", "passed"]], + hovertemplate=( + "sample_id=%{customdata[0]}
" + "team=%{y}
" + "score=%{x}
" + "shift=%{customdata[1]}
" + "region=%{customdata[2]}
" + "passed=%{customdata[3]}" + ), + name="score", + ) + ) + fig_horizontal.update_layout( + title="Score by Team (horizontal) — same data, transposed", + xaxis_title="Score", + yaxis_title="Team", + dragmode="select", + showlegend=False, + ) + + strip_horizontal = mo.ui.plotly(fig_horizontal) + strip_horizontal + return (strip_horizontal,) + + +@app.cell +def _(df, mo, selected_rows, strip_horizontal): + _sel_h = selected_rows(strip_horizontal.value, df) + + _summary_h = ( + f"{len(_sel_h)} rows — avg score: {_sel_h['score'].mean():.1f}" + if not _sel_h.empty + else "No rows selected yet." + ) + + mo.md(f""" + ### Horizontal Selection + + **{_summary_h}** + + **Indices:** {strip_horizontal.indices} + """) + return + + +@app.cell +def _(df, mo, selected_rows, strip_horizontal): + mo.ui.table(selected_rows(strip_horizontal.value, df)) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/third_party/plotly/violin_chart.py b/examples/third_party/plotly/violin_chart.py new file mode 100644 index 00000000000..0171da653dd --- /dev/null +++ b/examples/third_party/plotly/violin_chart.py @@ -0,0 +1,309 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "marimo", +# "pandas==2.3.3", +# "plotly==6.5.1", +# ] +# /// + +import marimo + +__generated_with = "0.20.2" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + import pandas as pd + import plotly.express as px + import plotly.graph_objects as go + + return go, mo, pd, px + + +@app.cell +def _(mo): + mo.md(""" + # Reactive Plotly Violin Plot Selection + + This example demonstrates reactive violin plot selections with `mo.ui.plotly`. + + Try: + - **Drag a box** over jittered sample points to select them + - **Click** a violin body to select the entire group + - Watch the table and stats below update from your selection + + The `customdata` field embeds the original row ID (`sample_id`) so selected + points map back to the source DataFrame without relying on raw indices. + """) + return + + +@app.cell +def _(pd): + rows = [ + ("ENG-01", "Engineering", "Day", "West", 72), + ("ENG-02", "Engineering", "Day", "West", 78), + ("ENG-03", "Engineering", "Day", "East", 81), + ("ENG-04", "Engineering", "Night", "East", 75), + ("ENG-05", "Engineering", "Night", "West", 84), + ("ENG-06", "Engineering", "Night", "Central", 89), + ("SAL-01", "Sales", "Day", "West", 61), + ("SAL-02", "Sales", "Day", "Central", 66), + ("SAL-03", "Sales", "Day", "East", 70), + ("SAL-04", "Sales", "Night", "West", 64), + ("SAL-05", "Sales", "Night", "East", 73), + ("SAL-06", "Sales", "Night", "Central", 77), + ("SUP-01", "Support", "Day", "East", 58), + ("SUP-02", "Support", "Day", "Central", 62), + ("SUP-03", "Support", "Day", "West", 68), + ("SUP-04", "Support", "Night", "East", 60), + ("SUP-05", "Support", "Night", "Central", 65), + ("SUP-06", "Support", "Night", "West", 71), + ] + df = pd.DataFrame(rows, columns=["sample_id", "team", "shift", "region", "score"]) + df["passed"] = df["score"] >= 75 + return (df,) + + +@app.cell +def _(): + def selected_rows(selection, data): + """Map a mo.ui.plotly selection back to rows in the source DataFrame.""" + empty = data.iloc[0:0].copy() + if not selection: + return empty + + # Prefer sample_id embedded via customdata + ids = [ + row["sample_id"] + for row in selection + if isinstance(row.get("sample_id"), str) + ] + if ids: + return ( + data[data["sample_id"].isin(ids)] + .drop_duplicates("sample_id") + .sort_values("sample_id") + ) + + # Fall back to pointIndex + indices = sorted({ + row["pointIndex"] + for row in selection + if isinstance(row.get("pointIndex"), int) + and 0 <= row["pointIndex"] < len(data) + }) + if indices: + return data.iloc[indices].copy() + + return empty + + return (selected_rows,) + + +@app.cell +def _(df, go, mo): + mo.md("## Single-Trace Violin Plot") + + fig_single = go.Figure( + data=go.Violin( + x=df["team"], + y=df["score"], + customdata=df[["sample_id", "shift", "region", "passed"]], + points="all", + jitter=0.35, + pointpos=0, + box_visible=True, + meanline_visible=True, + marker=dict(size=8, opacity=0.8, color="#7B2D8B"), + line=dict(color="#7B2D8B"), + fillcolor="rgba(123,45,139,0.15)", + hovertemplate=( + "sample_id=%{customdata[0]}
" + "team=%{x}
" + "score=%{y}
" + "shift=%{customdata[1]}
" + "region=%{customdata[2]}
" + "passed=%{customdata[3]}" + ), + name="score", + ) + ) + fig_single.update_layout( + title="Score by Team — drag over points or click a violin", + xaxis_title="Team", + yaxis_title="Score", + dragmode="select", + ) + + violin_single = mo.ui.plotly(fig_single) + violin_single + return (violin_single,) + + +@app.cell +def _(df, mo, selected_rows, violin_single): + _sel = selected_rows(violin_single.value, df) + + _summary = ( + f"{len(_sel)} rows selected — " + f"avg score: {_sel['score'].mean():.1f} — " + f"pass rate: {_sel['passed'].mean():.0%}" + if not _sel.empty + else "No rows selected yet." + ) + + mo.md(f""" + ### Single-Trace Selection + + **{_summary}** + + **Indices:** {violin_single.indices} + """) + return + + +@app.cell +def _(df, mo, selected_rows, violin_single): + mo.ui.table(selected_rows(violin_single.value, df)) + return + + +@app.cell +def _(df, mo, px): + mo.md("## Grouped Violin Plot") + + fig_grouped = px.violin( + df, + x="team", + y="score", + color="shift", + points="all", + box=True, + custom_data=["sample_id", "shift", "region", "passed"], + title="Score by Team and Shift — drag to compare groups", + ) + fig_grouped.update_traces( + jitter=0.35, + pointpos=0, + meanline_visible=True, + hovertemplate=( + "sample_id=%{customdata[0]}
" + "team=%{x}
" + "score=%{y}
" + "shift=%{customdata[1]}
" + "region=%{customdata[2]}
" + "passed=%{customdata[3]}" + ), + ) + fig_grouped.update_layout(dragmode="select", xaxis_title="Team", yaxis_title="Score") + + violin_grouped = mo.ui.plotly(fig_grouped) + violin_grouped + return (violin_grouped,) + + +@app.cell +def _(df, mo, selected_rows, violin_grouped): + _sel_g = selected_rows(violin_grouped.value, df) + + _summary_g = ( + f"{len(_sel_g)} rows — " + + ", ".join( + f"{team}: {count}" + for team, count in _sel_g.groupby("team").size().items() + ) + if not _sel_g.empty + else "No rows selected yet." + ) + + mo.md(f""" + ### Grouped Selection + + **{_summary_g}** + + **Indices:** {violin_grouped.indices} + + **Range:** {violin_grouped.ranges} + """) + return + + +@app.cell +def _(df, mo, selected_rows, violin_grouped): + mo.ui.table(selected_rows(violin_grouped.value, df)) + return + + +@app.cell +def _(df, go, mo): + mo.md("## Horizontal Violin Plot") + + fig_horizontal = go.Figure( + data=go.Violin( + x=df["score"], + y=df["team"], + orientation="h", + customdata=df[["sample_id", "shift", "region", "passed"]], + points="all", + jitter=0.35, + pointpos=0, + box_visible=True, + meanline_visible=True, + marker=dict(size=8, opacity=0.8, color="#2a9d8f"), + line=dict(color="#2a9d8f"), + fillcolor="rgba(42,157,143,0.15)", + hovertemplate=( + "sample_id=%{customdata[0]}
" + "team=%{y}
" + "score=%{x}
" + "shift=%{customdata[1]}
" + "region=%{customdata[2]}
" + "passed=%{customdata[3]}" + ), + name="score", + ) + ) + fig_horizontal.update_layout( + title="Score by Team (horizontal) — same data, transposed", + xaxis_title="Score", + yaxis_title="Team", + dragmode="select", + ) + + violin_horizontal = mo.ui.plotly(fig_horizontal) + violin_horizontal + return (violin_horizontal,) + + +@app.cell +def _(df, mo, selected_rows, violin_horizontal): + _sel_h = selected_rows(violin_horizontal.value, df) + + _summary_h = ( + f"{len(_sel_h)} rows — avg score: {_sel_h['score'].mean():.1f}" + if not _sel_h.empty + else "No rows selected yet." + ) + + mo.md(f""" + ### Horizontal Selection + + **{_summary_h}** + + **Indices:** {violin_horizontal.indices} + """) + return + + +@app.cell +def _(df, mo, selected_rows, violin_horizontal): + mo.ui.table(selected_rows(violin_horizontal.value, df)) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/third_party/plotly/waterfall_chart.py b/examples/third_party/plotly/waterfall_chart.py new file mode 100644 index 00000000000..10cc68910a6 --- /dev/null +++ b/examples/third_party/plotly/waterfall_chart.py @@ -0,0 +1,248 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "marimo", +# "pandas==2.3.3", +# "plotly==6.5.1", +# ] +# /// + +import marimo + +__generated_with = "0.20.2" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + import pandas as pd + import plotly.graph_objects as go + + return go, mo, pd + + +@app.cell +def _(mo): + mo.md(""" + # Reactive Plotly Waterfall Chart Selection + + This example demonstrates reactive waterfall chart selections with `mo.ui.plotly`. + + Waterfall charts (also called bridge charts) decompose a starting value into + its contributing increments, making it easy to trace how individual drivers + move a KPI from one level to another. + + Each bar has a **measure** type: + - **`absolute`** — starts at zero and sets the running total + - **`relative`** — adds to (or subtracts from) the running total + - **`total`** — shows the current running total as a reference bar + + Try: + - **Click** any bar to select it + - **Drag a box** over multiple bars to select a range + - Watch the detail panel and table update reactively + """) + return + + +@app.cell +def _(go, mo): + mo.md("## Annual P&L Bridge") + + fig_pl = go.Figure( + go.Waterfall( + name="P&L", + orientation="v", + measure=[ + "absolute", + "relative", + "relative", + "relative", + "relative", + "total", + "relative", + "relative", + "total", + ], + x=[ + "Revenue", + "COGS", + "Gross Profit", + "R&D", + "S&M", + "EBITDA", + "D&A", + "Tax", + "Net Income", + ], + y=[4_200, -1_500, 0, -420, -310, 0, -180, -240, 0], + connector={"line": {"color": "rgb(63, 63, 63)"}}, + increasing={"marker": {"color": "#2a9d8f"}}, + decreasing={"marker": {"color": "#e76f51"}}, + totals={"marker": {"color": "#264653", "line": {"color": "#264653"}}}, + hovertemplate=( + "%{x}
" + "Value: $%{y:,.0f}K
" + "" + ), + ) + ) + fig_pl.update_layout( + title="Annual P&L Bridge — click or box-select bars", + yaxis_title="USD (thousands)", + dragmode="select", + showlegend=False, + ) + + pl_plot = mo.ui.plotly(fig_pl) + pl_plot + return (pl_plot,) + + +@app.cell +def _(mo, pl_plot): + _pts = pl_plot.value + + if _pts: + _total = sum( + p["y"] for p in _pts + if isinstance(p.get("y"), (int, float)) and p.get("measure") != "total" + ) + _names = ", ".join(p["x"] for p in _pts if p.get("x")) + _detail = ( + f"**{len(_pts)} bar(s) selected:** {_names} \n" + f"Net relative contribution: **${_total:,.0f}K**" + ) + else: + _detail = "Click or drag to select bars." + + mo.md(f""" + ### Selection Detail + + {_detail} + + **Indices:** {pl_plot.indices} + + **Raw value:** {pl_plot.value} + """) + return + + +@app.cell +def _(go, mo): + mo.md("## Regional Sales Waterfall — Multi-Trace") + + fig_regional = go.Figure() + + traces = [ + ("North America", [800, 120, -40, 0], "#2a9d8f"), + ("Europe", [600, 80, -55, 0], "#e9c46a"), + ("Asia-Pacific", [450, 95, -30, 0], "#e76f51"), + ] + measures = ["absolute", "relative", "relative", "total"] + x_labels = ["Baseline", "Growth", "Churn", "Net"] + + for region, values, colour in traces: + fig_regional.add_trace( + go.Waterfall( + name=region, + measure=measures, + x=x_labels, + y=values, + offsetgroup=region, + increasing={"marker": {"color": colour}}, + totals={"marker": {"color": colour}}, + ) + ) + + fig_regional.update_layout( + title="Regional Sales Bridge — select bars across traces", + yaxis_title="Revenue ($M)", + barmode="group", + dragmode="select", + ) + + regional_plot = mo.ui.plotly(fig_regional) + regional_plot + return (regional_plot,) + + +@app.cell +def _(mo, pd, regional_plot): + _pts = regional_plot.value + + if _pts: + _rows = pd.DataFrame( + [ + { + "Stage": p.get("x"), + "Region": p.get("name"), + "Value ($M)": p.get("y"), + "Type": p.get("measure"), + } + for p in _pts + if p + ] + ) + else: + _rows = pd.DataFrame(columns=["Stage", "Region", "Value ($M)", "Type"]) + + mo.vstack([ + mo.md(f"**{len(_pts)} bar(s) selected** | Indices: {regional_plot.indices}"), + mo.ui.table(_rows), + ]) + return + + +@app.cell +def _(go, mo): + mo.md("## Horizontal Budget Waterfall") + + fig_h = go.Figure( + go.Waterfall( + orientation="h", + measure=["absolute", "relative", "relative", "relative", "total"], + y=["Budget", "Savings", "Overspend", "Reserve", "Actual"], + x=[500, -80, 120, -30, 0], + connector={"mode": "between", "line": {"width": 1, "color": "rgb(180,180,180)"}}, + increasing={"marker": {"color": "#e76f51"}}, + decreasing={"marker": {"color": "#2a9d8f"}}, + totals={"marker": {"color": "#457b9d"}}, + hovertemplate="%{y}
Amount: $%{x:,.0f}K", + ) + ) + fig_h.update_layout( + title="Budget vs Actual — horizontal bridge", + xaxis_title="USD (thousands)", + dragmode="select", + showlegend=False, + ) + + h_plot = mo.ui.plotly(fig_h) + h_plot + return (h_plot,) + + +@app.cell +def _(h_plot, mo): + _pts = h_plot.value + + if _pts: + _names = [p.get("y") for p in _pts if p.get("y")] + _summary = f"Selected: **{', '.join(_names)}**" + else: + _summary = "Click or drag to select budget items." + + mo.md(f""" + ### Horizontal Selection + + {_summary} + + **Indices:** {h_plot.indices} + """) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/third_party/polars/polars_example.py b/examples/third_party/polars/polars_example.py index af869d92ed1..52df18be91b 100644 --- a/examples/third_party/polars/polars_example.py +++ b/examples/third_party/polars/polars_example.py @@ -1,7 +1,7 @@ # /// script # requires-python = ">=3.10" # dependencies = [ -# "altair==5.4.1", +# "altair==5.5.0", # "marimo", # "numpy==2.0.2", # "polars==1.8.2", @@ -10,41 +10,40 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App(width="full") @app.cell(hide_code=True) -def __(mo): - mo.md( - f""" +def _(mo): + mo.md(""" # Using `Polars` in `marimo` > Lightning-fast DataFrame library for Rust and Python - """ - ) + """) return @app.cell -def __(): +def _(): import marimo as mo import polars as pl import numpy as np import altair as alt - return alt, mo, np, pl + + return alt, mo, pl @app.cell -def __(pl): +def _(pl): df = pl.read_csv( "https://gist.githubusercontent.com/ritchie46/cac6b337ea52281aa23c049250a4ff03/raw/89a957ff3919d90e6ef2d34235e6bf22304f3366/pokemon.csv" ) return (df,) -@app.cell -def __(df, mo): +@app.cell(hide_code=True) +def _(df, mo): # get all unique values values_1 = df["Type 1"].unique().drop_nulls().to_list() values_2 = df["Type 2"].unique().drop_nulls().to_list() @@ -59,12 +58,30 @@ def __(df, mo): ) - mo.hstack([type_1_filter, type_2_filter]) - return type_1_filter, type_2_filter, values_1, values_2 + mo.hstack([type_1_filter, type_2_filter], justify="start") + return type_1_filter, type_2_filter @app.cell -def __(alt, filtered, mo): +def _(df, pl, type_1_filter, type_2_filter): + filtered = df + if type_1_filter.value: + filtered = filtered.filter(pl.col("Type 1") == type_1_filter.value) + if type_2_filter.value: + filtered = filtered.filter(pl.col("Type 2") == type_2_filter.value) + return (filtered,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + Select points on the chart 👇 + """) + return + + +@app.cell(hide_code=True) +def _(alt, filtered, mo): _chart = ( alt.Chart(filtered) .mark_circle() @@ -77,35 +94,16 @@ def __(alt, filtered, mo): ) ) - mo.ui.altair_chart(_chart, legend_selection=True, label="Attack vs Defense") - return - - -@app.cell -def __(df, pl, type_1_filter, type_2_filter): - filtered = df - if type_1_filter.value: - filtered = filtered.filter(pl.col("Type 1") == type_1_filter.value) - if type_2_filter.value: - filtered = filtered.filter(pl.col("Type 2") == type_2_filter.value) - return (filtered,) - - -@app.cell -def __(filtered, mo): - table = mo.ui.table(filtered) - table - return (table,) + chart = mo.ui.altair_chart( + _chart, legend_selection=True, label="Attack vs Defense" + ) + chart + return (chart,) @app.cell -def __(mo, table): - mo.vstack( - [ - mo.ui.table(table.value, label="Selected", selection=None), - table.value, - ] - ) +def _(chart, mo): + mo.ui.table(chart.value) return diff --git a/examples/third_party/pygwalker/example.py b/examples/third_party/pygwalker/example.py index 30cd2485b6d..73101766612 100644 --- a/examples/third_party/pygwalker/example.py +++ b/examples/third_party/pygwalker/example.py @@ -5,26 +5,28 @@ # "vega-datasets==0.9.0", # ] # /// + import marimo -__generated_with = "0.9.14" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell -def __(): +def _(): import pygwalker as pyg from vega_datasets import data + return data, pyg @app.cell -def __(data, pyg): +def _(data, pyg): df = data.iris() pyg.walk(df) - return (df,) + return if __name__ == "__main__": diff --git a/examples/third_party/pyiceberg/data_catalog.py b/examples/third_party/pyiceberg/data_catalog.py new file mode 100644 index 00000000000..1982c14baff --- /dev/null +++ b/examples/third_party/pyiceberg/data_catalog.py @@ -0,0 +1,138 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "polars==1.27.1", +# "pyarrow==19.0.1", +# "pyiceberg==0.9.0", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + import os + + return mo, os + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + # PyIceberg REST Catalog + + This notebook shows you how to connect to an Apache Iceberg data catalog over REST. + """) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + /// details | Create a new catalog with Cloudflare + + 1. Create a Cloudflare account + 2. Go to + + + /// + """) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ## Connect to a data catalog + """) + return + + +@app.cell(hide_code=True) +def _(mo, os): + warehouse_input = mo.ui.text( + label="Warehouse", value=os.environ.get("DATA_CATALOG_WAREHOUSE", "") + ) + token_input = mo.ui.text( + label="Token", + value=os.environ.get("DATA_CATALOG_TOKEN", ""), + kind="password", + ) + catalog_input = mo.ui.text( + label="Catalog URI", value=os.environ.get("DATA_CATALOG_URI", "") + ) + mo.vstack([warehouse_input, token_input, catalog_input]) + return catalog_input, token_input, warehouse_input + + +@app.cell(hide_code=True) +def _(catalog_input, mo, token_input, warehouse_input): + mo.stop(not warehouse_input.value, mo.md("Missing Warehouse")) + mo.stop(not token_input.value, mo.md("Missing Token")) + mo.stop(not catalog_input.value, mo.md("Missing Catalog UI")) + + WAREHOUSE = warehouse_input.value + TOKEN = token_input.value + CATALOG_URI = catalog_input.value + return CATALOG_URI, TOKEN, WAREHOUSE + + +@app.cell +def _(CATALOG_URI, TOKEN, WAREHOUSE): + import pyarrow as pa + from pyiceberg.catalog.rest import RestCatalog + from pyiceberg.exceptions import NamespaceAlreadyExistsError + + # Connect to R2 Data Catalog + catalog = RestCatalog( + name="my_catalog", + warehouse=WAREHOUSE, + uri=CATALOG_URI, + token=TOKEN, + ) + + # Create default namespace + catalog.create_namespace_if_not_exists("default") + + # Create simple PyArrow table + df = pa.table( + { + "id": [1, 2, 3], + "name": ["Alice", "Bob", "Charlie"], + } + ) + + # Create an Iceberg table + test_table = ("default", "my_table") + table = catalog.create_table_if_not_exists( + test_table, + schema=df.schema, + ) + return df, table + + +@app.cell +def _(mo): + add_button = mo.ui.run_button(label="Add data") + clear_button = mo.ui.run_button(label="Clear data") + mo.hstack([add_button, clear_button]) + return add_button, clear_button + + +@app.cell +def _(add_button, clear_button, df, table): + if add_button.value: + table.append(df) + if clear_button.value: + table.delete() + table.to_polars().collect() + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/third_party/pymde/complete_graph.py b/examples/third_party/pymde/complete_graph.py index bd997702667..fa09d464e59 100644 --- a/examples/third_party/pymde/complete_graph.py +++ b/examples/third_party/pymde/complete_graph.py @@ -8,18 +8,20 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App() @app.cell(hide_code=True) -def __(mo): - mo.md("""# Visualizing Complete Graphs""") +def _(mo): + mo.md(""" + # Visualizing Complete Graphs + """) return @app.cell -def __(mo): +def _(mo): n_items = mo.ui.slider(start=3, stop=64, step=1) mo.md( f""" @@ -30,7 +32,7 @@ def __(mo): @app.cell -def __(mo, pymde): +def _(mo, pymde): penalty_function = mo.ui.dropdown( options={ "Linear": pymde.penalties.Linear, @@ -48,29 +50,29 @@ def __(mo, pymde): @app.cell -def __(complete_graph, mo, n_items, penalty_function): +def _(complete_graph, mo, n_items, penalty_function): plot = complete_graph(n_items.value, penalty_function.value) mo.md( f""" - Here is a plot of $K_n$ with $n={n_items.value}$, i.e., a complete graph on + Here is a plot of $K_n$ with $n={n_items.value}$, i.e., a complete graph on ${n_items.value}$ nodes. This graph has - \[ + \\[ (n)(n-1)/2 = {n_items.value*(n_items.value-1)//2} - \] + \\] - edges. The plot was obtained using a + edges. The plot was obtained using a {penalty_function.value.__name__.lower()} penalty function. {mo.as_html(plot)} """ ) - return (plot,) + return @app.cell -def __(pymde): +def _(pymde): import functools @functools.cache @@ -85,18 +87,21 @@ def complete_graph(n_items, penalty_function): ) mde.embed(verbose=True) return mde.plot(edges=edges) - return complete_graph, functools + + return (complete_graph,) @app.cell -def __(): +def _(): import pymde + return (pymde,) @app.cell -def __(): +def _(): import marimo as mo + return (mo,) diff --git a/examples/third_party/pymde/debugging_embeddings.py b/examples/third_party/pymde/debugging_embeddings.py index 42e742e3e6d..0abaf26dd23 100644 --- a/examples/third_party/pymde/debugging_embeddings.py +++ b/examples/third_party/pymde/debugging_embeddings.py @@ -4,52 +4,49 @@ # "pymde==0.1.18", # ] # /// + import marimo -__generated_with = "0.1.68" +__generated_with = "0.19.7" app = marimo.App() @app.cell -def __(mo): - mo.md( - """ - # Sanity-Checking Embeddings +def _(mo): + mo.md(""" + # Sanity-Checking Embeddings - This notebook shows you the basics of sanity-checking an embedding made - with the `PyMDE` library. We'll use MNIST as a case study. - """ - ) + This notebook shows you the basics of sanity-checking an embedding made + with the `PyMDE` library. We'll use MNIST as a case study. + """) return @app.cell -def __(mo): - mo.md( - """ - We'll start by making a simple neighborhood-preserving embedding. This - means that we'll try to identify pairs of images that are similar, using - a heuristic, and we'll tell PyMDE to place these pairs near each other in - the embedding. - """ - ) +def _(mo): + mo.md(""" + We'll start by making a simple neighborhood-preserving embedding. This + means that we'll try to identify pairs of images that are similar, using + a heuristic, and we'll tell PyMDE to place these pairs near each other in + the embedding. + """) return @app.cell -def __(n_neighbors): +def _(n_neighbors): n_neighbors return @app.cell -def __(n_neighbors): +def _(n_neighbors): ready = n_neighbors.value is not None - return ready, + return (ready,) @app.cell -def __(mo, ready): +def _(mo, ready): mo.md( """ Below, we've plotted an embedding along with a CDF of the distortions @@ -62,7 +59,7 @@ def __(mo, ready): @app.cell -def __(mnist, plt, quadratic_mde, ready): +def _(mnist, plt, quadratic_mde, ready): def compute_embedding(): _ = quadratic_mde.embed(verbose=True) quadratic_mde.plot(color_by=mnist.attributes["digits"]) @@ -78,36 +75,36 @@ def compute_embedding(): compute_embedding() if ready else (None, None, None) ) plots - return compute_embedding, distortions, pairs, plots + return (pairs,) @app.cell -def __(mo): +def _(mo): n_neighbors = mo.ui.slider( 3, 30, step=1, value=15, label="Number of neighbors in $k$-NN graph" ).form() - return n_neighbors, + return (n_neighbors,) @app.cell -def __(knn, n_neighbors, ready): +def _(knn, n_neighbors, ready): knn_graph = knn(n_neighbors.value) if ready else None - return knn_graph, + return (knn_graph,) @app.cell -def __(functools, mnist, pymde): +def _(functools, mnist, pymde): @functools.cache def knn(n_neighbors): return pymde.preprocess.k_nearest_neighbors( mnist.data, k=n_neighbors, verbose=True ) - return knn, + return (knn,) @app.cell -def __(knn_graph, mnist, pymde, ready, torch): +def _(knn_graph, mnist, pymde, ready, torch): def construct_mde_problem(): if torch.cuda.is_available(): device = "cuda" @@ -124,11 +121,11 @@ def construct_mde_problem(): ) quadratic_mde = construct_mde_problem() if ready else None - return construct_mde_problem, quadratic_mde + return (quadratic_mde,) @app.cell -def __(mo, ready): +def _(mo, ready): mo.md( """ ## Pairs with highest and lowest distortion @@ -143,14 +140,14 @@ def __(mo, ready): @app.cell -def __(mo, ready): +def _(mo, ready): n_pairs = mo.ui.slider(5, 10, label="number of pairs") n_pairs if ready else None - return n_pairs, + return (n_pairs,) @app.cell -def __(mo, ready): +def _(mo, ready): mo.md( """### Low distortion pairs @@ -162,13 +159,13 @@ def __(mo, ready): @app.cell -def __(n_pairs, pairs, plot_pairs, ready): +def _(n_pairs, pairs, plot_pairs, ready): plot_pairs(pairs[-n_pairs.value :]) if ready else None return @app.cell -def __(mo, ready): +def _(mo, ready): mo.md( """ ### High distortion pairs @@ -185,13 +182,13 @@ def __(mo, ready): @app.cell -def __(n_pairs, pairs, plot_pairs, ready): +def _(n_pairs, pairs, plot_pairs, ready): plot_pairs(pairs[: n_pairs.value]) if ready else None return @app.cell -def __(mnist, plt): +def _(mnist, plt): def plot_pairs(pairs): fig, axs = plt.subplots(2, pairs.shape[0], figsize=(15.0, 3.0)) for pair_index in range(pairs.shape[0]): @@ -207,17 +204,18 @@ def plot_pairs(pairs): axs[1][pair_index].set_yticks([]) plt.tight_layout() return plt.gca() - return plot_pairs, + + return (plot_pairs,) @app.cell -def __(pymde): +def _(pymde): mnist = pymde.datasets.MNIST() - return mnist, + return (mnist,) @app.cell -def __(): +def _(): import functools import matplotlib.pyplot as plt @@ -225,6 +223,7 @@ def __(): import torch import marimo as mo + return functools, mo, plt, pymde, torch diff --git a/examples/third_party/pymde/drawing_graphs.py b/examples/third_party/pymde/drawing_graphs.py index c559f56ef51..4bc2401ee3f 100644 --- a/examples/third_party/pymde/drawing_graphs.py +++ b/examples/third_party/pymde/drawing_graphs.py @@ -4,38 +4,41 @@ # "pymde==0.1.18", # ] # /// + import marimo -__generated_with = "0.1.0" +__generated_with = "0.19.7" app = marimo.App() @app.cell -def __(mo): - mo.md("# Drawing Graphs") +def _(mo): + mo.md(""" + # Drawing Graphs + """) return @app.cell -def __(mo): +def _(mo): n_items = mo.ui.slider(3, 15) - return n_items, + return (n_items,) @app.cell -def __(mo, n_items): +def _(mo, n_items): mo.md(f"Embedding graphs on {n_items} **{n_items.value}** items") return @app.cell -def __(draw_graphs, loss, n_items, penalty): +def _(draw_graphs, loss, n_items, penalty): draw_graphs(n_items, penalty, loss) return @app.cell -def __(mo, pymde): +def _(mo, pymde): penalties = { "Linear": pymde.penalties.Linear, "Quadratic": pymde.penalties.Quadratic, @@ -47,7 +50,7 @@ def __(mo, pymde): @app.cell -def __(mo, pymde): +def _(mo, pymde): losses = { "Linear": pymde.losses.Absolute, "Quadratic": pymde.losses.Quadratic, @@ -58,7 +61,7 @@ def __(mo, pymde): @app.cell -def __(complete_graph, mo, tree): +def _(complete_graph, mo, tree): def draw_graphs(n_items, penalty, loss): complete_graph_tab = [penalty, complete_graph(n_items.value, penalty.value)] tree_tab = [loss, tree(n_items.value, loss.value)] @@ -68,17 +71,18 @@ def draw_graphs(n_items, penalty, loss): "Binary tree": tree_tab, } ) - return draw_graphs, + + return (draw_graphs,) @app.cell -def __(): +def _(): embedding_dim = 2 - return embedding_dim, + return (embedding_dim,) @app.cell -def __(format_axis, functools, penalties, pymde): +def _(functools, penalties, pymde): @functools.cache def complete_graph(n_items, penalty): edges = pymde.all_edges(n_items) @@ -91,11 +95,12 @@ def complete_graph(n_items, penalty): ) mde.embed(verbose=False) return format_axis(mde.plot(edges=edges)) - return complete_graph, + + return (complete_graph,) @app.cell -def __(embedding_dim, format_axis, functools, losses, pymde, torch): +def _(embedding_dim, functools, losses, pymde, torch): @functools.cache def tree(n_items, loss): edges = [] @@ -121,23 +126,22 @@ def tree(n_items, loss): ) mde.embed(snapshot_every=1, max_iter=20, verbose=False) return format_axis(mde.plot(edges=tree.edges)) - return tree, + return (tree,) -@app.cell -def __(): - def format_axis(ax): - ax.figure.set_size_inches(3.5, 3.5) - ax.set_title("embedding") - ax.figure.tight_layout() - ax.set_xticks([]) - ax.set_yticks([]) - return ax - return format_axis, + +@app.function +def format_axis(ax): + ax.figure.set_size_inches(3.5, 3.5) + ax.set_title("embedding") + ax.figure.tight_layout() + ax.set_xticks([]) + ax.set_yticks([]) + return ax @app.cell -def __(): +def _(): import marimo as mo import pymde @@ -145,7 +149,8 @@ def __(): import numpy as np import scipy.sparse as sp import torch - return functools, mo, np, pymde, sp, torch + + return functools, mo, pymde, torch if __name__ == "__main__": diff --git a/examples/third_party/pymde/embedding_numbers.py b/examples/third_party/pymde/embedding_numbers.py index ed10abb8772..bade49dc969 100644 --- a/examples/third_party/pymde/embedding_numbers.py +++ b/examples/third_party/pymde/embedding_numbers.py @@ -4,33 +4,32 @@ # "pymde==0.1.18", # ] # /// + import marimo -__generated_with = "0.8.0" +__generated_with = "0.19.7" app = marimo.App() @app.cell -def __(mo): - mo.md( - """ - # Embedding MNIST - - This app shows how to use the function `pymde.preserve_neighbors` - to produce embeddings that highlight the local structure of your - data, using MNIST as a case study. In these embeddings similar - digits are near each other, and dissimilar digits are not near each other. - - ## Data - The data we'll embed are 70,000 28x28 grayscale images of handwritten - digits: - """ - ) +def _(mo): + mo.md(""" + # Embedding MNIST + + This app shows how to use the function `pymde.preserve_neighbors` + to produce embeddings that highlight the local structure of your + data, using MNIST as a case study. In these embeddings similar + digits are near each other, and dissimilar digits are not near each other. + + ## Data + The data we'll embed are 70,000 28x28 grayscale images of handwritten + digits: + """) return @app.cell -def __(button, show_random_images): +def _(button, show_random_images): button show_random_images(5) @@ -38,14 +37,14 @@ def __(button, show_random_images): @app.cell -def __(mo): +def _(mo): button = mo.ui.button(label="Click this button") mo.md(f"{button} _to view another random sample of images._").center() - return button, + return (button,) @app.cell -def __(mo): +def _(mo): params = ( mo.md( """ @@ -67,11 +66,11 @@ def __(mo): ) params - return params, + return (params,) @app.cell -def __(params): +def _(params): if params.value is not None: embedding_dimension, constraint_type = ( params.value["embedding_dimension"], @@ -83,7 +82,7 @@ def __(params): @app.cell -def __(constraint_type, pymde): +def _(constraint_type, pymde): if constraint_type is not None: _constraints = { "Centered": pymde.Centered(), @@ -91,18 +90,11 @@ def __(constraint_type, pymde): } constraint = _constraints[constraint_type] - return constraint, + return (constraint,) @app.cell -def __( - compute_embedding, - constraint, - embedding_dimension, - mnist, - plt, - pymde, -): +def _(compute_embedding, constraint, embedding_dimension, mnist, plt, pymde): def show_embedding(): _, embedding = compute_embedding(embedding_dimension, constraint) pymde.plot(embedding, color_by=mnist.attributes["digits"]) @@ -110,11 +102,11 @@ def show_embedding(): return plt.gca() show_embedding() if embedding_dimension is not None else None - return show_embedding, + return @app.cell -def __(mnist, mo, pymde, torch): +def _(mnist, mo, pymde, torch): embedding_cache = {} @@ -137,17 +129,18 @@ def compute_embedding(embedding_dim, constraint): value = (mde, X) embedding_cache[key] = value return value - return compute_embedding, embedding_cache + + return (compute_embedding,) @app.cell -def __(pymde): +def _(pymde): mnist = pymde.datasets.MNIST() - return mnist, + return (mnist,) @app.cell -def __(mnist, plt, torch): +def _(mnist, plt, torch): def show_random_images(n_images): indices = torch.randperm(mnist.data.shape[0])[:n_images] images = mnist.data[indices].reshape((-1, 28, 28)) @@ -159,16 +152,18 @@ def show_random_images(n_images): ax.set_xticks([]) plt.tight_layout() return fig - return show_random_images, + + return (show_random_images,) @app.cell -def __(): +def _(): import matplotlib.pyplot as plt import pymde import torch import marimo as mo + return mo, plt, pymde, torch diff --git a/examples/third_party/pymde/google_scholar.py b/examples/third_party/pymde/google_scholar.py index 1ce3492bd6f..5e915c38027 100644 --- a/examples/third_party/pymde/google_scholar.py +++ b/examples/third_party/pymde/google_scholar.py @@ -4,26 +4,25 @@ # "pymde==0.1.18", # ] # /// + import marimo -__generated_with = "0.1.68" +__generated_with = "0.19.7" app = marimo.App() @app.cell def _(mo): - mo.md( - r""" - # Embedding Google Scholar - - This notebook shows how to use the function `pymde.preserve_distances` to - produce embeddings of networks, in which the goal is to preserve the - shortest-path distances in the network. - - It uses an academic co-authorship network collected from Google Scholar as - a case study. - """ - ) + mo.md(r""" + # Embedding Google Scholar + + This notebook shows how to use the function `pymde.preserve_distances` to + produce embeddings of networks, in which the goal is to preserve the + shortest-path distances in the network. + + It uses an academic co-authorship network collected from Google Scholar as + a case study. + """) return @@ -34,26 +33,27 @@ def _(): import matplotlib.pyplot as plt import numpy as np import torch + return np, plt, pymde, torch @app.cell def _(pymde): gscholar = pymde.datasets.google_scholar() - return gscholar, + return (gscholar,) @app.cell def _(gscholar): scholars_df = gscholar.other_data['dataframe'] scholars_df - return scholars_df, + return @app.cell def _(gscholar): coauthorship_graph = gscholar.data - return coauthorship_graph, + return (coauthorship_graph,) @app.cell @@ -84,7 +84,7 @@ def _(DEVICE, coauthorship_graph, pymde): verbose=True) mde.embed(verbose=True) - return mde, + return (mde,) @app.cell @@ -97,7 +97,7 @@ def _(mde, np, plt): plt.xticks(np.arange(1, 11)) plt.xlabel('graph distances') plt.show() - return ax, original_distances + return @app.cell @@ -122,7 +122,7 @@ def _(coauthorship_graph, gscholar, mde, plt, torch): _ax = mde.plot(edges=edges, color_by=gscholar.attributes['coauthors'], color_map='viridis', figsize_inches=(12, 12)) plt.tight_layout() _ax - return edges, indices + return @app.cell @@ -139,7 +139,7 @@ def _(gscholar): } scholar_disciplines_df = gscholar.other_data['disciplines'] topic_colors = [legend[code] for code in scholar_disciplines_df['topic']] - return colors, legend, scholar_disciplines_df, topic_colors + return scholar_disciplines_df, topic_colors @app.cell @@ -150,15 +150,16 @@ def _(mde, pymde, scholar_disciplines_df, topic_colors): @app.cell -def __(torch): +def _(torch): DEVICE = 'cuda' if torch.cuda.is_available() else 'cpu' - return DEVICE, + return (DEVICE,) @app.cell def _(): import marimo as mo - return mo, + + return (mo,) if __name__ == "__main__": diff --git a/examples/third_party/pymde/interactive_cluster_analysis.py b/examples/third_party/pymde/interactive_cluster_analysis.py index ffabdc64f16..37a2e14aeb1 100644 --- a/examples/third_party/pymde/interactive_cluster_analysis.py +++ b/examples/third_party/pymde/interactive_cluster_analysis.py @@ -5,37 +5,38 @@ # "pymde==0.1.18", # ] # /// + import marimo -__generated_with = "0.8.0" +__generated_with = "0.19.7" app = marimo.App() @app.cell -def __(mo): - mo.md("""# Cluster analysis""") +def _(mo): + mo.md(""" + # Cluster analysis + """) return @app.cell -def __(mo): - mo.md( - """ - Here's an **embedding of MNIST**: each point represents a digit, - with similar digits close to each other. - """ - ) +def _(mo): + mo.md(""" + Here's an **embedding of MNIST**: each point represents a digit, + with similar digits close to each other. + """) return @app.cell -def __(compute_embedding, constraint, embedding_dimension): +def _(compute_embedding, constraint, embedding_dimension): embedding = compute_embedding(embedding_dimension, constraint) - return embedding, + return (embedding,) @app.cell -def __(alt, df, mo): +def _(alt, df, mo): chart = mo.ui.altair_chart( alt.Chart(df) .mark_circle(size=4) @@ -48,17 +49,17 @@ def __(alt, df, mo): chart_selection="interval", ) chart - return chart, + return (chart,) @app.cell -def __(chart, mo): +def _(chart, mo): table = mo.ui.table(chart.value) - return table, + return (table,) @app.cell -def __(chart, mo, show_images, table): +def _(chart, mo, show_images, table): # mo.stop() prevents this cell from running if the chart has # no selection mo.stop(not len(chart.value)) @@ -82,18 +83,18 @@ def __(chart, mo, show_images, table): {table} """ ) - return selected_images, + return @app.cell -def __(pymde): +def _(pymde): embedding_dimension = 2 constraint = pymde.Standardized() return constraint, embedding_dimension @app.cell -def __(embedding, mnist, pd, torch): +def _(embedding, mnist, pd, torch): indices = torch.randperm(mnist.data.shape[0])[:20000].numpy() embedding_sampled = embedding.numpy()[indices] @@ -105,11 +106,11 @@ def __(embedding, mnist, pd, torch): "digit": mnist.attributes["digits"][indices], } ) - return df, embedding_sampled, indices + return (df,) @app.cell -def __(functools, mnist, mo, pymde, torch): +def _(functools, mnist, mo, pymde, torch): @functools.cache def compute_embedding(embedding_dim, constraint): mo.output.append( @@ -126,17 +127,18 @@ def compute_embedding(embedding_dim, constraint): X = mde.embed(verbose=True) mo.output.clear() return X - return compute_embedding, + + return (compute_embedding,) @app.cell -def __(pymde): +def _(pymde): mnist = pymde.datasets.MNIST() - return mnist, + return (mnist,) @app.cell -def __(mnist, plt): +def _(mnist, plt): def show_images(indices, max_images=10): indices = indices[:max_images] images = mnist.data.reshape((-1, 28, 28))[indices] @@ -153,11 +155,12 @@ def show_images(indices, max_images=10): axes.set_xticks([]) plt.tight_layout() return fig - return show_images, + + return (show_images,) @app.cell -def __(): +def _(): import functools import matplotlib.pyplot as plt @@ -165,19 +168,22 @@ def __(): import torch import marimo as mo + return functools, mo, plt, pymde, torch @app.cell -def __(): +def _(): import altair as alt - return alt, + + return (alt,) @app.cell -def __(): +def _(): import pandas as pd - return pd, + + return (pd,) if __name__ == "__main__": diff --git a/examples/third_party/pymde/rotational_invariance.py b/examples/third_party/pymde/rotational_invariance.py index 8be58071e02..84af3646357 100644 --- a/examples/third_party/pymde/rotational_invariance.py +++ b/examples/third_party/pymde/rotational_invariance.py @@ -4,50 +4,56 @@ # "pymde==0.1.18", # ] # /// + import marimo -__generated_with = "0.1.56" +__generated_with = "0.19.7" app = marimo.App(width="full") @app.cell -def __(mo): - mo.md("# Rotational Invariance of Embeddings") +def _(mo): + mo.md(""" + # Rotational Invariance of Embeddings + """) return @app.cell -def __(): +def _(): import pymde - return pymde, + + return (pymde,) @app.cell -def __(): +def _(): import matplotlib.pyplot as plt - return plt, + + return (plt,) @app.cell -def __(): +def _(): import marimo as mo - return mo, + + return (mo,) @app.cell -def __(mo): +def _(mo): degrees = mo.ui.slider(0, 360, step=5, label=r"$\theta$") - return degrees, + return (degrees,) @app.cell -def __(embedding, mde): +def _(embedding, mde): E_X = mde.average_distortion(embedding) - return E_X, + return (E_X,) @app.cell -def __(E_X, degrees, mde, mo, rotated_embedding): +def _(E_X, degrees, mde, mo, rotated_embedding): _E_X_hat = mde.average_distortion(rotated_embedding) rotation = mo.md( @@ -83,11 +89,11 @@ def __(E_X, degrees, mde, mo, rotated_embedding): which should match $E(X)$! """ ).style({"max-width": "550px"}) - return rotation, + return (rotation,) @app.cell -def __(embedding, mnist, plt, pymde): +def _(embedding, mnist, plt, pymde): import functools @functools.cache @@ -102,37 +108,38 @@ def rotate_embedding(degrees): ) plt.tight_layout() return rotated_embedding, ax - return functools, rotate_embedding + + return (rotate_embedding,) @app.cell -def __(degrees, rotate_embedding): +def _(degrees, rotate_embedding): rotated_embedding, ax = rotate_embedding(degrees.value) return ax, rotated_embedding @app.cell -def __(ax, mo, rotation): +def _(ax, mo, rotation): mo.hstack([rotation, mo.center(ax)], justify="start", align="start") return @app.cell -def __(pymde): +def _(pymde): mnist = pymde.datasets.MNIST() - return mnist, + return (mnist,) @app.cell -def __(mnist, pymde): +def _(mnist, pymde): mde = pymde.laplacian_embedding(mnist.data, verbose=True) - return mde, + return (mde,) @app.cell -def __(mde): +def _(mde): embedding = mde.embed(verbose=True) - return embedding, + return (embedding,) if __name__ == "__main__": diff --git a/examples/third_party/pymde/tree.py b/examples/third_party/pymde/tree.py index 6da64bfbb6d..d0ee2b0d2a6 100644 --- a/examples/third_party/pymde/tree.py +++ b/examples/third_party/pymde/tree.py @@ -4,14 +4,15 @@ # "pymde==0.1.18", # ] # /// + import marimo -__generated_with = "0.1.0" +__generated_with = "0.19.7" app = marimo.App() @app.cell -def __(mo, n_items): +def _(mo, n_items): mo.md( f""" # Embedding binary trees @@ -26,32 +27,32 @@ def __(mo, n_items): @app.cell -def __(mo, n_items): +def _(mo, n_items): mo.md(f"Let's embed a binary tree on **{n_items.value}** vertices.") return @app.cell -def __(embedding, pymde, tree): +def _(embedding, pymde, tree): pymde.plot(embedding, edges=tree.edges) return @app.cell -def __(embed, make_graph, n_items): +def _(embed, make_graph, n_items): tree, graph = make_graph(n_items.value) embedding = embed(graph) - return embedding, graph, tree + return embedding, tree @app.cell -def __(mo): +def _(mo): n_items = mo.ui.slider(64, 1024, step=64) - return n_items, + return (n_items,) @app.cell -def __(functools, pymde, torch): +def _(functools, pymde, torch): @functools.cache def make_graph(n_items): edges = [] @@ -68,11 +69,12 @@ def make_graph(n_items): stack.append(c2) tree = pymde.Graph.from_edges(torch.tensor(edges)) return tree, pymde.preprocess.graph.shortest_paths(tree) - return make_graph, + + return (make_graph,) @app.cell -def __(functools, pymde): +def _(functools, pymde): @functools.cache def embed(shortest_paths_graph): mde = pymde.MDE( @@ -84,17 +86,19 @@ def embed(shortest_paths_graph): ), ) return mde.embed(verbose=True) - return embed, + + return (embed,) @app.cell -def __(): +def _(): import functools import pymde import torch import marimo as mo + return functools, mo, pymde, torch diff --git a/examples/third_party/pymde/us_counties.py b/examples/third_party/pymde/us_counties.py index b37f946f11c..690b9dfbc51 100644 --- a/examples/third_party/pymde/us_counties.py +++ b/examples/third_party/pymde/us_counties.py @@ -5,60 +5,58 @@ # "pymde==0.1.18", # ] # /// + import marimo -__generated_with = "0.1.56" +__generated_with = "0.19.7" app = marimo.App() @app.cell def _(mo): - mo.md( - """ - # Embedding 🇺🇸 Counties - - This notebook accompanies chapter 10 of the monograph - [Minimum-Distortion Embedding](https://web.stanford.edu/~boyd/papers/min_dist_emb.html). - - 🇺🇸 In this example notebook, we use PyMDE to embed and visualize 3,220 - US counties, described by their demographic data (collected between 2013- - 2017 by an ACS longitudinal survey). - - 🌎 We then color each county by the fraction of voters who voted for a - democratic candidate in the 2016 presidential election. Interestingly, the - embedding vaguely resembles a map of the US, clustered by political party - preference, though no geographic or party preference data were - used to compute the embedding! - - ⚡ We use `mo.ui.altair_chart` to create a reactive and interactive - scatter plot of the embedding: this makes it possible to see where counties - land in the embedding! - """ - ) + mo.md(""" + # Embedding 🇺🇸 Counties + + This notebook accompanies chapter 10 of the monograph + [Minimum-Distortion Embedding](https://web.stanford.edu/~boyd/papers/min_dist_emb.html). + + 🇺🇸 In this example notebook, we use PyMDE to embed and visualize 3,220 + US counties, described by their demographic data (collected between 2013- + 2017 by an ACS longitudinal survey). + + 🌎 We then color each county by the fraction of voters who voted for a + democratic candidate in the 2016 presidential election. Interestingly, the + embedding vaguely resembles a map of the US, clustered by political party + preference, though no geographic or party preference data were + used to compute the embedding! + + ⚡ We use `mo.ui.altair_chart` to create a reactive and interactive + scatter plot of the embedding: this makes it possible to see where counties + land in the embedding! + """) return @app.cell def _(): import pymde - return pymde, + + return (pymde,) @app.cell def _(pymde): dataset = pymde.datasets.counties() - return dataset, + return (dataset,) @app.cell def _(mo): - mo.md( - """ - ## The data + mo.md(""" + ## The data - The data we embed includes demographic information about each county. - """ - ) + The data we embed includes demographic information about each county. + """) return @@ -70,42 +68,38 @@ def _(dataset, mo): @app.cell def _(mo): - mo.md( - """ - ## The embedding - - We now make a neighbor-preserving embedding, to explore the local - relationships in the data. - """ - ) + mo.md(""" + ## The embedding + + We now make a neighbor-preserving embedding, to explore the local + relationships in the data. + """) return @app.cell def _(dataset, pymde): mde = pymde.preserve_neighbors(data=dataset.data, verbose=True) - return mde, + return (mde,) @app.cell def _(mde): embedding = mde.embed() - return embedding, + return (embedding,) @app.cell def _(mo): - mo.md( - """ - Finally we visualize the embedding, rotating it so that it vaguely - resembles a map of the US. Note that counties that voted Republican tend - to cluster together, as do counties that voted Democratic, even though - our original data had no information about political party preference! - - In some real sense, the embedding "discovered" political preference - from demographic data. - """ - ) + mo.md(""" + Finally we visualize the embedding, rotating it so that it vaguely + resembles a map of the US. Note that counties that voted Republican tend + to cluster together, as do counties that voted Democratic, even though + our original data had no information about political party preference! + + In some real sense, the embedding "discovered" political preference + from demographic data. + """) return @@ -113,11 +107,11 @@ def _(mo): def _(embedding, pymde): # Rotate the embedding by some amount of degrees rotated_embedding = pymde.rotate(embedding, -30.0) - return rotated_embedding, + return (rotated_embedding,) @app.cell -def __(dataset, pd, rotated_embedding): +def _(dataset, pd, rotated_embedding): embedding_df = pd.concat( [ pd.DataFrame( @@ -133,24 +127,22 @@ def __(dataset, pd, rotated_embedding): ], axis=1, ) - return embedding_df, + return (embedding_df,) @app.cell -def __(mo): - mo.md( - """ - ### Try it! 👆 +def _(mo): + mo.md(""" + ### Try it! 👆 - Select points in the scatter plot below with your cursor: they're - automatically sent back to Python, letting you investigate further! - """ - ) + Select points in the scatter plot below with your cursor: they're + automatically sent back to Python, letting you investigate further! + """) return @app.cell -def __(alt, embedding_df, mo): +def _(alt, embedding_df, mo): plot = mo.ui.altair_chart( alt.Chart(data=embedding_df, width=400, height=400) .mark_circle(size=10, opacity=1) @@ -161,37 +153,41 @@ def __(alt, embedding_df, mo): ) ) plot - return plot, + return (plot,) @app.cell -def __(mo, plot): +def _(mo, plot): mo.ui.table(plot.value) return @app.cell -def __(): +def _(): import matplotlib.pyplot as plt - return plt, + + return @app.cell -def __(): +def _(): import pandas as pd - return pd, + + return (pd,) @app.cell -def __(): +def _(): import altair as alt - return alt, + + return (alt,) @app.cell def _(): import marimo as mo - return mo, + + return (mo,) if __name__ == "__main__": diff --git a/examples/third_party/sage/chat_with_github_repo.py b/examples/third_party/sage/chat_with_github_repo.py index 93cd1246135..f1753cfab54 100644 --- a/examples/third_party/sage/chat_with_github_repo.py +++ b/examples/third_party/sage/chat_with_github_repo.py @@ -8,24 +8,25 @@ import marimo -__generated_with = "0.9.0" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell -def __(): +def _(): import marimo as mo + return (mo,) @app.cell -def __(mo, sys): +def _(mo, sys): mo.md(f"# 🤖💬 {sys.argv[-1]} ") return @app.cell(hide_code=True) -def __(mo, sys): +def _(mo, sys): mo.md( f""" I'm a chatbot that can answer questions about the **{sys.argv[-1]}** GitHub repo. Ask me anything! @@ -35,7 +36,7 @@ def __(mo, sys): @app.cell -def __(): +def _(): import sys import logging @@ -63,8 +64,6 @@ def __(): create_history_aware_retriever, create_retrieval_chain, create_stuff_documents_chain, - dotenv, - gr, logging, sage_config, sys, @@ -72,7 +71,7 @@ def __(): @app.cell -def __( +def _( ChatPromptTemplate, MessagesPlaceholder, build_llm_via_langchain, @@ -125,11 +124,12 @@ def build_rag_chain(args): history_aware_retriever, question_answer_chain ) return rag_chain + return (build_rag_chain,) @app.cell -def __(configargparse, sage_config, sys): +def _(configargparse, sage_config, sys): parser = configargparse.ArgParser( description="Batch-embeds a GitHub repository and its issues.", ignore_unknown_config_file_keys=True, @@ -153,17 +153,17 @@ def __(configargparse, sage_config, sys): args = parser.parse_args(_argv) for validator in arg_validators: validator(args) - return arg_validators, args, parser, validator + return (args,) @app.cell -def __(args): +def _(args): args return @app.cell -def __(AIMessage, HumanMessage, args, build_rag_chain, logging, mo): +def _(AIMessage, HumanMessage, args, build_rag_chain, logging, mo): rag_chain = build_rag_chain(args) @@ -220,11 +220,12 @@ async def predict(messages): logging.info(f"Query rewrite: {query_rewrite}") response += chunk yield mo.md(response) - return predict, rag_chain, source_md + + return (predict,) @app.cell -def __(mo, predict): +def _(mo, predict): mo.ui.chat(predict, prompts=["What does this repo do?", "Give me some sample code"]) return diff --git a/examples/third_party/substrate/video_generation.py b/examples/third_party/substrate/video_generation.py index 9f78ff8ee23..d0225552ce8 100644 --- a/examples/third_party/substrate/video_generation.py +++ b/examples/third_party/substrate/video_generation.py @@ -8,18 +8,19 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell -def __(): +def _(): import marimo as mo + return (mo,) @app.cell -def __(mo): +def _(mo): API_KEY = mo.ui.text( label="Enter your [substrate.run](https://substrate.run) API Key:", kind="password", @@ -29,7 +30,7 @@ def __(mo): @app.cell -def __(substrate): +def _(substrate): from substrate import GenerateImage, UpscaleImage, StableVideoDiffusion prompt = "aerial shot of rainforest at sunset clouds sun rays" @@ -47,33 +48,24 @@ def __(substrate): ) res = substrate.run(video_node) - return ( - GenerateImage, - StableVideoDiffusion, - UpscaleImage, - image_node, - prompt, - res, - upscale_node, - video_node, - ) + return res, video_node @app.cell -def __(mo, res, video_node): +def _(mo, res, video_node): video = res.get(video_node) mo.image(video.video_uri) - return (video,) + return @app.cell -def __(API_KEY, mo): +def _(API_KEY, mo): mo.stop(API_KEY.value is None) from substrate import Substrate, ComputeText, sb substrate = Substrate(api_key=API_KEY.value) - return ComputeText, Substrate, sb, substrate + return (substrate,) if __name__ == "__main__": diff --git a/examples/third_party/unsloth/llama_3_1_8b_2x_faster_finetuning.py b/examples/third_party/unsloth/llama_3_1_8b_2x_faster_finetuning.py index 8cd4a5d6eae..45e8a548b65 100644 --- a/examples/third_party/unsloth/llama_3_1_8b_2x_faster_finetuning.py +++ b/examples/third_party/unsloth/llama_3_1_8b_2x_faster_finetuning.py @@ -11,82 +11,85 @@ # "xformers", # ] # /// + import marimo -__generated_with = "0.8.13" +__generated_with = "0.19.7" app = marimo.App(width="full") @app.cell -def __(): +def _(): import marimo as mo - return mo, + + return (mo,) @app.cell -def __(): +def _(): from unsloth import FastLanguageModel from transformers import TextStreamer import torch - return FastLanguageModel, TextStreamer, torch + + return FastLanguageModel, torch @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - - - - - - This example shows how to use [Unsloth](https://github.com/unslothai/unsloth) to finetune Llama-3.1 8b. It uses - marimo's UI elements to make the experience interactive, so you don't need to edit any code! +def _(mo): + mo.md(""" + + + + + + This example shows how to use [Unsloth](https://github.com/unslothai/unsloth) to finetune Llama-3.1 8b. It uses + marimo's UI elements to make the experience interactive, so you don't need to edit any code! - _Join Discord if you need help + ⭐ Star us on Github⭐_ + _Join Discord if you need help + ⭐ Star us on Github⭐_ - To install Unsloth on your own computer, follow the installation instructions on our Github page [here](https://github.com/unslothai/unsloth?tab=readme-ov-file#-installation-instructions). + To install Unsloth on your own computer, follow the installation instructions on our Github page [here](https://github.com/unslothai/unsloth?tab=readme-ov-file#-installation-instructions). - You will learn how to do [data prep](#Data), how to [train](#Train), how to [run the model](#Inference), & [how to save it](#Save) (eg for Llama.cpp). + You will learn how to do [data prep](#Data), how to [train](#Train), how to [run the model](#Inference), & [how to save it](#Save) (eg for Llama.cpp). - [NEW] Llama-3.1 8b, 70b & 405b are trained on a crazy 15 trillion tokens with 128K long context lengths! + [NEW] Llama-3.1 8b, 70b & 405b are trained on a crazy 15 trillion tokens with 128K long context lengths! - **[NEW] Try 2x faster inference in a free Colab for Llama-3.1 8b Instruct [here](https://colab.research.google.com/drive/1T-YBVfnphoVc8E2E854qF3jdia2Ll2W2?usp=sharing)** - """ - ) + **[NEW] Try 2x faster inference in a free Colab for Llama-3.1 8b Instruct [here](https://colab.research.google.com/drive/1T-YBVfnphoVc8E2E854qF3jdia2Ll2W2?usp=sharing)** + """) return @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - * We support Llama, Mistral, Phi-3, Gemma, Yi, DeepSeek, Qwen, TinyLlama, Vicuna, Open Hermes etc - * We support 16bit LoRA or 4bit QLoRA. Both 2x faster. - * `max_seq_length` can be set to anything, since we do automatic RoPE Scaling via [kaiokendev's](https://kaiokendev.github.io/til) method. - * [**NEW**] We make Gemma-2 9b / 27b **2x faster**! See our [Gemma-2 9b notebook](https://colab.research.google.com/drive/1vIrqH5uYDQwsJ4-OO3DErvuv4pBgVwk4?usp=sharing) - * [**NEW**] To finetune and auto export to Ollama, try our [Ollama notebook](https://colab.research.google.com/drive/1WZDi7APtQ9VsvOrQSSC5DDtxq159j8iZ?usp=sharing) - * [**NEW**] We make Mistral NeMo 12B 2x faster and fit in under 12GB of VRAM! [Mistral NeMo notebook](https://colab.research.google.com/drive/17d3U-CAIwzmbDRqbZ9NnpHxCkmXB6LZ0?usp=sharing) - """ - ) +def _(mo): + mo.md(r""" + * We support Llama, Mistral, Phi-3, Gemma, Yi, DeepSeek, Qwen, TinyLlama, Vicuna, Open Hermes etc + * We support 16bit LoRA or 4bit QLoRA. Both 2x faster. + * `max_seq_length` can be set to anything, since we do automatic RoPE Scaling via [kaiokendev's](https://kaiokendev.github.io/til) method. + * [**NEW**] We make Gemma-2 9b / 27b **2x faster**! See our [Gemma-2 9b notebook](https://colab.research.google.com/drive/1vIrqH5uYDQwsJ4-OO3DErvuv4pBgVwk4?usp=sharing) + * [**NEW**] To finetune and auto export to Ollama, try our [Ollama notebook](https://colab.research.google.com/drive/1WZDi7APtQ9VsvOrQSSC5DDtxq159j8iZ?usp=sharing) + * [**NEW**] We make Mistral NeMo 12B 2x faster and fit in under 12GB of VRAM! [Mistral NeMo notebook](https://colab.research.google.com/drive/17d3U-CAIwzmbDRqbZ9NnpHxCkmXB6LZ0?usp=sharing) + """) return @app.cell -def __(mo): - mo.md("### Load the model") +def _(mo): + mo.md(""" + ### Load the model + """) return @app.cell(hide_code=True) -def __(mo): - mo.md("""Start by choosing a couple of parameters:""") +def _(mo): + mo.md(""" + Start by choosing a couple of parameters: + """) return @app.cell(hide_code=True) -def __(mo): +def _(mo): max_seq_length = mo.ui.number( value=2048, start=1, stop=4096, label="Max sequence length" ) @@ -98,19 +101,21 @@ def __(mo): @app.cell -def __(): +def _(): dtype = None # None for auto detection. Float16 for Tesla T4, V100, Bfloat16 for Ampere+ - return dtype, + return (dtype,) @app.cell(hide_code=True) -def __(mo): - mo.md(r"""We now add LoRA adapters so we only need to update 1 to 10% of all parameters!""") +def _(mo): + mo.md(r""" + We now add LoRA adapters so we only need to update 1 to 10% of all parameters! + """) return @app.cell -def __(FastLanguageModel, dtype, load_in_4bit, max_seq_length): +def _(FastLanguageModel, dtype, load_in_4bit, max_seq_length): model, tokenizer = FastLanguageModel.from_pretrained( model_name="unsloth/Meta-Llama-3.1-8B", max_seq_length=max_seq_length.value, @@ -143,27 +148,25 @@ def __(FastLanguageModel, dtype, load_in_4bit, max_seq_length): @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - - ### Data Prep - We now use the Alpaca dataset from [yahma](https://huggingface.co/datasets/yahma/alpaca-cleaned), which is a filtered version of 52K of the original [Alpaca dataset](https://crfm.stanford.edu/2023/03/13/alpaca.html). You can replace this code section with your own data prep. +def _(mo): + mo.md(r""" + + ### Data Prep + We now use the Alpaca dataset from [yahma](https://huggingface.co/datasets/yahma/alpaca-cleaned), which is a filtered version of 52K of the original [Alpaca dataset](https://crfm.stanford.edu/2023/03/13/alpaca.html). You can replace this code section with your own data prep. - **[NOTE]** To train only on completions (ignoring the user's input) read TRL's docs [here](https://huggingface.co/docs/trl/sft_trainer#train-on-completions-only). + **[NOTE]** To train only on completions (ignoring the user's input) read TRL's docs [here](https://huggingface.co/docs/trl/sft_trainer#train-on-completions-only). - **[NOTE]** Remember to add the **EOS_TOKEN** to the tokenized output!! Otherwise you'll get infinite generations! + **[NOTE]** Remember to add the **EOS_TOKEN** to the tokenized output!! Otherwise you'll get infinite generations! - If you want to use the `llama-3` template for ShareGPT datasets, try our conversational [notebook](https://colab.research.google.com/drive/1XamvWYinY6FOSX9GLvnqSjjsNflxdhNc?usp=sharing). + If you want to use the `llama-3` template for ShareGPT datasets, try our conversational [notebook](https://colab.research.google.com/drive/1XamvWYinY6FOSX9GLvnqSjjsNflxdhNc?usp=sharing). - For text completions like novel writing, try this [notebook](https://colab.research.google.com/drive/1ef-tab5bhkvWmBOObepl1WgJvfvSzn5Q?usp=sharing). - """ - ) + For text completions like novel writing, try this [notebook](https://colab.research.google.com/drive/1ef-tab5bhkvWmBOObepl1WgJvfvSzn5Q?usp=sharing). + """) return @app.cell -def __(tokenizer): +def _(tokenizer): alpaca_prompt = """Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request. ### Instruction: @@ -201,28 +204,20 @@ def formatting_prompts_func(examples): formatting_prompts_func, batched=True, ) - return ( - EOS_TOKEN, - alpaca_prompt, - dataset, - formatting_prompts_func, - load_dataset, - ) + return alpaca_prompt, dataset @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - ### Train the model - Now let's use Huggingface TRL's `SFTTrainer`! More docs here: [TRL SFT docs](https://huggingface.co/docs/trl/sft_trainer). We do 60 steps to speed things up, but you can set `num_train_epochs=1` for a full run, and turn off `max_steps=None`. We also support TRL's `DPOTrainer`! - """ - ) +def _(mo): + mo.md(r""" + ### Train the model + Now let's use Huggingface TRL's `SFTTrainer`! More docs here: [TRL SFT docs](https://huggingface.co/docs/trl/sft_trainer). We do 60 steps to speed things up, but you can set `num_train_epochs=1` for a full run, and turn off `max_steps=None`. We also support TRL's `DPOTrainer`! + """) return @app.cell(hide_code=True) -def __(dataset, max_seq_length, model, tokenizer): +def _(dataset, max_seq_length, model, tokenizer): from trl import SFTTrainer from transformers import TrainingArguments from unsloth import is_bfloat16_supported @@ -252,11 +247,11 @@ def __(dataset, max_seq_length, model, tokenizer): output_dir="outputs", ), ) - return SFTTrainer, TrainingArguments, is_bfloat16_supported, trainer + return (trainer,) @app.cell(hide_code=True) -def __(torch): +def _(torch): # @title Show current memory stats gpu_stats = torch.cuda.get_device_properties(0) start_gpu_memory = round( @@ -265,26 +260,26 @@ def __(torch): max_memory = round(gpu_stats.total_memory / 1024 / 1024 / 1024, 3) print(f"GPU = {gpu_stats.name}. Max memory = {max_memory} GB.") print(f"{start_gpu_memory} GB of memory reserved.") - return gpu_stats, max_memory, start_gpu_memory + return max_memory, start_gpu_memory @app.cell -def __(mo): +def _(mo): train_button = mo.ui.run_button(label="Click to train!", kind="danger"); train_button.center() - return train_button, + return (train_button,) @app.cell -def __(train_button, trainer): +def _(train_button, trainer): if train_button.value: trainer_stats = trainer.train() else: trainer_stats = None - return trainer_stats, + return (trainer_stats,) @app.cell -def __(max_memory, mo, start_gpu_memory, torch, trainer_stats): +def _(max_memory, mo, start_gpu_memory, torch, trainer_stats): mo.stop(trainer_stats is None, mo.md("Train the model 👆")) # Show final memory and time stats @@ -302,30 +297,23 @@ def __(max_memory, mo, start_gpu_memory, torch, trainer_stats): print( f"Peak reserved memory for training % of max memory = {lora_percentage} %." ) - return ( - lora_percentage, - used_memory, - used_memory_for_lora, - used_percentage, - ) + return @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - - ### Inference - Let's run the model! You can change the instruction and input - leave the output blank! - - **[NEW] Try 2x faster inference in a free Colab for Llama-3.1 8b Instruct [here](https://colab.research.google.com/drive/1T-YBVfnphoVc8E2E854qF3jdia2Ll2W2?usp=sharing)** - """ - ) +def _(mo): + mo.md(r""" + + ### Inference + Let's run the model! You can change the instruction and input - leave the output blank! + + **[NEW] Try 2x faster inference in a free Colab for Llama-3.1 8b Instruct [here](https://colab.research.google.com/drive/1T-YBVfnphoVc8E2E854qF3jdia2Ll2W2?usp=sharing)** + """) return @app.cell -def __(mo): +def _(mo): inf_instr_inp = mo.md( """ {instr} @@ -339,18 +327,11 @@ def __(mo): inf_instr_inp - return inf_instr_inp, + return (inf_instr_inp,) @app.cell -def __( - FastLanguageModel, - alpaca_prompt, - inf_instr_inp, - mo, - model, - tokenizer, -): +def _(FastLanguageModel, alpaca_prompt, inf_instr_inp, mo, model, tokenizer): mo.stop(inf_instr_inp.value is None) FastLanguageModel.for_inference(model) # Enable native 2x faster inference @@ -367,36 +348,34 @@ def __( outputs = model.generate(**_inputs, max_new_tokens=64, use_cache=True) tokenizer.batch_decode(outputs) - return outputs, + return @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - And we're done! If you have any questions on Unsloth, we have a [Discord](https://discord.gg/u54VK8m8tk) channel! If you find any bugs or want to keep updated with the latest LLM stuff, or need help, join projects etc, feel free to join our Discord! - - Some other links: - 1. Zephyr DPO 2x faster [free Colab](https://colab.research.google.com/drive/15vttTpzzVXv_tJwEk-hIcQ0S9FcEWvwP?usp=sharing) - 2. Llama 7b 2x faster [free Colab](https://colab.research.google.com/drive/1lBzz5KeZJKXjvivbYvmGarix9Ao6Wxe5?usp=sharing) - 3. TinyLlama 4x faster full Alpaca 52K in 1 hour [free Colab](https://colab.research.google.com/drive/1AZghoNBQaMDgWJpi4RbffGM1h6raLUj9?usp=sharing) - 4. CodeLlama 34b 2x faster [A100 on Colab](https://colab.research.google.com/drive/1y7A0AxE3y8gdj4AVkl2aZX47Xu3P1wJT?usp=sharing) - 5. Mistral 7b [free Kaggle version](https://www.kaggle.com/code/danielhanchen/kaggle-mistral-7b-unsloth-notebook) - 6. We also did a [blog](https://huggingface.co/blog/unsloth-trl) with 🤗 HuggingFace, and we're in the TRL [docs](https://huggingface.co/docs/trl/main/en/sft_trainer#accelerate-fine-tuning-2x-using-unsloth)! - 7. `ChatML` for ShareGPT datasets, [conversational notebook](https://colab.research.google.com/drive/1Aau3lgPzeZKQ-98h69CCu1UJcvIBLmy2?usp=sharing) - 8. Text completions like novel writing [notebook](https://colab.research.google.com/drive/1ef-tab5bhkvWmBOObepl1WgJvfvSzn5Q?usp=sharing) - 9. [**NEW**] We make Phi-3 Medium / Mini **2x faster**! See our [Phi-3 Medium notebook](https://colab.research.google.com/drive/1hhdhBa1j_hsymiW9m-WzxQtgqTH_NHqi?usp=sharing) - 10. [**NEW**] We make Gemma-2 9b / 27b **2x faster**! See our [Gemma-2 9b notebook](https://colab.research.google.com/drive/1vIrqH5uYDQwsJ4-OO3DErvuv4pBgVwk4?usp=sharing) - 11. [**NEW**] To finetune and auto export to Ollama, try our [Ollama notebook](https://colab.research.google.com/drive/1WZDi7APtQ9VsvOrQSSC5DDtxq159j8iZ?usp=sharing) - 12. [**NEW**] We make Mistral NeMo 12B 2x faster and fit in under 12GB of VRAM! [Mistral NeMo notebook](https://colab.research.google.com/drive/17d3U-CAIwzmbDRqbZ9NnpHxCkmXB6LZ0?usp=sharing) - -
- - - Support our work if you can! Thanks! -
- """ - ) +def _(mo): + mo.md(r""" + And we're done! If you have any questions on Unsloth, we have a [Discord](https://discord.gg/u54VK8m8tk) channel! If you find any bugs or want to keep updated with the latest LLM stuff, or need help, join projects etc, feel free to join our Discord! + + Some other links: + 1. Zephyr DPO 2x faster [free Colab](https://colab.research.google.com/drive/15vttTpzzVXv_tJwEk-hIcQ0S9FcEWvwP?usp=sharing) + 2. Llama 7b 2x faster [free Colab](https://colab.research.google.com/drive/1lBzz5KeZJKXjvivbYvmGarix9Ao6Wxe5?usp=sharing) + 3. TinyLlama 4x faster full Alpaca 52K in 1 hour [free Colab](https://colab.research.google.com/drive/1AZghoNBQaMDgWJpi4RbffGM1h6raLUj9?usp=sharing) + 4. CodeLlama 34b 2x faster [A100 on Colab](https://colab.research.google.com/drive/1y7A0AxE3y8gdj4AVkl2aZX47Xu3P1wJT?usp=sharing) + 5. Mistral 7b [free Kaggle version](https://www.kaggle.com/code/danielhanchen/kaggle-mistral-7b-unsloth-notebook) + 6. We also did a [blog](https://huggingface.co/blog/unsloth-trl) with 🤗 HuggingFace, and we're in the TRL [docs](https://huggingface.co/docs/trl/main/en/sft_trainer#accelerate-fine-tuning-2x-using-unsloth)! + 7. `ChatML` for ShareGPT datasets, [conversational notebook](https://colab.research.google.com/drive/1Aau3lgPzeZKQ-98h69CCu1UJcvIBLmy2?usp=sharing) + 8. Text completions like novel writing [notebook](https://colab.research.google.com/drive/1ef-tab5bhkvWmBOObepl1WgJvfvSzn5Q?usp=sharing) + 9. [**NEW**] We make Phi-3 Medium / Mini **2x faster**! See our [Phi-3 Medium notebook](https://colab.research.google.com/drive/1hhdhBa1j_hsymiW9m-WzxQtgqTH_NHqi?usp=sharing) + 10. [**NEW**] We make Gemma-2 9b / 27b **2x faster**! See our [Gemma-2 9b notebook](https://colab.research.google.com/drive/1vIrqH5uYDQwsJ4-OO3DErvuv4pBgVwk4?usp=sharing) + 11. [**NEW**] To finetune and auto export to Ollama, try our [Ollama notebook](https://colab.research.google.com/drive/1WZDi7APtQ9VsvOrQSSC5DDtxq159j8iZ?usp=sharing) + 12. [**NEW**] We make Mistral NeMo 12B 2x faster and fit in under 12GB of VRAM! [Mistral NeMo notebook](https://colab.research.google.com/drive/17d3U-CAIwzmbDRqbZ9NnpHxCkmXB6LZ0?usp=sharing) + +
+ + + Support our work if you can! Thanks! +
+ """) return diff --git a/examples/ui/README.md b/examples/ui/README.md index e8c2df4ef3a..cc85ed536dd 100644 --- a/examples/ui/README.md +++ b/examples/ui/README.md @@ -1,6 +1,6 @@ # UI 🖱️ -These examples showcase how to use marimo's built-in UI elements. +These basic examples show how to use marimo's built-in UI elements. > [!TIP] > New to marimo? Run `marimo tutorial intro` and `marimo tutorial ui` @@ -14,13 +14,14 @@ examples folder_. The requirements of each notebook are serialized in them as a top-level comment. Here are the steps to open an example notebook: -1. [Install marimo](https://docs.marimo.io/getting_started/index.html#installation) -2. [Install `uv`](https://github.com/astral-sh/uv/?tab=readme-ov-file#installation) -3. Open an example with `marimo edit --sandbox `. +1. [Install `uv`](https://github.com/astral-sh/uv/?tab=readme-ov-file#installation) +2. Open an example with `uvx marimo edit --sandbox ` > [!TIP] > The [`--sandbox` flag](https://docs.marimo.io/guides/editor_features/package_management.html) opens the notebook in an isolated virtual environment, > automatically installing the notebook's dependencies 📦 -You can also open notebooks without `uv`, with just `marimo edit `; -however, you'll need to install the requirements yourself. +You can also open notebooks without `uv`, in which case you'll need to +manually [install marimo](https://docs.marimo.io/getting_started/index.html#installation) +first. Then run `marimo edit `; however, you'll also need to +install the requirements yourself. diff --git a/examples/ui/array_element.py b/examples/ui/array_element.py new file mode 100644 index 00000000000..474a0d57316 --- /dev/null +++ b/examples/ui/array_element.py @@ -0,0 +1,28 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + array = mo.ui.array([mo.ui.text(), mo.ui.slider(1, 10), mo.ui.date()]) + array + return (array,) + + +@app.cell +def _(array): + array.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/arrays_and_dicts.py b/examples/ui/arrays_and_dicts.py index 6389554c662..efd6f0a8892 100644 --- a/examples/ui/arrays_and_dicts.py +++ b/examples/ui/arrays_and_dicts.py @@ -7,51 +7,61 @@ import marimo -__generated_with = "0.9.14" +__generated_with = "0.19.7" app = marimo.App() +@app.cell +def _(): + import marimo as mo + import random + + return mo, random + + @app.cell(hide_code=True) -def __(mo): - mo.md("""# Arrays and Dictionaries""") +def _(mo): + mo.md(""" + # Arrays and Dictionaries + """) return @app.cell(hide_code=True) -def __(mo): - mo.md( - """ - Use `mo.ui.array` and `mo.ui.dictionary` to create UI elements that wrap - other elements. - - Because UI elements must be assigned to global variables, - these functions are required when the set of elements to create is not - known until runtime. - """ - ) +def _(mo): + mo.md(""" + Use `mo.ui.array` and `mo.ui.dictionary` to create UI elements that wrap + other elements. + + Because UI elements must be assigned to global variables, + these functions are required when the set of elements to create is not + known until runtime. + """) return @app.cell -def __(mo): +def _(mo): create = mo.ui.button(label="Create new collections") return (create,) @app.cell -def __(create): +def _(create): create.center() return @app.cell -def __(mo): - mo.md("""UI Elements ...""") +def _(mo): + mo.md(""" + UI Elements ... + """) return @app.cell -def __(create, mo, random): +def _(create, mo, random): create array = mo.ui.array( @@ -67,31 +77,31 @@ def __(create, mo, random): @app.cell -def __(mo): - mo.md("""... and their values""") +def _(mo): + mo.md(""" + ... and their values + """) return @app.cell -def __(array, dictionary, mo): +def _(array, dictionary, mo): mo.hstack([array.value, dictionary.value], justify="space-around") return @app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - Key difference between marimo dict and standard python dict: +def _(mo): + mo.md(r""" + Key difference between marimo dict and standard python dict: - The main reason to use `mo.ui.dictionary` is for reactive execution — when you interact with an element in a `mo.ui.dictionary`, all cells that reference the `mo.ui.dictionary` run automatically, just like all other ui elements. When you use a regular dictionary, you don't get this reactivity. - """ - ) + The main reason to use `mo.ui.dictionary` is for reactive execution — when you interact with an element in a `mo.ui.dictionary`, all cells that reference the `mo.ui.dictionary` run automatically, just like all other ui elements. When you use a regular dictionary, you don't get this reactivity. + """) return @app.cell(hide_code=True) -def __(create, mo): +def _(create, mo): create slider = mo.ui.slider(1, 10, show_value=True) @@ -120,11 +130,11 @@ def __(create, mo): ], justify="space-around", ) - return date, mo_d, py_d, slider, text + return mo_d, py_d @app.cell(hide_code=True) -def __(mo, mo_d, py_d): +def _(mo, mo_d, py_d): mo_d_ref = {k: mo_d[k].value for k in mo_d.value.keys()} py_d_ref = {k: py_d[k].value for k in py_d.keys()} mo.hstack( @@ -134,21 +144,16 @@ def __(mo, mo_d, py_d): ], justify="space-around", ) - return mo_d_ref, py_d_ref + return @app.cell(hide_code=True) -def __(mo): - mo.md(r"""Notice that when you interact with the UI elements in the marimo dict, the reference of marimo dict updates automatically. However, when you interact with the elements in the python dict, you need to manually re-run the cell to see the updated values.""") +def _(mo): + mo.md(r""" + Notice that when you interact with the UI elements in the marimo dict, the reference of marimo dict updates automatically. However, when you interact with the elements in the python dict, you need to manually re-run the cell to see the updated values. + """) return -@app.cell -def __(): - import marimo as mo - import random - return mo, random - - if __name__ == "__main__": app.run() diff --git a/examples/ui/batch.py b/examples/ui/batch.py new file mode 100644 index 00000000000..69229e1dbef --- /dev/null +++ b/examples/ui/batch.py @@ -0,0 +1,30 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + element = mo.md("{start} → {end}").batch( + start=mo.ui.date(label="Start"), end=mo.ui.date(label="End") + ) + element + return (element,) + + +@app.cell +def _(element): + element.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/batch_and_form.py b/examples/ui/batch_and_form.py index 095cb614379..e4a84281255 100644 --- a/examples/ui/batch_and_form.py +++ b/examples/ui/batch_and_form.py @@ -7,29 +7,36 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App() @app.cell -def __(mo): - mo.md("""# Batch and Form""") +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + mo.md(""" + # Batch and Form + """) return @app.cell -def __(mo): - mo.md( - """ - Make custom UI elements using `batch()`, and turn any UI element - into a form with `form()`. - """ - ) +def _(mo): + mo.md(""" + Make custom UI elements using `batch()`, and turn any UI element + into a form with `form()`. + """) return @app.cell -def __(mo, reset): +def _(mo, reset): reset variables = ( @@ -54,13 +61,13 @@ def __(mo, reset): @app.cell -def __(mo, reset, submitted_values, variables): +def _(mo, reset, submitted_values, variables): if variables.value is not None: submitted_values["x"].add(variables.value["x"]) submitted_values["y"].add(variables.value["y"]) - x = variables.value["x"] if variables.value else "\ldots" - y = variables.value["y"] if variables.value else "\ldots" + x = variables.value["x"] if variables.value else r"\ldots" + y = variables.value["y"] if variables.value else r"\ldots" mo.md( @@ -73,11 +80,11 @@ def __(mo, reset, submitted_values, variables): {mo.hstack([mo.tree(submitted_values), reset], align="center", gap=4)} """ ).callout() - return x, y + return @app.cell -def __(reset): +def _(reset): reset submitted_values = {"x": set(), "y": set()} @@ -85,16 +92,10 @@ def __(reset): @app.cell -def __(mo): +def _(mo): reset = mo.ui.button(label="reset history") return (reset,) -@app.cell -def __(): - import marimo as mo - return (mo,) - - if __name__ == "__main__": app.run() diff --git a/examples/ui/button.py b/examples/ui/button.py new file mode 100644 index 00000000000..95b20e2d038 --- /dev/null +++ b/examples/ui/button.py @@ -0,0 +1,30 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + button = mo.ui.button( + value=0, on_click=lambda value: value + 1, label="increment", kind="warn" + ) + button + return (button,) + + +@app.cell +def _(button): + button.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/chat.py b/examples/ui/chat.py new file mode 100644 index 00000000000..19a99d95819 --- /dev/null +++ b/examples/ui/chat.py @@ -0,0 +1,35 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + def simple_echo_model(messages, config): + return f"You said: {messages[-1].content}" + + chatbot = mo.ui.chat( + simple_echo_model, + prompts=["Hello", "How are you?"], + show_configuration_controls=True + ) + chatbot + return (chatbot,) + + +@app.cell +def _(chatbot): + chatbot.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/checkbox.py b/examples/ui/checkbox.py new file mode 100644 index 00000000000..9075a6b119c --- /dev/null +++ b/examples/ui/checkbox.py @@ -0,0 +1,28 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + checkbox = mo.ui.checkbox(label="check me") + checkbox + return (checkbox,) + + +@app.cell +def _(checkbox): + checkbox.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/code_editor.py b/examples/ui/code_editor.py new file mode 100644 index 00000000000..111949e2f91 --- /dev/null +++ b/examples/ui/code_editor.py @@ -0,0 +1,44 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + initial_code = """# implement foo below + def foo(): + ... + """ + + editor = mo.ui.code_editor(value=initial_code, language="python") + editor + return (editor,) + + +@app.cell +def _(editor): + editor.value + return + + +@app.cell +def _(mo): + copy_editor = mo.ui.code_editor( + value="let a = 'b';", + language="javascript", + show_copy_button=False, + ) + copy_editor + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/data_editor.py b/examples/ui/data_editor.py new file mode 100644 index 00000000000..91cc3b6a3af --- /dev/null +++ b/examples/ui/data_editor.py @@ -0,0 +1,65 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "polars==1.39.3", +# "vega-datasets==0.9.0", +# ] +# /// +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(): + import os + + return (os,) + + +@app.cell +def _(): + DATA_FILE = "data.csv" + return (DATA_FILE,) + + +@app.cell +def _(DATA_FILE, mo, os): + import polars as pl + + if not os.path.exists(DATA_FILE): + from vega_datasets import data + + data.cars().to_csv(DATA_FILE) + + editor = mo.ui.data_editor(pl.read_csv(DATA_FILE)).form(bordered=False) + editor + return (editor,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + The following cell writes the updated dataframe to disk when the submit button is clicked. + """) + return + + +@app.cell +def _(DATA_FILE, editor, mo): + mo.stop(editor.value is None, mo.md("Submit your changes.")) + + editor.value.write_csv(DATA_FILE) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/data_explorer.py b/examples/ui/data_explorer.py index 92e44817dea..79bdf3776db 100644 --- a/examples/ui/data_explorer.py +++ b/examples/ui/data_explorer.py @@ -1,122 +1,42 @@ # /// script -# requires-python = ">=3.11" +# requires-python = ">=3.12" # dependencies = [ -# "marimo", -# "pandas==2.2.3", -# "matplotlib==3.9.2", -# "altair==5.4.1", +# "polars==1.17.1", +# "vega-datasets==0.9.0", # ] # /// import marimo -__generated_with = "0.8.19" -app = marimo.App(width="full") - - -@app.cell(hide_code=True) -def __(mo): - mo.md("""# Data Explorer""") - return - - -@app.cell(hide_code=True) -def __(mo): - sample = "https://github.com/vega/vega/blob/main/docs/data/stocks.csv" - - mo.md( - f""" - This notebook lets you upload a CSV and plot its columns. - - You can download a sample CSV if you'd like. - """ - ) - return (sample,) - - -@app.cell(hide_code=True) -def __(mo, uploaded_file): - mo.md( - f""" - {mo.hstack([mo.md("**Upload a CSV.**")], justify="center")} - - {uploaded_file} - """ - ) - return - - -@app.cell -def __(io, mo, pd, uploaded_file): - mo.stop(not uploaded_file.name()) - df = pd.read_csv(io.StringIO(uploaded_file.contents().decode())) - return (df,) - - -@app.cell -def __(df, mo): - mo.ui.table(df, page_size=5, selection=None) - return +__generated_with = "0.19.7" +app = marimo.App(width="medium") @app.cell -def __(df, mo): - plot_type = mo.ui.dropdown( - ["line", "hist"], value="line", label="Choose a plot type: " - ) - - x_column = mo.ui.dropdown(df.columns, label="Choose x-axis: ") - y_column = mo.ui.dropdown(df.columns, label="Choose y-axis: ") - color_column = mo.ui.dropdown(df.columns, label="Choose color-axis: ") - return color_column, plot_type, x_column, y_column - +def _(): + import marimo as mo -@app.cell -def __(color_column, mo, plot_type, x_column, y_column): - mo.hstack( - [x_column, y_column, color_column, plot_type], justify="space-around" - ).callout(kind="warn" if not x_column.value else "neutral") - return + return (mo,) @app.cell -def __(alt, color_column, df, mo, plot_type, x_column, y_column): - mo.stop(not x_column.value) +def _(): + from vega_datasets import data - - def plot(x_column, y_column, color_column): - y_column = y_column or "count()" - title = f"{y_column} by {x_column}" - encoding = {"x": x_column, "y": y_column} - if color_column: - encoding["color"] = color_column - if plot_type.value == "line": - chart = alt.Chart(df).mark_line() - else: - chart = alt.Chart(df).mark_bar().encode(x=alt.X(x_column, bin=True)) - return chart.encode(**encoding).properties(title=title, width="container") - - - plot(x_column.value, y_column.value, color_column.value) - return (plot,) + return (data,) @app.cell -def __(mo): - uploaded_file = mo.ui.file(filetypes=[".csv"], kind="area") - return (uploaded_file,) +def _(data, mo): + explorer = mo.ui.data_explorer(data.iris()) + explorer + return (explorer,) @app.cell -def __(): - import marimo as mo - import altair as alt - - - import io - import matplotlib.pyplot as plt - import pandas as pd - return alt, io, mo, pd, plt +def _(explorer): + explorer.value + return if __name__ == "__main__": diff --git a/examples/ui/dataframe.py b/examples/ui/dataframe.py new file mode 100644 index 00000000000..c8c978f1572 --- /dev/null +++ b/examples/ui/dataframe.py @@ -0,0 +1,60 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "marimo", +# "vega-datasets==0.9.0", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(): + from vega_datasets import data + + return (data,) + + +@app.cell(hide_code=True) +def _(mo): + lazy_button = mo.ui.checkbox(label="Lazy Dataframe") + lazy_button + return (lazy_button,) + + +@app.cell +def _(data, lazy_button, mo): + def format_length(value: float) -> str: + return f"{value:.1f} cm" + + dataframe_transformer = mo.ui.dataframe( + data.iris(), + lazy=lazy_button.value, + format_mapping={ + "sepal_length": format_length, + "sepal_width": "{:.1f}".format, + }, + ) + dataframe_transformer + return (dataframe_transformer,) + + +@app.cell +def _(dataframe_transformer): + dataframe_transformer.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/date.py b/examples/ui/date.py new file mode 100644 index 00000000000..857ed738e2b --- /dev/null +++ b/examples/ui/date.py @@ -0,0 +1,28 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + date = mo.ui.date() + date + return (date,) + + +@app.cell +def _(date): + date.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/date_range.py b/examples/ui/date_range.py new file mode 100644 index 00000000000..789ec0ad919 --- /dev/null +++ b/examples/ui/date_range.py @@ -0,0 +1,32 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + import datetime as dt + + date_range = mo.ui.date_range( + start=dt.date(2023, 1, 1), stop=dt.date(2023, 12, 31) + ) + date_range + return (date_range,) + + +@app.cell +def _(date_range): + date_range.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/datetime_input.py b/examples/ui/datetime_input.py new file mode 100644 index 00000000000..8c011b9faab --- /dev/null +++ b/examples/ui/datetime_input.py @@ -0,0 +1,28 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + datetime = mo.ui.datetime(label="datetime") + datetime + return (datetime,) + + +@app.cell +def _(datetime): + datetime.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/dictionary.py b/examples/ui/dictionary.py new file mode 100644 index 00000000000..68e3305ac4d --- /dev/null +++ b/examples/ui/dictionary.py @@ -0,0 +1,28 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + dictionary = mo.ui.dictionary({"name": mo.ui.text(), "age": mo.ui.slider(1, 100)}) + dictionary + return (dictionary,) + + +@app.cell +def _(dictionary): + dictionary.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/download.py b/examples/ui/download.py new file mode 100644 index 00000000000..9f98d166b23 --- /dev/null +++ b/examples/ui/download.py @@ -0,0 +1,119 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "marimo", +# "pandas==2.2.3", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell(hide_code=True) +def _(json, mo, pd): + # Text file download + text_download = mo.download( + data="Hello, world!".encode("utf-8"), + filename="hello.txt", + mimetype="text/plain", + label="Download text", + ) + + # CSV download using pandas + df = pd.DataFrame({"name": ["Alice", "Bob", "Charlie"], "age": [25, 30, 35]}) + csv_download = mo.download( + data=df.to_csv().encode("utf-8"), + filename="data.csv", + mimetype="text/csv", + label="Download CSV", + ) + + # JSON download + data = {"message": "Hello", "count": 42} + json_download = mo.download( + data=json.dumps(data).encode("utf-8"), + filename="data.json", + mimetype="application/json", + label="Download JSON", + ) + + mo.hstack([text_download, csv_download, json_download]) + return + + +@app.cell(hide_code=True) +def _(json, mo, pd): + import time + import asyncio + + + # Text file download with lazy loading + def get_text_data(): + time.sleep(1) + return "Hello, world!".encode("utf-8") + + + text_download_lazy = mo.download( + data=get_text_data, + filename="hello.txt", + mimetype="text/plain", + label="Download text", + ) + + + # CSV download using pandas with lazy loading + async def get_csv_data(): + await asyncio.sleep(1) + _df = pd.DataFrame( + {"name": ["Alice", "Bob", "Charlie"], "age": [25, 30, 35]} + ) + return _df + + + csv_download_lazy = mo.download( + data=get_csv_data, + filename="data.csv", + mimetype="text/csv", + label="Download CSV", + ) + + + # JSON download with lazy loading + async def get_json_data(): + await asyncio.sleep(1) + _data = {"message": "Hello", "count": 42} + return json.dumps(_data).encode("utf-8") + + + json_download_lazy = mo.download( + data=get_json_data, + filename="data.json", + mimetype="application/json", + label="Download JSON", + ) + + + mo.hstack([text_download_lazy, csv_download_lazy, json_download_lazy]) + return + + +@app.cell +def _(): + import pandas as pd + import json + + return json, pd + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/dropdown.py b/examples/ui/dropdown.py new file mode 100644 index 00000000000..f22739b2070 --- /dev/null +++ b/examples/ui/dropdown.py @@ -0,0 +1,28 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + dropdown = mo.ui.dropdown(["Option A", "Option B", "Option C"]) + dropdown + return (dropdown,) + + +@app.cell +def _(dropdown): + dropdown.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/file.py b/examples/ui/file.py new file mode 100644 index 00000000000..51c91138258 --- /dev/null +++ b/examples/ui/file.py @@ -0,0 +1,28 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + file = mo.ui.file() + file + return (file,) + + +@app.cell +def _(file): + file.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/file_browser.py b/examples/ui/file_browser.py new file mode 100644 index 00000000000..5c1cc3bf580 --- /dev/null +++ b/examples/ui/file_browser.py @@ -0,0 +1,41 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + file_browser = mo.ui.file_browser() + file_browser + return (file_browser,) + + +@app.cell +def _(file_browser): + file_browser.value + return + + +@app.cell +def _(mo): + file_browser_all = mo.ui.file_browser(selection_mode="all") + file_browser_all + return (file_browser_all,) + + +@app.cell +def _(file_browser_all): + file_browser_all.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/form.py b/examples/ui/form.py new file mode 100644 index 00000000000..208f275b988 --- /dev/null +++ b/examples/ui/form.py @@ -0,0 +1,44 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + # Create a form with multiple elements + form = ( + mo.md( + """ + **Your form.** + + {name} + + {date} + """ + ) + .batch( + name=mo.ui.text(label="name"), + date=mo.ui.date(label="date"), + ) + .form(show_clear_button=True, bordered=False) + ) + form + return (form,) + + +@app.cell +def _(form): + form.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/image_comparison_demo.py b/examples/ui/image_comparison_demo.py new file mode 100644 index 00000000000..2b2a8178bd8 --- /dev/null +++ b/examples/ui/image_comparison_demo.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "marimo", +# ] +# /// +# Copyright 2026 Marimo. All rights reserved. + +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + # Image Comparison Demo + + This demo showcases the `mo.image_compare` feature, which allows for side-by-side comparison of images. + + ## Basic Usage - Horizontal Comparison + + The default orientation is horizontal, where you can slide left and right to compare images: + """) + return + + +@app.cell +def _(): + before_image_path = "https://picsum.photos/200/301.jpg" + after_image_path = "https://picsum.photos/200/300.jpg" + return after_image_path, before_image_path + + +@app.cell +def _(after_image_path, before_image_path, mo): + # Basic horizontal comparison with default settings + mo.image_compare(before_image=before_image_path, after_image=after_image_path) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ## Custom Initial Position + + You can set the initial position of the slider: + """) + return + + +@app.cell +def _(after_image_path, before_image_path, mo): + mo.image_compare( + before_image=before_image_path, + after_image=after_image_path, + direction="horizontal", + value=20, # Start at 25% position + ) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ## Vertical Comparison + + You can also use a vertical comparison slider: + """) + return + + +@app.cell +def _(after_image_path, before_image_path, mo): + mo.image_compare( + before_image=before_image_path, + after_image=after_image_path, + direction="vertical", + value=75, # Start at 75% position + height=400, + ) + return + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/inputs.py b/examples/ui/inputs.py deleted file mode 100644 index dd04f074c70..00000000000 --- a/examples/ui/inputs.py +++ /dev/null @@ -1,221 +0,0 @@ -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "marimo", -# ] -# /// - -import marimo - -__generated_with = "0.8.19" -app = marimo.App(width="medium") - - -@app.cell(hide_code=True) -def __(mo): - mo.md( - r""" - # Inputs - - There are many way that a user can input with your notebook, such as text boxes, sliders, dates, and more. - """ - ) - return - - -@app.cell -def __(mo): - mo.md(r"""## Text boxes""") - return - - -@app.cell -def __(mo): - mo.hstack( - [ - username := mo.ui.text(label="Username"), - email := mo.ui.text(label="Email", kind="email"), - mo.ui.text(label="Password", kind="password"), - ] - ) - return email, username - - -@app.cell -def __(mo, username): - mo.stop(not username.value, mo.md("What is your name?")) - - mo.md(f"👋 Hello {username.value}, nice to meet you!") - return - - -@app.cell -def __(mo): - mo.ui.text_area( - label="A space for your thoughts", full_width=True, max_length=1000 - ) - return - - -@app.cell -def __(mo): - mo.ui.number(label="What is your favorite number?", start=0, stop=10) - return - - -@app.cell -def __(mo): - mo.md(r"""## Sliders""") - return - - -@app.cell -def __(mo): - slider = mo.ui.slider(0, 100, value=50, label="Basic slider", show_value=True) - range_slider = mo.ui.range_slider( - 0, 100, value=(30, 70), label="Range slider", show_value=True - ) - custom_steps = mo.ui.slider( - steps=[1, 10, 100, 1000], value=10, label="Custom steps", show_value=True - ) - vertical = mo.ui.slider( - 0, 100, value=50, label="Vertical slider", orientation="vertical" - ) - mo.vstack([slider, range_slider, custom_steps, vertical]).center() - return custom_steps, range_slider, slider, vertical - - -@app.cell -def __(mo): - mo.md(r"""## Checkboxes and Radios""") - return - - -@app.cell -def __(mo): - COLORS = ["red", "green", "blue"] - colors = mo.ui.array( - [mo.ui.checkbox(label=color) for color in COLORS], - ) - - shape = mo.ui.radio( - ["circle", "square", "triangle"], inline=True, value="square" - ) - mo.md(f""" - Let's build something: - - **Pick a shape:** - - {shape} - - **Pick a color:** - - {colors.hstack().left()} - """).center() - return COLORS, colors, shape - - -@app.cell(hide_code=True) -def __(COLORS, colors, mo, shape): - selected_colors = [color for i, color in enumerate(COLORS) if colors.value[i]] - - - def draw_shape(shape, colors): - if not colors: - return "" - - gradient = "" - if isinstance(colors, list) and len(colors) > 1: - gradient_id = f"grad{hash(tuple(colors)) % 1000}" - stops = "".join( - [ - f'' - for i, color in enumerate(colors) - ] - ) - gradient = f'{stops}' - fill_color = f"url(#{gradient_id})" - else: - fill_color = colors if isinstance(colors, str) else colors[0] - - if shape == "circle": - html = f'{gradient}' - elif shape == "square": - html = f'{gradient}' - elif shape == "triangle": - html = f'{gradient}' - else: - html = "Shape not recognized" - return mo.Html(html) - - - mo.md(f""" - A {"/".join(selected_colors)} {shape.value}: - {draw_shape(shape.value, selected_colors)} - """).center() - return draw_shape, selected_colors - - -@app.cell -def __(mo): - mo.md("""## Dates""") - return - - -@app.cell -def __(mo): - import datetime - - start_date = mo.ui.date( - label="Start date", - start=datetime.date(2020, 1, 1), - stop=datetime.date(2020, 12, 31), - ) - end_date = mo.ui.date( - label="End date", - start=datetime.date(2020, 1, 1), - stop=datetime.date(2020, 12, 31), - ) - return datetime, end_date, start_date - - -@app.cell -def __(end_date, mo, start_date): - mo.hstack( - [ - mo.hstack([start_date, "➡️", end_date]).left(), - mo.md(f"From {start_date.value} to {end_date.value}"), - ] - ) - return - - -@app.cell(hide_code=True) -def __(mo): - mo.md("""## Dropdowns""") - return - - -@app.cell -def __(mo): - single = mo.ui.dropdown( - ["Option 1", "Option 2", "Option 3", "Option 4", "Option 5"], - label="Single select", - ) - multi = mo.ui.multiselect( - ["Option 1", "Option 2", "Option 3", "Option 4", "Option 5"], - label="Multi select", - value=["Option 1", "Option 2"], - ) - mo.hstack([single, multi]) - return multi, single - - -@app.cell -def __(): - import marimo as mo - return (mo,) - - -if __name__ == "__main__": - app.run() diff --git a/examples/ui/layout.py b/examples/ui/layout.py index e968e0ebaeb..393d01ae4ba 100644 --- a/examples/ui/layout.py +++ b/examples/ui/layout.py @@ -7,24 +7,28 @@ import marimo -__generated_with = "0.8.19" +__generated_with = "0.23.2" app = marimo.App() @app.cell(hide_code=True) -def __(mo): - mo.md("""# Stacks""") +def _(mo): + mo.md(""" + # Stacks + """) return @app.cell(hide_code=True) -def __(mo): - mo.md("""Use `mo.hstack` and `mo.vstack` to layout outputs in rows and columns.""") +def _(mo): + mo.md(""" + Use `mo.hstack` and `mo.vstack` to layout outputs in rows and columns. + """) return @app.cell -def __(mo): +def _(mo): align = mo.ui.dropdown( label="Align", options=["start", "end", "center", "stretch"] ) @@ -49,13 +53,15 @@ def __(mo): @app.cell -def __(mo): - mo.md("""## Horizontal Stack: `hstack`""") +def _(mo): + mo.md(""" + ## Horizontal Stack: `hstack` + """) return @app.cell -def __(align, boxes, gap, justify, mo, wrap): +def _(align, boxes, gap, justify, mo, wrap): mo.hstack( boxes, align=align.value, @@ -67,13 +73,15 @@ def __(align, boxes, gap, justify, mo, wrap): @app.cell -def __(mo): - mo.md("""## Vertical Stack: `vstack`""") +def _(mo): + mo.md(""" + ## Vertical Stack: `vstack` + """) return @app.cell -def __(align, boxes, gap, mo): +def _(align, boxes, gap, mo): mo.vstack( boxes, align=align.value, @@ -83,7 +91,7 @@ def __(align, boxes, gap, mo): @app.cell -def __(mo, size): +def _(mo, size): def create_box(num): box_size = size.value + num * 10 return mo.Html( @@ -92,12 +100,13 @@ def create_box(num): boxes = [create_box(i) for i in range(1, 5)] - return boxes, create_box + return (boxes,) @app.cell -def __(): +def _(): import marimo as mo + return (mo,) diff --git a/examples/ui/matrix.py b/examples/ui/matrix.py new file mode 100644 index 00000000000..59f68f65676 --- /dev/null +++ b/examples/ui/matrix.py @@ -0,0 +1,47 @@ +import marimo + +__generated_with = "0.19.11" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + import numpy as np + + return mo, np + + +@app.cell +def _(mo): + matrix = mo.ui.matrix( + [[1, 0, 0], [0, 1, 0], [0, 0, 1]], + min_value=-5, + max_value=10, + step=0.001, + precision=3, + scientific=True, + label="$I$", + ) + matrix + return (matrix,) + + +@app.cell +def _(matrix): + matrix.value + return + + +@app.cell +def _(mo, np): + mo.hstack( + [mo.ui.matrix(np.ones(3)), mo.ui.matrix(np.ones((1, 3)))], + justify="start", + gap=2, + ) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/mermaid.py b/examples/ui/mermaid.py deleted file mode 100644 index a9cddc57889..00000000000 --- a/examples/ui/mermaid.py +++ /dev/null @@ -1,66 +0,0 @@ -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "marimo", -# ] -# /// - -import marimo - -__generated_with = "0.8.19" -app = marimo.App() - - -@app.cell -def __(): - import marimo as mo - return (mo,) - - -@app.cell -def __(mo): - mo.mermaid( - """ - graph TD - A[Enter Chart Definition] --> B(Preview) - B --> C{decide} - C --> D[Keep] - C --> E[Edit Definition] - E --> B - D --> F[Save Image and Code] - F --> B - """ - ).center() - return - - -@app.cell -def __(mo): - graph = mo.ui.code_editor( - value="""sequenceDiagram - Alice->>John: Hello John, how are you? - John-->>Alice: Great! - Alice-)John: See you later!""", - language="mermaid", - label="Mermaid editor", - ) - graph - return (graph,) - - -@app.cell -def __(graph, mo): - mo.md( - f""" - You can render mermaid directly inside `mo.md`. Using - - `mo.mermaid()` - - {mo.mermaid(graph.value)} - """ - ) - return - - -if __name__ == "__main__": - app.run() diff --git a/examples/ui/microphone.py b/examples/ui/microphone.py new file mode 100644 index 00000000000..ae80da5febf --- /dev/null +++ b/examples/ui/microphone.py @@ -0,0 +1,30 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + microphone = mo.ui.microphone(label="Drop a beat!") + microphone + return (microphone,) + + +@app.cell +def _(microphone, mo): + mo.stop(not microphone.value.getvalue(), "Record something with the microphone! 👆") + + mo.audio(microphone.value) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/multiselect.py b/examples/ui/multiselect.py new file mode 100644 index 00000000000..c46fb75179b --- /dev/null +++ b/examples/ui/multiselect.py @@ -0,0 +1,28 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + multiselect = mo.ui.multiselect(options=["Apples", "Oranges", "Pears"]) + multiselect + return (multiselect,) + + +@app.cell +def _(multiselect): + multiselect.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/number.py b/examples/ui/number.py new file mode 100644 index 00000000000..95d62ede692 --- /dev/null +++ b/examples/ui/number.py @@ -0,0 +1,28 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + number = mo.ui.number(start=1, stop=10) + number + return (number,) + + +@app.cell +def _(number): + number.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/radio.py b/examples/ui/radio.py new file mode 100644 index 00000000000..530cdd640c4 --- /dev/null +++ b/examples/ui/radio.py @@ -0,0 +1,28 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + radio = mo.ui.radio(["A", "B", "C"]) + radio + return (radio,) + + +@app.cell +def _(radio): + radio.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/range_slider.py b/examples/ui/range_slider.py new file mode 100644 index 00000000000..090df27fdf9 --- /dev/null +++ b/examples/ui/range_slider.py @@ -0,0 +1,28 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + range_slider = mo.ui.range_slider(start=1, stop=10) + range_slider + return (range_slider,) + + +@app.cell +def _(range_slider): + range_slider.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/refresh.py b/examples/ui/refresh.py index 124b8d6d7db..eb8b08848ad 100644 --- a/examples/ui/refresh.py +++ b/examples/ui/refresh.py @@ -1,135 +1,27 @@ -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "pandas==2.2.3", -# "vega-datasets==0.9.0", -# "requests==2.32.3", -# "altair==5.4.1", -# "marimo", -# ] -# /// import marimo -__generated_with = "0.1.68" -app = marimo.App(width="full") +__generated_with = "0.19.7" +app = marimo.App() @app.cell -def __( - chart, - iss_df, - mo, - n_points_slider, - refresh_interval_slider, - refresher, -): - mo.hstack([ - mo.vstack([ - mo.md("## Settings | [`marimo.ui.slider`](https://docs.marimo.io/api/inputs/slider.html), [`marimo.ui.refresh`](https://docs.marimo.io/recipes.html#run-a-cell-on-a-timer)\n---"), - refresh_interval_slider, - n_points_slider, - refresher, - mo.md("## ISS Positions | [`marimo.ui.altair_chart`](https://docs.marimo.io/api/plotting.html#marimo.ui.altair_chart)\n---"), - mo.as_html(chart).style({"width": "700px"}) - ], align="center"), - mo.vstack([ - mo.md("## Data | [`marimo.as_html`](https://docs.marimo.io/api/html.html)`(pd.DataFrame)`\n---"), - mo.as_html(iss_df) - ]) - ], justify="center", wrap=True, gap=3) - return - - -@app.cell -def __(alt, get_iss_positions, sphere, world): - hover=alt.selection_point(on="mouseover", clear="mouseout") - - # iss positions - iss_df = get_iss_positions() - iss = alt.Chart(iss_df[['longitude','latitude','timestamp']]).mark_circle( - stroke='black', size=100, - ).encode( - longitude=alt.Longitude('longitude:Q'), - latitude='latitude:Q', - fill=alt.Fill('timestamp:Q', scale=alt.Scale(scheme='purples'), legend=None), - strokeWidth=alt.condition(hover, alt.value(3, empty=False), alt.value(0)), - tooltip=[ - alt.Tooltip('longitude:Q', title='Longitude', format='.4f'), - alt.Tooltip('latitude:Q', title='Latitude', format='.4f'), - alt.Tooltip('timestamp:T', title='Timestamp', format='%Y-%m-%d %H:%M:%S') - ] - ).add_params(hover) - - chart = alt.layer(sphere, world, iss).project(type="naturalEarth1").properties(width=640, title="") - return chart, hover, iss, iss_df - - -@app.cell -def __(alt, data): - # load geo data from Vega Datasets - countries = alt.topo_feature(data.world_110m.url, 'countries') - - # world base - sphere = alt.Chart(alt.sphere()).mark_geoshape( - fill="aliceblue", stroke="black", strokeWidth=1.5 - ) - - # world map - world = alt.Chart(countries).mark_geoshape( - fill="mintcream", stroke="black", strokeWidth=0.35 - ) - return countries, sphere, world - - -@app.cell -def __( - n_points_slider, - pd, - refresh_interval_slider, - refresher, - requests, - time, -): - def get_iss_positions(refresher=refresher): - refresher - timepoints = [int(time())] - while len(timepoints) <= n_points_slider.value: - timepoints.append(timepoints[-1] - refresh_interval_slider.value) - else: - timepoints.pop(0) - timepoints_str = str(timepoints)[1:-1].replace(" ", "") - iss_url = f"https://api.wheretheiss.at/v1/satellites/25544/positions?timestamps={timepoints_str}" - response = requests.get(iss_url) - df = pd.DataFrame(response.json()) - df['timestamp'] = pd.to_datetime(df.timestamp, unit='s') - return df[['timestamp','latitude','longitude','altitude','velocity','visibility']] - return get_iss_positions, - +def _(): + import marimo as mo -@app.cell -def __(mo, refresh_interval_slider): - refresher = mo.ui.refresh(default_interval=f"{refresh_interval_slider.value}s") - return refresher, + return (mo,) @app.cell -def __(mo): - refresh_interval_slider = mo.ui.slider(start=5, stop=60, step=1, value=10, label="refresh interval (default = 10 sec)") - n_points_slider = mo.ui.slider(start=5, stop=30, step=1, value=15, label="number of points (default = 15)") - return n_points_slider, refresh_interval_slider +def _(mo): + refresh = mo.ui.refresh(default_interval=1) + refresh + return (refresh,) @app.cell -def __(): - import altair as alt - import marimo as mo - import pandas as pd - import requests - from time import time - from vega_datasets import data - - pd.options.display.max_rows = 30 - return alt, data, mo, pd, requests, time +def _(refresh): + print(refresh.value) + return if __name__ == "__main__": diff --git a/examples/ui/run_button.py b/examples/ui/run_button.py new file mode 100644 index 00000000000..8851802b180 --- /dev/null +++ b/examples/ui/run_button.py @@ -0,0 +1,34 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + first_button = mo.ui.run_button(label="Option 1") + second_button = mo.ui.run_button(label="Option 2") + first_button, second_button + return first_button, second_button + + +@app.cell +def _(first_button, second_button): + if first_button.value: + print("You chose option 1!") + elif second_button.value: + print("You chose option 2!") + else: + print("Click a button!") + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/slider.py b/examples/ui/slider.py new file mode 100644 index 00000000000..38c7338a135 --- /dev/null +++ b/examples/ui/slider.py @@ -0,0 +1,28 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + slider = mo.ui.slider(start=1, stop=10) + slider + return (slider,) + + +@app.cell +def _(slider): + slider.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/switch.py b/examples/ui/switch.py new file mode 100644 index 00000000000..25eb9594f9d --- /dev/null +++ b/examples/ui/switch.py @@ -0,0 +1,28 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + switch = mo.ui.switch() + switch + return (switch,) + + +@app.cell +def _(switch): + switch.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/table.py b/examples/ui/table.py index 191b44311f3..5553310c7cd 100644 --- a/examples/ui/table.py +++ b/examples/ui/table.py @@ -1,228 +1,69 @@ -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "marimo", -# ] -# /// - import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App() -@app.cell(hide_code=True) -def __(mo): - mo.md( - """ - # Tables - - > “Sometimes I’ll start a sentence and I don’t even know where it’s going. I just hope I find it along the way.” - — Michael Scott - """ - ) - return - - -@app.cell(hide_code=True) -def __(mo): - mo.md("""_Create rich tables with selectable rows using_ `mo.ui.table`.""") - return - - -@app.cell -def __(mo): - mo.md("""**Single selection.**""") - return - - @app.cell -def __(mo, office_characters): - single_select_table = mo.ui.table( - office_characters, - selection="single", - pagination=True, - ) - return (single_select_table,) - - -@app.cell -def __(mo, single_select_table): - mo.ui.tabs({"table": single_select_table, "selection": single_select_table.value}) - return - +def _(): + import marimo as mo -@app.cell -def __(mo): - mo.md("""**Multi-selection.**""") - return + return (mo,) @app.cell -def __(mo, office_characters): - multi_select_table = mo.ui.table( - office_characters, - selection="multi", - pagination=True, +def _(mo): + # ui.table accepts a list of rows as dicts, or a dict mapping column names to values, + # or a dataframe-like object + table = mo.ui.table( + [ + {"first_name": "Michael", "last_name": "Scott"}, + {"first_name": "Jim", "last_name": "Halpert"}, + {"first_name": "Pam", "last_name": "Beesly"}, + ], + # Show full name on hover for each row using column placeholders + hover_template="{{first_name}} {{last_name}}", + # Add header tooltip for column headers (shown via info icon + title) + header_tooltip={ + "first_name": "Employee's first name", + "last_name": "Employee's last name", + }, ) - return (multi_select_table,) + table + return (table,) @app.cell -def __(mo, multi_select_table): - mo.ui.tabs({"table": multi_select_table, "selection": multi_select_table.value}) +def _(mo): + # Per-cell hover: provide a callable for finer control + def cell_hover(row_id: str, column_name: str, value) -> str: + return f"{row_id}:{column_name}={value}" + + hover_table = mo.ui.table( + [{"a": i, "b": i * i} for i in range(8)], + hover_template=cell_hover, + ) + hover_table return @app.cell -def __(mo): - mo.md("""**No selection.**""") +def _(table): + table.value return @app.cell -def __(mo, office_characters): - table = mo.ui.table( - office_characters, - label="Employees", - selection=None, +def _(mo): + # Demonstrate a long table with a sticky header and a custom max height + long_rows = [{"row": i, "first_name": f"First {i}", "last_name": f"Last {i}"} for i in range(200)] + long_table = mo.ui.table( + long_rows, + pagination=False, + max_height=300, ) - - table - return (table,) - - -@app.cell -def __(mo): - office_characters = [ - { - "first_name": "Michael", - "last_name": "Scott", - "skill": mo.ui.slider(1, 10, value=3), - "favorite place": mo.image(src="https://picsum.photos/100", rounded=True), - }, - { - "first_name": "Jim", - "last_name": "Halpert", - "skill": mo.ui.slider(1, 10, value=7), - "favorite place": mo.image(src="https://picsum.photos/100"), - }, - { - "first_name": "Pam", - "last_name": "Beesly", - "skill": mo.ui.slider(1, 10, value=3), - "favorite place": mo.image(src="https://picsum.photos/100"), - }, - { - "first_name": "Dwight", - "last_name": "Schrute", - "skill": mo.ui.slider(1, 10, value=7), - "favorite place": mo.image(src="https://picsum.photos/100"), - }, - { - "first_name": "Angela", - "last_name": "Martin", - "skill": mo.ui.slider(1, 10, value=5), - "favorite place": mo.image(src="https://picsum.photos/100"), - }, - { - "first_name": "Kevin", - "last_name": "Malone", - "skill": mo.ui.slider(1, 10, value=3), - "favorite place": mo.image(src="https://picsum.photos/100"), - }, - { - "first_name": "Oscar", - "last_name": "Martinez", - "skill": mo.ui.slider(1, 10, value=3), - "favorite place": mo.image(src="https://picsum.photos/100"), - }, - { - "first_name": "Stanley", - "last_name": "Hudson", - "skill": mo.ui.slider(1, 10, value=5), - "favorite place": mo.image(src="https://picsum.photos/100"), - }, - { - "first_name": "Phyllis", - "last_name": "Vance", - "skill": mo.ui.slider(1, 10, value=5), - "favorite place": mo.image(src="https://picsum.photos/100"), - }, - { - "first_name": "Meredith", - "last_name": "Palmer", - "skill": mo.ui.slider(1, 10, value=7), - "favorite place": mo.image(src="https://picsum.photos/100"), - }, - { - "first_name": "Creed", - "last_name": "Bratton", - "skill": mo.ui.slider(1, 10, value=3), - "favorite place": mo.image(src="https://picsum.photos/100"), - }, - { - "first_name": "Ryan", - "last_name": "Howard", - "skill": mo.ui.slider(1, 10, value=5), - "favorite place": mo.image(src="https://picsum.photos/100"), - }, - { - "first_name": "Kelly", - "last_name": "Kapoor", - "skill": mo.ui.slider(1, 10, value=3), - "favorite place": mo.image(src="https://picsum.photos/100"), - }, - { - "first_name": "Toby", - "last_name": "Flenderson", - "skill": mo.ui.slider(1, 10, value=3), - "favorite place": mo.image(src="https://picsum.photos/100"), - }, - { - "first_name": "Darryl", - "last_name": "Philbin", - "skill": mo.ui.slider(1, 10, value=7), - "favorite place": mo.image(src="https://picsum.photos/100"), - }, - { - "first_name": "Erin", - "last_name": "Hannon", - "skill": mo.ui.slider(1, 10, value=5), - "favorite place": mo.image(src="https://picsum.photos/100"), - }, - { - "first_name": "Andy", - "last_name": "Bernard", - "skill": mo.ui.slider(1, 10, value=5), - "favorite place": mo.image(src="https://picsum.photos/100"), - }, - { - "first_name": "Jan", - "last_name": "Levinson", - "skill": mo.ui.slider(1, 10, value=5), - "favorite place": mo.image(src="https://picsum.photos/100"), - }, - { - "first_name": "David", - "last_name": "Wallace", - "skill": mo.ui.slider(1, 10, value=3), - "favorite place": mo.image(src="https://picsum.photos/100"), - }, - { - "first_name": "Holly", - "last_name": "Flax", - "skill": mo.ui.slider(1, 10, value=7), - "favorite place": mo.image(src="https://picsum.photos/100"), - }, - ] - return (office_characters,) - - -@app.cell -def __(): - import marimo as mo - return (mo,) + long_table + return if __name__ == "__main__": diff --git a/examples/ui/table_advanced.py b/examples/ui/table_advanced.py new file mode 100644 index 00000000000..0d8e76f5dd7 --- /dev/null +++ b/examples/ui/table_advanced.py @@ -0,0 +1,285 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "marimo", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + # Tables + + > “Sometimes I’ll start a sentence and I don’t even know where it’s going. I just hope I find it along the way.” + — Michael Scott + """) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + _Create rich tables with selectable rows using_ `mo.ui.table`. + """) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + **Single selection.** + """) + return + + +@app.cell +def _(mo, office_characters): + single_select_table = mo.ui.table( + office_characters, + selection="single", + pagination=True, + ) + return (single_select_table,) + + +@app.cell +def _(mo, single_select_table): + mo.ui.tabs({"table": single_select_table, "selection": single_select_table.value}) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + **Multi-selection.** + """) + return + + +@app.cell +def _(mo, office_characters): + multi_select_table = mo.ui.table( + office_characters, + selection="multi", + pagination=True, + ) + return (multi_select_table,) + + +@app.cell +def _(mo, multi_select_table): + mo.ui.tabs({"table": multi_select_table, "selection": multi_select_table.value}) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + **No selection.** + """) + return + + +@app.cell +def _(mo, office_characters): + table = mo.ui.table( + office_characters, + label="Employees", + selection=None, + ) + + table + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + **Select individual cells** + """) + return + + +@app.cell +def _(mo): + data = {str(col): [col * 10 + row for row in range(10)] for col in range(10)} + mo.ui.table(data, selection="multi-cell", initial_selection=[("3", "5"),("9","8")]) + return + + +@app.cell +def _(mo): + mo.md(""" + **Style individual cells** + """) + return + + +@app.cell +def _(mo): + def apply_styling(row_id, column_name, value): + row = int(row_id) + column = int(column_name) + r = row / 8 * 12 + g = column / 2 * 32 + b = (row + column) / 10 * 16 + return { + "backgroundColor": f"rgb({r % 256}, {g % 256}, {b % 256})", + "color": "white" + if (r * 0.299 + g * 0.587 + b * 0.114) < 186 + else "black", + } + + + colors = { + str(col): [row * 20 + col for row in range(320)] for col in range(10) + } + color_table = mo.ui.table( + data=colors, pagination=True, page_size=16, style_cell=apply_styling + ) + color_table + return + + +@app.cell +def _(mo): + office_characters = [ + { + "first_name": "Michael", + "last_name": "Scott", + "skill": mo.ui.slider(1, 10, value=3), + "favorite place": mo.image(src="https://picsum.photos/100", rounded=True), + }, + { + "first_name": "Jim", + "last_name": "Halpert", + "skill": mo.ui.slider(1, 10, value=7), + "favorite place": mo.image(src="https://picsum.photos/100"), + }, + { + "first_name": "Pam", + "last_name": "Beesly", + "skill": mo.ui.slider(1, 10, value=3), + "favorite place": mo.image(src="https://picsum.photos/100"), + }, + { + "first_name": "Dwight", + "last_name": "Schrute", + "skill": mo.ui.slider(1, 10, value=7), + "favorite place": mo.image(src="https://picsum.photos/100"), + }, + { + "first_name": "Angela", + "last_name": "Martin", + "skill": mo.ui.slider(1, 10, value=5), + "favorite place": mo.image(src="https://picsum.photos/100"), + }, + { + "first_name": "Kevin", + "last_name": "Malone", + "skill": mo.ui.slider(1, 10, value=3), + "favorite place": mo.image(src="https://picsum.photos/100"), + }, + { + "first_name": "Oscar", + "last_name": "Martinez", + "skill": mo.ui.slider(1, 10, value=3), + "favorite place": mo.image(src="https://picsum.photos/100"), + }, + { + "first_name": "Stanley", + "last_name": "Hudson", + "skill": mo.ui.slider(1, 10, value=5), + "favorite place": mo.image(src="https://picsum.photos/100"), + }, + { + "first_name": "Phyllis", + "last_name": "Vance", + "skill": mo.ui.slider(1, 10, value=5), + "favorite place": mo.image(src="https://picsum.photos/100"), + }, + { + "first_name": "Meredith", + "last_name": "Palmer", + "skill": mo.ui.slider(1, 10, value=7), + "favorite place": mo.image(src="https://picsum.photos/100"), + }, + { + "first_name": "Creed", + "last_name": "Bratton", + "skill": mo.ui.slider(1, 10, value=3), + "favorite place": mo.image(src="https://picsum.photos/100"), + }, + { + "first_name": "Ryan", + "last_name": "Howard", + "skill": mo.ui.slider(1, 10, value=5), + "favorite place": mo.image(src="https://picsum.photos/100"), + }, + { + "first_name": "Kelly", + "last_name": "Kapoor", + "skill": mo.ui.slider(1, 10, value=3), + "favorite place": mo.image(src="https://picsum.photos/100"), + }, + { + "first_name": "Toby", + "last_name": "Flenderson", + "skill": mo.ui.slider(1, 10, value=3), + "favorite place": mo.image(src="https://picsum.photos/100"), + }, + { + "first_name": "Darryl", + "last_name": "Philbin", + "skill": mo.ui.slider(1, 10, value=7), + "favorite place": mo.image(src="https://picsum.photos/100"), + }, + { + "first_name": "Erin", + "last_name": "Hannon", + "skill": mo.ui.slider(1, 10, value=5), + "favorite place": mo.image(src="https://picsum.photos/100"), + }, + { + "first_name": "Andy", + "last_name": "Bernard", + "skill": mo.ui.slider(1, 10, value=5), + "favorite place": mo.image(src="https://picsum.photos/100"), + }, + { + "first_name": "Jan", + "last_name": "Levinson", + "skill": mo.ui.slider(1, 10, value=5), + "favorite place": mo.image(src="https://picsum.photos/100"), + }, + { + "first_name": "David", + "last_name": "Wallace", + "skill": mo.ui.slider(1, 10, value=3), + "favorite place": mo.image(src="https://picsum.photos/100"), + }, + { + "first_name": "Holly", + "last_name": "Flax", + "skill": mo.ui.slider(1, 10, value=7), + "favorite place": mo.image(src="https://picsum.photos/100"), + }, + ] + return (office_characters,) + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/tabs.py b/examples/ui/tabs.py index 003aa87e584..9b4cd5ddfd0 100644 --- a/examples/ui/tabs.py +++ b/examples/ui/tabs.py @@ -1,74 +1,30 @@ -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "marimo", -# ] -# /// - import marimo -__generated_with = "0.8.19" +__generated_with = "0.19.7" app = marimo.App() -@app.cell(hide_code=True) -def __(mo): - mo.md("""# Tabs""") - return - - -@app.cell(hide_code=True) -def __(mo): - mo.md("""Use `mo.ui.tabs` to organize outputs.""") - return - - @app.cell -def __(mo): - settings = mo.vstack( - [ - mo.md("Edit User"), - first := mo.ui.text(label="First Name"), - last := mo.ui.text(label="Last Name"), - ] - ) - - organization = mo.vstack( - [ - mo.md("Edit Organization"), - org := mo.ui.text(label="Organization Name", value="..."), - employees := mo.ui.number( - label="Number of Employees", start=0, stop=1000 - ), - ] - ) +def _(): + import marimo as mo - mo.ui.tabs( - { - "🧙‍♀ User": settings, - "🏢 Organization": organization, - } - ) - return employees, first, last, org, organization, settings + return (mo,) @app.cell -def __(employees, first, last, mo, org): - mo.md( - f""" - Welcome **{first.value} {last.value}** to **{org.value}**! You are - employee no. **{employees.value + 1}**. - - #{"🎉" * (min(employees.value + 1, 1000))} - """ - ) if all([first.value, last.value, org.value]) else None - return +def _(mo): + tabs = mo.ui.tabs({ + "Bob says": mo.md("Hello, Alice! 👋"), + "Alice says": mo.md("Hello, Bob! 👋") + }) + tabs + return (tabs,) @app.cell -def __(): - import marimo as mo - return (mo,) +def _(tabs): + tabs.value + return if __name__ == "__main__": diff --git a/examples/ui/tabs_advanced.py b/examples/ui/tabs_advanced.py new file mode 100644 index 00000000000..846c702bcd4 --- /dev/null +++ b/examples/ui/tabs_advanced.py @@ -0,0 +1,82 @@ +# /// script +# requires-python = ">=3.9" +# dependencies = [ +# "marimo", +# ] +# /// + +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + # Tabs + """) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + Use `mo.ui.tabs` to organize outputs. + """) + return + + +@app.cell +def _(mo): + settings = mo.vstack( + [ + mo.md("Edit User"), + first := mo.ui.text(label="First Name"), + last := mo.ui.text(label="Last Name"), + ] + ) + + organization = mo.vstack( + [ + mo.md("Edit Organization"), + org := mo.ui.text(label="Organization Name", value="..."), + employees := mo.ui.number( + label="Number of Employees", start=0, stop=1000 + ), + ] + ) + + mo.ui.tabs( + { + "🧙‍♀ User": settings, + "🏢 Organization": organization, + } + ) + return employees, first, last, org + + +@app.cell +def _(employees, first, last, mo, org): + mo.md( + f""" + Welcome **{first.value} {last.value}** to **{org.value}**! You are + employee no. **{employees.value + 1}**. + + #{"🎉" * (min(employees.value + 1, 1000))} + """ + ) if all([first.value, last.value, org.value]) else mo.md( + "Type a first and last name!" + ) + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/text.py b/examples/ui/text.py new file mode 100644 index 00000000000..18186026234 --- /dev/null +++ b/examples/ui/text.py @@ -0,0 +1,28 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + text = mo.ui.text() + text + return (text,) + + +@app.cell +def _(text): + text.value + return + + +if __name__ == "__main__": + app.run() diff --git a/examples/ui/text_area.py b/examples/ui/text_area.py new file mode 100644 index 00000000000..71f7312dd2a --- /dev/null +++ b/examples/ui/text_area.py @@ -0,0 +1,28 @@ +import marimo + +__generated_with = "0.19.7" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + text_area = mo.ui.text_area(placeholder="type some text ...") + text_area + return (text_area,) + + +@app.cell +def _(text_area): + text_area.value + return + + +if __name__ == "__main__": + app.run() diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs deleted file mode 100644 index 948e55353b5..00000000000 --- a/frontend/.eslintrc.cjs +++ /dev/null @@ -1,197 +0,0 @@ -/* Copyright 2024 Marimo. All rights reserved. */ -/** - * @type {import('eslint').Linter.Config} - */ -module.exports = { - root: true, - extends: [ - "eslint:recommended", - // This ruleset is meant to be used after extending eslint:recommended. - // It disables core ESLint rules that are already checked by the TypeScript compiler. - "plugin:@typescript-eslint/eslint-recommended", - // TS ESLint - "plugin:@typescript-eslint/recommended-type-checked", - "plugin:@typescript-eslint/stylistic-type-checked", - "plugin:@typescript-eslint/strict-type-checked", - // Accessibility - "plugin:jsx-a11y/strict", - // React - "plugin:react-hooks/recommended", - "plugin:react/recommended", - "plugin:react/jsx-runtime", - "plugin:ssr-friendly/recommended", - // Storybook - "plugin:storybook/recommended", - // Unicorn - "plugin:unicorn/all", - // Testing - "plugin:vitest/recommended", - // This removes rules that conflict with prettier/biomejs. - "prettier", - ], - settings: { - react: { - version: "detect", - }, - }, - parser: "@typescript-eslint/parser", - parserOptions: { - project: require.resolve("./tsconfig.json"), - }, - plugins: ["@typescript-eslint", "header", "react-compiler"], - rules: { - "react-compiler/react-compiler": "warn", - - "header/header": [ - "error", - "block", - " Copyright 2024 Marimo. All rights reserved. ", - ], - - // These rules don't require type information and have autofixes - "@typescript-eslint/array-type": ["error", { default: "array-simple" }], - "@typescript-eslint/consistent-generic-constructors": "error", - "@typescript-eslint/consistent-type-definitions": "error", - "@typescript-eslint/no-confusing-non-null-assertion": "error", - "@typescript-eslint/no-dynamic-delete": "error", - "@typescript-eslint/prefer-ts-expect-error": "error", - curly: "error", - - // Turn off recommended we don't want - "ssr-friendly/no-dom-globals-in-react-fc": "off", - "ssr-friendly/no-dom-globals-in-constructor": "off", - "react/prop-types": "off", - "react/no-unescaped-entities": "off", - "@typescript-eslint/no-unnecessary-condition": "off", - "@typescript-eslint/use-unknown-in-catch-callback-variable": "off", - "@typescript-eslint/ban-types": [ - "error", - { - types: { - // un-ban {} - "{}": false, - }, - extendDefaults: true, - }, - ], - "@typescript-eslint/no-confusing-void-expression": [ - "error", - { ignoreArrowShorthand: true }, - ], - "@typescript-eslint/prefer-nullish-coalescing": "off", // Throws an error: TypeError: Cannot read properties of undefined (reading 'some') - "@typescript-eslint/no-unused-vars": "off", - "@typescript-eslint/consistent-indexed-object-style": "off", - "@typescript-eslint/require-await": "off", - "@typescript-eslint/restrict-template-expressions": "off", - "jsx-a11y/no-autofocus": "off", - "jsx-a11y/no-static-element-interactions": "off", - "jsx-a11y/no-noninteractive-element-interactions": "off", - "jsx-a11y/click-events-have-key-events": "off", - "storybook/no-redundant-story-name": "off", - // Turn of unicorn rules that don't have autofixes or that we don't want - "unicorn/prefer-string-raw": "off", - "unicorn/consistent-function-scoping": "off", - "unicorn/expiring-todo-comments": "off", - "unicorn/filename-case": "off", - "unicorn/no-array-callback-reference": "off", - "unicorn/no-array-for-each": "off", - "unicorn/no-array-method-this-argument": "off", // false positives - "unicorn/no-array-reduce": "off", - "unicorn/no-await-expression-member": "off", - "unicorn/no-null": "off", - "unicorn/no-keyword-prefix": "off", - "unicorn/no-useless-undefined": "off", - "unicorn/prefer-add-event-listener": "off", - "unicorn/require-post-message-target-origin": "off", - "unicorn/prefer-at": "off", - "unicorn/prefer-code-point": "off", - "unicorn/prefer-module": "off", - "unicorn/prefer-query-selector": "off", - "unicorn/prefer-dom-node-text-content": "off", - "unicorn/prefer-top-level-await": "off", - "unicorn/prevent-abbreviations": "off", - - "no-console": "error", - - // Would like to turn on, but too many existing errors - "@typescript-eslint/no-floating-promises": "off", - "@typescript-eslint/no-misused-promises": "off", - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/no-unsafe-return": "off", - // "@typescript-eslint/strict-boolean-expressions": [ - // "error", - // { - // // Defaults - // allowString: true, - // allowNumber: true, - // allowNullableObject: true, - // // Custom - // allowNullableBoolean: true, - // allowNullableString: true, - // }, - // ], - - // These rules aim to reduce bikeshedding during code reviews - // Often there are multiple ways to do something and this forces consistency - "prefer-template": "error", // Use template literals instead of string concatenation - "unicorn/switch-case-braces": ["error", "avoid"], // Only braces when necessary - "unicorn/consistent-destructuring": "error", - "unicorn/prefer-logical-operator-over-ternary": "error", - "unicorn/prefer-spread": "error", - "unicorn/no-object-as-default-parameter": "error", - "unicorn/prefer-number-properties": "error", - "unicorn/prefer-ternary": "error", - "unicorn/prefer-array-some": "error", - "react/jsx-boolean-value": ["error", "always"], // Force `={true}` or `={false}` as it's more explicit - "react/hook-use-state": "error", - "react/jsx-no-useless-fragment": "error", - "react/jsx-pascal-case": "error", - "react/self-closing-comp": "error", - "react/function-component-definition": [ - "error", - { - namedComponents: "arrow-function", - unnamedComponents: "arrow-function", - }, - ], - }, - overrides: [ - { - files: ["**/e2e-tests/**"], - parserOptions: { - project: require.resolve("./e2e-tests/tsconfig.json"), - }, - rules: { - "testing-library/prefer-screen-queries": "off", - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/await-thenable": "off", - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/require-await": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/no-unsafe-return": "off", - }, - }, - { - files: ["**/__tests__/**"], - rules: { - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/require-await": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "typescript-eslint/no-unsafe-return": "off", - }, - }, - { - files: ["**/stories/**"], - rules: { - "no-console": "off", - }, - }, - ], -}; diff --git a/frontend/.gitignore b/frontend/.gitignore index dfdfd24806a..e0ed053c5b8 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,26 +1,3 @@ -# dependencies -/node_modules -/.pnp -.pnp.js -.swc -.turbo - -# testing -/coverage - -# production -/build - -# library output -/lib -!/src/lib - -# playwright -/test-results/ -/playwright-report/ -/playwright/.cache/ -e2e-tests/screenshots -e2e-tests/exports - -# storybook -/storybook-static +playwright-report/ +test-results/ +coverage/ diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 00000000000..9d806e687fc --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,164 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": [ + "typescript", + "unicorn", + "react", + "vitest", + "jsx-a11y" + ], + "jsPlugins": [ + "./lint/marimo-plugin.js" + ], + "ignorePatterns": [ + "**/e2e-tests/**" + ], + "categories": { + "correctness": "error", + "nursery": "off", + "pedantic": "off", + "perf": "warn", + "restriction": "warn", + "style": "off", + "suspicious": "warn" + }, + "rules": { + // === Marimo custom rules === + "marimo/add-event-listener-object": "error", + "marimo/remove-event-listener-object": "error", + "marimo/prefer-object-params": "warn", + "marimo/atom-with-storage-args": "error", + "marimo/no-deprecated-tailwind-classes": "warn", + "marimo/no-removed-tailwind-classes": "error", + + // === Disabled category rules (too noisy / not applicable) === + "no-use-before-define": "off", + "no-undefined": "off", + "no-shadow": "off", + "no-eq-null": "off", + "no-plusplus": "off", + "no-param-reassign": "off", + "no-void": "off", + "no-await-in-loop": "off", + "complexity": "off", + "default-case": "off", + "class-methods-use-this": "off", + "react/react-in-jsx-scope": "off", + "react/only-export-components": "off", + "typescript/explicit-module-boundary-types": "off", + "unicorn/no-array-for-each": "off", + "unicorn/no-array-reduce": "off", + "unicorn/consistent-function-scoping": "off", + "unicorn/prefer-add-event-listener": "off", + "vitest/require-test-timeout": "off", + "vitest/require-mock-type-parameters": "off", + + // === Core rules === + "no-var": "error", + "no-console": "error", + "no-constant-condition": "warn", + "no-inner-declarations": "error", + "no-useless-constructor": "error", + + "no-redeclare": "off", + "no-empty": "warn", + "no-cond-assign": "warn", + "prefer-const": "error", + "prefer-template": "error", + "default-param-last": "error", + "eqeqeq": ["error", "smart"], + "curly": "error", + "array-callback-return": "warn", + "no-new": "off", + "no-bitwise": "off", + // TODO: re-enable + "no-control-regex": "warn", + // TODO: re-enable + "no-unsafe-optional-chaining": "warn", + // TODO: re-enable + "no-unused-expressions": "warn", + // TODO: re-enable + "no-unused-vars": "warn", + + // === TypeScript === + "typescript/no-explicit-any": "warn", + "typescript/no-non-null-assertion": "warn", + "typescript/ban-ts-comment": "warn", + "typescript/consistent-generic-constructors": "error", + "typescript/consistent-type-definitions": "error", + "typescript/no-confusing-non-null-assertion": "error", + "typescript/no-dynamic-delete": "error", + "typescript/prefer-ts-expect-error": "error", + "typescript/no-empty-object-type": "off", + "typescript/no-confusing-void-expression": "error", + "typescript/consistent-type-imports": ["error", { "disallowTypeAnnotations": false }], + "typescript/explicit-function-return-type": "off", + + // === Unicorn === + "unicorn/switch-case-braces": ["error", "avoid"], + "unicorn/prefer-logical-operator-over-ternary": "error", + "unicorn/prefer-spread": "error", + "unicorn/no-object-as-default-parameter": "error", + "unicorn/prefer-number-properties": "error", + "unicorn/prefer-ternary": "error", + "unicorn/no-instanceof-array": "error", + + // === React === + "react/no-array-index-key": "off", + "react/jsx-boolean-value": ["error", "always"], + "react/jsx-pascal-case": "error", + "react/no-multi-comp": "off", + "react/jsx-filename-extension": "off", + // TODO: re-enable as error + "react/jsx-no-useless-fragment": "warn", + "react/jsx-no-target-blank": "warn", + "react/jsx-no-comment-textnodes": "warn", + "react/self-closing-comp": "error", + + // === React Hooks === + "react-hooks/rules-of-hooks": "warn", + "react-hooks/exhaustive-deps": "warn", + + // === JSX A11y === + // TODO: re-enable all of these as error + "jsx-a11y/click-events-have-key-events": "off", + "jsx-a11y/heading-has-content": "off", + "jsx-a11y/iframe-has-title": "warn", + "jsx-a11y/media-has-caption": "off", + "jsx-a11y/no-autofocus": "off", + "jsx-a11y/no-noninteractive-tabindex": "warn", + "jsx-a11y/no-static-element-interactions": "off", + "jsx-a11y/prefer-tag-over-role": "warn", + "jsx-a11y/role-has-required-aria-props": "warn", + + "react/no-danger": "off", + + // === Unicorn === + "unicorn/prefer-node-protocol": "error", + + // === Vitest === + "vitest/expect-expect": "warn", + "vitest/no-identical-title": "error", + "vitest/no-commented-out-tests": "warn", + }, + "overrides": [ + { + "files": [ + "**/__mocks__/**", + "**/vite.config.*", + "**/stories/**", + "**/Logger.ts" + ], + "rules": { + "no-console": "off" + } + }, + { + "files": ["**/__tests__/**"], + "rules": { + "typescript/no-non-null-assertion": "off", + "typescript/unbound-method": "off" + } + } + ] +} diff --git a/frontend/.pnpmfile.cjs b/frontend/.pnpmfile.cjs index 6966d8c36ef..de1a34074fa 100644 --- a/frontend/.pnpmfile.cjs +++ b/frontend/.pnpmfile.cjs @@ -1,3 +1,4 @@ +// oxlint-disable typescript/no-dynamic-delete const IGNORE = ["vue", "solid-js", "svelte", "solid-swr-store"]; module.exports = { diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index 544dbba5784..92428dc35c0 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -1,18 +1,23 @@ +import * as mod from "node:module"; +import * as path from "node:path"; import type { StorybookConfig } from "@storybook/react-vite"; -const config: StorybookConfig = { + +function absolutePath(value: string) { + const require = mod.createRequire(import.meta.url); + return path.dirname(require.resolve(path.join(value, "package.json"))); +} + +export default { stories: ["../src/**/*.mdx", "../src/**/*.@(mdx|stories.@(js|jsx|ts|tsx))"], addons: [ - "@storybook/addon-links", - "@storybook/addon-essentials", - "@storybook/addon-interactions", + absolutePath("@storybook/addon-links"), + absolutePath("@storybook/addon-docs"), ], framework: { - name: "@storybook/react-vite", + name: absolutePath("@storybook/react-vite"), options: {}, }, docs: { - autodocs: "tag", docsMode: false, }, -}; -export default config; +} satisfies StorybookConfig; diff --git a/frontend/.storybook/preview-head.html b/frontend/.storybook/preview-head.html index 2ed620bd3b0..52d2f84dc7d 100644 --- a/frontend/.storybook/preview-head.html +++ b/frontend/.storybook/preview-head.html @@ -1,9 +1 @@ - - - - - diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 0e63af3ebe7..cc3dc060710 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -1,13 +1,13 @@ -import type { Preview, Decorator } from "@storybook/react"; +import type { Decorator, Preview } from "@storybook/react-vite"; import "../src/css/index.css"; import "../src/css/app/App.css"; import "./sb.css"; -import "tailwindcss/tailwind.css"; +import "tailwindcss"; import React, { useEffect } from "react"; -import { cn } from "../src/utils/cn"; -import { TooltipProvider } from "../src/components/ui/tooltip"; -import { Toaster } from "../src/components/ui/toaster"; import { TailwindIndicator } from "../src/components/debug/indicator"; +import { Toaster } from "../src/components/ui/toaster"; +import { TooltipProvider } from "../src/components/ui/tooltip"; +import { cn } from "../src/utils/cn"; const withTheme: Decorator = (Story, context) => { const theme = context.globals.theme || "light"; diff --git a/frontend/.storybook/sb.css b/frontend/.storybook/sb.css index e09e2c595a6..6000b30a82d 100644 --- a/frontend/.storybook/sb.css +++ b/frontend/.storybook/sb.css @@ -1,3 +1,5 @@ +@reference "../src/css/globals.css"; + .sbdocs-content { display: flex; gap: 20px; @@ -9,5 +11,5 @@ } .sbdocs.sbdocs-wrapper h1 { - @apply text-foreground !important; + @apply !text-foreground; } diff --git a/frontend/.stylelintrc.cjs b/frontend/.stylelintrc.cjs index cadb6597b8d..ce13331ca37 100644 --- a/frontend/.stylelintrc.cjs +++ b/frontend/.stylelintrc.cjs @@ -9,7 +9,10 @@ module.exports = { "selector-id-pattern": null, "no-descending-specificity": null, // Turn off rules from the standard config - "at-rule-no-unknown": [true, { ignoreAtRules: ["tailwind"] }], + "at-rule-no-unknown": [ + true, + { ignoreAtRules: ["tailwind", "reference", "config", "theme"] }, + ], "font-family-no-missing-generic-family-keyword": null, "number-max-precision": null, // Force font-size to be in rem diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 00000000000..b8ea5ef3dbd --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,55 @@ +# Frontend Guidelines + +## Key Principles + +- Write clear, maintainable code over clever/short syntax +- Use TypeScript with proper typing for all code +- Use functional programming patterns; avoid classes +- Favor composition over inheritance +- Reduce code duplication, if you see code that is repeated, refactor it to a function or component. This standardizes the codebase. + +## Naming Conventions + +- **Directories**: lowercase with dashes (`components/auth-wizard/`) +- **Components**: PascalCase (`DashboardMenu.tsx`) +- **Variables**: descriptive with auxiliary verbs (`isLoading`, `hasError`, `canSubmit`) + +## Testing + +### Unit Tests + +Tests live alongside source files or in `__tests__` directories. + +```bash +pnpm turbo --filter @marimo-team/frontend test # All tests +pnpm turbo --filter @marimo-team/frontend test src/__tests__/lru.test.ts # Specific file +``` + +Best practices: +- test edge cases +- use descriptive names +- group with `describe` +- prefer complete assertions over individual property checks (e.g., `expect(result).toEqual(expected)` rather than checking each property separately) + +### E2E Tests + +E2E tests use Playwright. See [e2e-tests/README.md](e2e-tests/README.md) for details. + +## Code Quality + +Avoid as much as possible using type assertions as they can cause runtime errors. Instead, use type guards, type predicates and error handling. +```typescript +callFunction(x as T) // Avoid this +``` + +- Use logNever or assertNever to handle exhaustive switch cases. +```typescript +switch (value) { + case "a": + break; + default: + logNever(value); +} +``` + +- Keep comments minimal. Comments should only explain "why", not "what". diff --git a/frontend/benchmarks/base64-conversion.bench.ts b/frontend/benchmarks/base64-conversion.bench.ts new file mode 100644 index 00000000000..20650249ffe --- /dev/null +++ b/frontend/benchmarks/base64-conversion.bench.ts @@ -0,0 +1,60 @@ +import { bench, describe } from "vitest"; + +// To run: +// pnpm vitest bench benchmarks/base64-conversion.bench.ts + +// Helper to convert Uint8Array to base64 +function uint8ArrayToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); +} + +// Slow implementation (using Uint8Array.from) +function base64ToUint8ArraySlow(bytes: string): Uint8Array { + const binary = window.atob(bytes); + return Uint8Array.from(binary, (c) => c.charCodeAt(0)); +} + +// Fast implementation (using manual loop) +function base64ToUint8ArrayFast(bytes: string): Uint8Array { + const binary = window.atob(bytes); + const len = binary.length; + const uint8Array = new Uint8Array(len); + for (let i = 0; i < len; i++) { + uint8Array[i] = binary.charCodeAt(i); + } + return uint8Array; +} + +// Test data setup +const sizes = { + small: 1024, // 1KB + medium: 10 * 1024, // 10KB + large: 100 * 1024, // 100KB + xlarge: 1024 * 1024, // 1MB +}; + +const testData: Record = {}; +for (const [name, size] of Object.entries(sizes)) { + const data = new Uint8Array(size); + for (let i = 0; i < size; i++) { + data[i] = i % 256; + } + testData[name] = uint8ArrayToBase64(data); +} + +// Benchmarks +describe("base64 to Uint8Array conversion", () => { + for (const [name, base64] of Object.entries(testData)) { + bench(`slow - ${name}`, () => { + base64ToUint8ArraySlow(base64); + }); + + bench(`fast - ${name}`, () => { + base64ToUint8ArrayFast(base64); + }); + } +}); diff --git a/frontend/benchmarks/uint8array-to-base64.bench.ts b/frontend/benchmarks/uint8array-to-base64.bench.ts new file mode 100644 index 00000000000..e0e6384b649 --- /dev/null +++ b/frontend/benchmarks/uint8array-to-base64.bench.ts @@ -0,0 +1,50 @@ +import { bench, describe } from "vitest"; + +// To run: +// pnpm vitest bench benchmarks/uint8array-to-base64.bench.ts + +// Slow implementation (using Array.from with callback) +function uint8ArrayToBase64Slow(binary: Uint8Array): string { + const chars = Array.from(binary, (byte) => String.fromCharCode(byte)); + return window.btoa(chars.join("")); +} + +// Fast implementation (using manual loop) +function uint8ArrayToBase64Fast(binary: Uint8Array): string { + let binaryString = ""; + const len = binary.length; + for (let i = 0; i < len; i++) { + binaryString += String.fromCharCode(binary[i]); + } + return window.btoa(binaryString); +} + +// Test data setup +const sizes = { + small: 1024, // 1KB + medium: 10 * 1024, // 10KB + large: 100 * 1024, // 100KB + xlarge: 1024 * 1024, // 1MB +}; + +const testData: Record = {}; +for (const [name, size] of Object.entries(sizes)) { + const data = new Uint8Array(size); + for (let i = 0; i < size; i++) { + data[i] = i % 256; + } + testData[name] = data; +} + +// Benchmarks +describe("Uint8Array to base64 conversion", () => { + for (const [name, data] of Object.entries(testData)) { + bench(`slow - ${name}`, () => { + uint8ArrayToBase64Slow(data); + }); + + bench(`fast - ${name}`, () => { + uint8ArrayToBase64Fast(data); + }); + } +}); diff --git a/frontend/e2e-tests/README.md b/frontend/e2e-tests/README.md new file mode 100644 index 00000000000..dce709022ad --- /dev/null +++ b/frontend/e2e-tests/README.md @@ -0,0 +1,20 @@ +# E2E Tests + +E2E tests use Playwright. + +## Running Tests + +```bash +pnpm playwright test # Run all e2e tests +pnpm playwright test e2e-tests/slides.spec.ts # Run specific test +pnpm playwright test --ui # Interactive UI mode +``` + +## Rebuild Before Taking Screenshots + +Rebuild the frontend for tests that take screenshots to view the latest changes. + +```bash +make fe +pnpm playwright test +``` diff --git a/frontend/e2e-tests/badButton.spec.ts b/frontend/e2e-tests/badButton.spec.ts index 2d3f927f5e6..e195b6d32bf 100644 --- a/frontend/e2e-tests/badButton.spec.ts +++ b/frontend/e2e-tests/badButton.spec.ts @@ -1,5 +1,5 @@ -/* Copyright 2024 Marimo. All rights reserved. */ -import { test, expect } from "@playwright/test"; +/* Copyright 2026 Marimo. All rights reserved. */ +import { expect, test } from "@playwright/test"; import { getAppUrl } from "../playwright.config"; const appUrl = getAppUrl("bad_button.py"); diff --git a/frontend/e2e-tests/bugs.spec.ts b/frontend/e2e-tests/bugs.spec.ts index 4a339af4783..899522de64c 100644 --- a/frontend/e2e-tests/bugs.spec.ts +++ b/frontend/e2e-tests/bugs.spec.ts @@ -1,5 +1,5 @@ -/* Copyright 2024 Marimo. All rights reserved. */ -import { test, expect } from "@playwright/test"; +/* Copyright 2026 Marimo. All rights reserved. */ +import { expect, test } from "@playwright/test"; import { getAppUrl } from "../playwright.config"; import { createCellBelow, maybeRestartKernel, runCell } from "./helper"; @@ -16,7 +16,7 @@ test.beforeEach(async ({ page }, info) => { * This test makes sure that downstream UI elements are re-initialized when * upstream source cells are re-run. */ -test("correctly initializes cells", async ({ page }, info) => { +test("correctly initializes cells", async ({ page }) => { // Is initialized to 1 const number = page .getByTestId("marimo-plugin-number-input") diff --git a/frontend/e2e-tests/cells.spec.ts b/frontend/e2e-tests/cells.spec.ts index d6ef5728de1..d366c1ad003 100644 --- a/frontend/e2e-tests/cells.spec.ts +++ b/frontend/e2e-tests/cells.spec.ts @@ -1,10 +1,10 @@ -/* Copyright 2024 Marimo. All rights reserved. */ -import { test, expect } from "@playwright/test"; +/* Copyright 2026 Marimo. All rights reserved. */ +import { expect, test } from "@playwright/test"; import { getAppUrl, resetFile } from "../playwright.config"; import { exportAsHTMLAndTakeScreenshot, - pressShortcut, maybeRestartKernel, + pressShortcut, } from "./helper"; const appUrl = getAppUrl("cells.py"); @@ -38,7 +38,7 @@ test("keeps re-renders from growing", async ({ page }) => { // It is also ok to decrease the count if we find a way to reduce the number // of renders. expect(cellRenderCount).toBeDefined(); - expect(Number.parseInt(cellRenderCount || "")).toBeLessThanOrEqual(6); + expect(Number.parseInt(cellRenderCount || "", 10)).toBeLessThanOrEqual(6); }); /** diff --git a/frontend/e2e-tests/columns-scroll.spec.ts b/frontend/e2e-tests/columns-scroll.spec.ts new file mode 100644 index 00000000000..7792084d366 --- /dev/null +++ b/frontend/e2e-tests/columns-scroll.spec.ts @@ -0,0 +1,159 @@ +/* Copyright 2026 Marimo. All rights reserved. */ +import type { Locator, Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; +import { getAppUrl } from "../playwright.config"; +import { maybeRestartKernel, pressShortcut } from "./helper"; + +const appUrl = getAppUrl("columns.py"); + +/** + * Asserts whether scrolling horizontally and vertically into view works as expected. + */ + +test.beforeEach(async ({ page }, info) => { + await page.goto(appUrl); + if (info.retry) { + await page.reload(); + await maybeRestartKernel(page); + } +}); + +async function waitForCellsToRun(page: Page): Promise { + await page.waitForLoadState("networkidle"); + + const app = page.locator("#App"); + await expect(app).toHaveAttribute("data-config-width", "columns"); + + // Go-to-definition resolves through the kernel's variable registry, so the + // cells have to have run before the jump can find anything. + await pressShortcut(page, "global.runStale"); + await expect(page.locator(".marimo-cell.needs-run")).toHaveCount(0, { + timeout: 30_000, + }); +} + +/** + * Locates the cell containing `text` and asserts it starts outside the + * initial viewport along `axis`, so a later `toBeInViewport` assertion can't + * pass vacuously. + */ +async function offScreenCell( + page: Page, + text: string, + axis: "x" | "both", +): Promise { + const cell = page.locator(".marimo-cell").filter({ hasText: text }); + await expect(cell).toHaveCount(1); + await expect(cell).not.toBeInViewport(); + + const viewport = page.viewportSize(); + const box = await cell.boundingBox(); + if (!viewport || !box) { + throw new Error(`could not measure the "${text}" cell`); + } + expect(box.x).toBeGreaterThanOrEqual(viewport.width); + if (axis === "both") { + expect(box.y).toBeGreaterThanOrEqual(viewport.height); + } + + return cell; +} + +/** + * Returns the column 3 cell holding `far_away_variable = 42`, which starts + * off-screen horizontally only. + */ +async function setUpColumnsNotebook(page: Page): Promise { + await waitForCellsToRun(page); + return offScreenCell(page, "far_away_variable = 42", "x"); +} + +/** + * Cmd/Ctrl + click a usage to jump to its definition. Go-to-definition only + * arms on a modifier keydown, and only resolves a target once a mousemove + * while the modifier is held has marked the token -- so drive + * keydown -> hover -> click explicitly. + * + * Cross-cell jumps resolve through the kernel's variable registry. Waiting + * for `.mo-cm-reactive-reference` (not the AST `.underline` fallback) is what + * proves that registry is ready; without it the click arms but cannot find + * the defining cell. + */ +async function jumpToDefinition( + page: Page, + variableName: string, +): Promise { + const usage = page + .locator(".cm-content") + .first() + .locator(".mo-cm-reactive-reference") + .getByText(variableName, { exact: true }) + .first(); + await expect(usage).toBeVisible({ timeout: 30_000 }); + + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await page.keyboard.down(modifier); + await usage.hover(); + + // Guard: if the token never marks, the click below is a no-op and the + // test would fail for reasons unrelated to scrolling. + await expect( + page.locator(".mo-cm-reactive-reference-hover").first(), + ).toBeVisible(); + + await usage.click(); + await page.keyboard.up(modifier); +} + +test("jump to definition scrolls horizontally to an off-screen column", async ({ + page, +}) => { + const definitionCell = await setUpColumnsNotebook(page); + + // Cmd/Ctrl + click the usage in column 0; its definition is in column 3. + await jumpToDefinition(page, "far_away_variable"); + + await expect(definitionCell).toBeInViewport({ ratio: 0.5 }); +}); + +test("jump to definition scrolls both vertically and horizontally to an off-screen cell", async ({ + page, +}) => { + await waitForCellsToRun(page); + // Column 4 has a tall filler cell above this one, so it starts off-screen + // below the fold as well as to the right -- unlike far_away_variable, + // which only needs a horizontal scroll. + const definitionCell = await offScreenCell( + page, + "deep_and_far_variable = 99", + "both", + ); + + await jumpToDefinition(page, "deep_and_far_variable"); + + await expect(definitionCell).toBeInViewport({ ratio: 0.5 }); +}); + +test("find next scrolls horizontally to a match in an off-screen column", async ({ + page, +}) => { + const definitionCell = await setUpColumnsNotebook(page); + + // Focus a cell so the cell-scoped find/replace shortcut applies. + await page.locator(".cm-content").first().click(); + await pressShortcut(page, "cell.findAndReplace"); + + const findInput = page.getByTestId("find-input"); + await expect(findInput).toBeVisible(); + await findInput.fill("far_away_variable"); + + // Two matches in column 0, then the definition in column 3. Stepping + // through the in-cell matches first is what looked like search being + // "stuck": the selection advanced, but the viewport never followed. + const findNext = page.getByTestId("find-next-button"); + await findNext.click(); + await findNext.click(); + await findNext.click(); + + await expect(definitionCell).toBeInViewport({ ratio: 0.5 }); +}); diff --git a/frontend/e2e-tests/components.spec.ts b/frontend/e2e-tests/components.spec.ts index 3ea1716c518..70bbfa5f71d 100644 --- a/frontend/e2e-tests/components.spec.ts +++ b/frontend/e2e-tests/components.spec.ts @@ -1,8 +1,9 @@ -/* Copyright 2024 Marimo. All rights reserved. */ -import { test, expect, type Page } from "@playwright/test"; +/* Copyright 2026 Marimo. All rights reserved. */ + +import { fileURLToPath } from "node:url"; +import { expect, type Page, test } from "@playwright/test"; import { getAppUrl } from "../playwright.config"; import { takeScreenshot } from "./helper"; -import { fileURLToPath } from "node:url"; const _filename = fileURLToPath(import.meta.url); @@ -20,7 +21,7 @@ test.describe.configure({ mode: "parallel" }); const pageHelper = (page: Page) => { return { cell(index: number) { - return page.locator(".Cell").nth(index); + return page.locator(".marimo-cell").nth(index); }, async selectBasicComponent(type: string) { const select = await this.cell(1).locator("select"); @@ -145,6 +146,18 @@ test("file area", async ({ page }) => { await takeScreenshot(page, _filename); }); +test("file area single", async ({ page }) => { + const helper = pageHelper(page); + await helper.selectBasicComponent("file area single"); + const element = page.getByText("Drag and drop a file here"); + // Verify is visible + await expect(element).toBeVisible(); + // Verify output + await helper.verifyOutput("None"); + + await takeScreenshot(page, _filename); +}); + test("multiselect", async ({ page }) => { const helper = pageHelper(page); await helper.selectBasicComponent("multiselect"); @@ -278,7 +291,7 @@ test("table", async ({ page }) => { helper.cell(3).locator(".marimo-json-output").first(), ).toHaveText( ` -[1 Items +[1 Item 0:{2 Items "first_name":"Dwight" "last_name":"Schrute" diff --git a/frontend/e2e-tests/disabled.spec.ts b/frontend/e2e-tests/disabled.spec.ts index 46c93ead4b4..190d76576ce 100644 --- a/frontend/e2e-tests/disabled.spec.ts +++ b/frontend/e2e-tests/disabled.spec.ts @@ -1,8 +1,9 @@ -/* Copyright 2024 Marimo. All rights reserved. */ -import { test, expect } from "@playwright/test"; +/* Copyright 2026 Marimo. All rights reserved. */ + +import { fileURLToPath } from "node:url"; +import { expect, test } from "@playwright/test"; import { getAppUrl, resetFile } from "../playwright.config"; import { maybeRestartKernel, takeScreenshot } from "./helper"; -import { fileURLToPath } from "node:url"; const _filename = fileURLToPath(import.meta.url); diff --git a/frontend/e2e-tests/global-setup.ts b/frontend/e2e-tests/global-setup.ts new file mode 100644 index 00000000000..d70f5857146 --- /dev/null +++ b/frontend/e2e-tests/global-setup.ts @@ -0,0 +1,51 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import { chromium, type FullConfig } from "@playwright/test"; +import { type ApplicationNames, getAppUrl } from "../playwright.config"; + +async function globalSetup(_config: FullConfig) { + // Start a browser to test server connectivity + const browser = await chromium.launch(); + const page = await browser.newPage(); + + // Test some apps to ensure they're ready + const criticalApps: ApplicationNames[] = ["components.py"]; + + console.log("🔧 Testing server connectivity..."); + + for (const app of criticalApps) { + try { + const url = getAppUrl(app); + console.log(`Testing ${app} at ${url}`); + + // Wait for server to be ready with retries + let retries = 3; + while (retries > 0) { + try { + await page.goto(url, { + waitUntil: "load", + timeout: 15_000, + }); + console.log(`✅ ${app} ready`); + break; + } catch (error) { + retries--; + if (retries === 0) { + console.error(`❌ ${app} failed to start:`, error); + throw error; + } + console.log(`⏳ Retrying ${app} (${retries} attempts left)`); + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } + } catch (error) { + console.error(`Failed to connect to ${app}:`, error); + throw error; + } + } + + await browser.close(); + console.log("🎉 All servers ready!"); +} + +export default globalSetup; diff --git a/frontend/e2e-tests/global-teardown.ts b/frontend/e2e-tests/global-teardown.ts new file mode 100644 index 00000000000..bd8e1d1a3ae --- /dev/null +++ b/frontend/e2e-tests/global-teardown.ts @@ -0,0 +1,103 @@ +/* Copyright 2026 Marimo. All rights reserved. */ +/* oxlint-disable no-console -- for debugging */ + +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import { getTestPorts } from "../playwright.config"; + +const execAsync = promisify(exec); + +/** + * Kill a process and all of its descendants (e.g. orphaned kernel + * workers spawned via multiprocessing). + */ +async function killProcessTree(pid: number): Promise { + try { + const { stdout } = await execAsync(`pgrep -P ${pid}`); + const childPids = stdout + .split("\n") + .map((line) => Number.parseInt(line.trim(), 10)) + .filter((childPid) => Number.isInteger(childPid)); + await Promise.all(childPids.map((childPid) => killProcessTree(childPid))); + } catch { + // No children found; pgrep exits non-zero in that case. + } + + try { + await execAsync(`kill -9 ${pid}`); + } catch { + // Process may have already exited. + } +} + +async function getCommand(pid: number): Promise { + try { + const { stdout } = await execAsync(`ps -o command= -p ${pid}`); + return stdout.trim(); + } catch { + return ""; + } +} + +/** + * Kill whatever is listening on `port`, plus its parent process (`uv`, + * which doesn't forward SIGTERM to the marimo process it spawns) and all + * descendants (orphaned kernel workers). + */ +async function killServerOnPort(port: number): Promise { + let pids: number[] = []; + try { + const { stdout } = await execAsync(`lsof -ti tcp:${port} -sTCP:LISTEN -nP`); + pids = stdout + .split("\n") + .map((line) => Number.parseInt(line.trim(), 10)) + .filter((pid) => Number.isInteger(pid)); + } catch { + // Nothing listening on this port. + return; + } + + for (const pid of pids) { + const command = await getCommand(pid); + if (!command.includes("marimo")) { + continue; + } + + let targetPid = pid; + try { + const { stdout } = await execAsync(`ps -o ppid= -p ${pid}`); + const parentPid = Number.parseInt(stdout.trim(), 10); + if (Number.isInteger(parentPid) && parentPid > 1) { + // Only promote to the parent if it's actually the `uv` wrapper; + // otherwise leave it alone and just kill the marimo process itself. + const parentCommand = await getCommand(parentPid); + if (/(^|\/)uv(\s|$)/.test(parentCommand)) { + targetPid = parentPid; + } + } + } catch { + // Fall back to killing just the pid bound to the port. + } + await killProcessTree(targetPid); + } +} + +async function globalTeardown() { + console.log("🧹 Cleaning up test environment..."); + + try { + const ports = getTestPorts(); + await Promise.all(ports.map((port) => killServerOnPort(port))); + console.log(`✅ Cleaned up marimo servers on ports: ${ports.join(", ")}`); + + // Small delay to ensure cleanup completes + await new Promise((resolve) => setTimeout(resolve, 1000)); + + console.log("🎉 Cleanup complete!"); + } catch (error) { + console.error("❌ Error during cleanup:", error); + // Don't throw - we don't want cleanup failures to fail the test run + } +} + +export default globalTeardown; diff --git a/frontend/e2e-tests/helper.ts b/frontend/e2e-tests/helper.ts index 647129d06bf..4c0d00e53b3 100644 --- a/frontend/e2e-tests/helper.ts +++ b/frontend/e2e-tests/helper.ts @@ -1,7 +1,9 @@ -/* Copyright 2024 Marimo. All rights reserved. */ -import { type Locator, type Page, expect } from "@playwright/test"; -import { HotkeyProvider, type HotkeyAction } from "../src/core/hotkeys/hotkeys"; +/* Copyright 2026 Marimo. All rights reserved. */ + import path from "node:path"; +import { expect, type Locator, type Page } from "@playwright/test"; +import { type HotkeyAction, HotkeyProvider } from "../src/core/hotkeys/hotkeys"; +import { clickWithRetry, waitForMarimoApp } from "./test-utils"; export async function createCellBelow(opts: { page: Page; @@ -80,7 +82,7 @@ export async function takeScreenshot(page: Page, filename: string) { */ export async function pressShortcut(page: Page, action: HotkeyAction) { const isMac = await page.evaluate(() => navigator.userAgent.includes("Mac")); - const provider = HotkeyProvider.create(isMac); + const provider = HotkeyProvider.create(); const key = provider.getHotkey(action); // playwright uses "Meta" for command key on mac, "Control" for windows/linux // we also need to capitalize the first letter of each key @@ -103,18 +105,11 @@ export async function exportAsHTMLAndTakeScreenshot(page: Page) { // Wait for networkidle so that the notebook is fully loaded await page.waitForLoadState("networkidle"); - // Start waiting for download before clicking. + await openCommandPalette({ page, command: "Download as HTML" }); + const [download] = await Promise.all([ page.waitForEvent("download"), - page - .getByTestId("notebook-menu-dropdown") - .click() - .then(() => { - return page.getByText("Download", { exact: true }).hover(); - }) - .then(() => { - return page.getByText("Download as HTML", { exact: true }).click(); - }), + page.getByRole("button", { name: "Export HTML" }).click(), ]); // Wait for the download process to complete and save the downloaded file somewhere. @@ -150,10 +145,7 @@ export async function exportAsPNG(page: Page) { .getByTestId("notebook-menu-dropdown") .click() .then(() => { - return page.getByText("Download", { exact: true }).hover(); - }) - .then(() => { - return page.getByText("Download as PNG", { exact: true }).click(); + return openCommandPalette({ page, command: "Download as PNG" }); }), ]); @@ -162,12 +154,43 @@ export async function exportAsPNG(page: Page) { await download.saveAs(path); } +/** + * Open the command palette, type something, and hit Enter + */ +export async function openCommandPalette(opts: { + page: Page; + command: string; +}) { + const { page, command } = opts; + + // Blur any active element first to avoid typing into cell editors + await page.evaluate(() => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + }); + + // Open command palette with Ctrl+K (or Cmd+K on Mac) + await pressShortcut(page, "global.commandPalette"); + + // Wait for the command palette input to be visible + const commandInput = page.getByPlaceholder("Type to search..."); + await expect(commandInput).toBeVisible(); + + // Focus and wait for it to be ready + await commandInput.focus(); + await commandInput.fill(command); + + // Hit Enter to execute + await page.keyboard.press("Enter"); +} + /** * Waits for the page to load. If we have resumed a session, we restart the kernel. */ export async function maybeRestartKernel(page: Page) { // Wait for cells to appear - await waitForCellsToRender(page); + await waitForMarimoApp(page); // If it says, "You have connected to an existing session", then restart const hasText = await page @@ -177,14 +200,7 @@ export async function maybeRestartKernel(page: Page) { return; } - await page.getByTestId("notebook-menu-dropdown").click(); + await clickWithRetry(page, "[data-testid='notebook-menu-dropdown']"); await page.getByText("Restart kernel", { exact: true }).click(); await page.getByLabel("Confirm Restart", { exact: true }).click(); } - -/** - * Waits for cells to render in edit mode. - */ -export async function waitForCellsToRender(page: Page) { - await page.waitForSelector("[data-testid=cell-editor]"); -} diff --git a/frontend/e2e-tests/kitchen-sink-wasm.spec.ts b/frontend/e2e-tests/kitchen-sink-wasm.spec.ts index 500e10f26d4..29caf3d8f63 100644 --- a/frontend/e2e-tests/kitchen-sink-wasm.spec.ts +++ b/frontend/e2e-tests/kitchen-sink-wasm.spec.ts @@ -1,7 +1,8 @@ -/* Copyright 2024 Marimo. All rights reserved. */ +/* Copyright 2026 Marimo. All rights reserved. */ + +import { fileURLToPath } from "node:url"; import { expect, test } from "@playwright/test"; import { exportAsHTMLAndTakeScreenshot, takeScreenshot } from "./helper"; -import { fileURLToPath } from "node:url"; const _filename = fileURLToPath(import.meta.url); diff --git a/frontend/e2e-tests/kitchen-sink.spec.ts b/frontend/e2e-tests/kitchen-sink.spec.ts index 964a255c4e5..6da1a988d10 100644 --- a/frontend/e2e-tests/kitchen-sink.spec.ts +++ b/frontend/e2e-tests/kitchen-sink.spec.ts @@ -1,4 +1,6 @@ -/* Copyright 2024 Marimo. All rights reserved. */ +/* Copyright 2026 Marimo. All rights reserved. */ + +import { fileURLToPath } from "node:url"; import { test } from "@playwright/test"; import { getAppUrl } from "../playwright.config"; import { @@ -6,7 +8,6 @@ import { exportAsPNG, takeScreenshot, } from "./helper"; -import { fileURLToPath } from "node:url"; const _filename = fileURLToPath(import.meta.url); diff --git a/frontend/e2e-tests/layout-grid-with-sidebar.spec.ts b/frontend/e2e-tests/layout-grid-with-sidebar.spec.ts new file mode 100644 index 00000000000..0afa95aef3c --- /dev/null +++ b/frontend/e2e-tests/layout-grid-with-sidebar.spec.ts @@ -0,0 +1,33 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import { fileURLToPath } from "node:url"; +import { expect, test } from "@playwright/test"; +import { getAppUrl } from "../playwright.config"; +import { takeScreenshot } from "./helper"; + +const _filename = fileURLToPath(import.meta.url); + +const runUrl = getAppUrl("layout_grid_with_sidebar.py//run"); + +test("sidebar renders in run mode with grid layout", async ({ page }) => { + await page.goto(runUrl); + // wait 500ms to render + await page.waitForTimeout(500); + + // Verify main content is visible + await expect(page.getByText("Grid with Sidebar").last()).toBeVisible(); + await expect(page.getByText("Main Content Area").last()).toBeVisible(); + + // Verify sidebar content is visible + await expect(page.getByText("Sidebar Title").first()).toBeVisible(); + await expect( + page.getByText("This sidebar should be visible in run mode").first(), + ).toBeVisible(); + + // Verify nav menu items are visible + await expect(page.getByText("Section 1").first()).toBeVisible(); + await expect(page.getByText("Section 2").first()).toBeVisible(); + await expect(page.getByText("Section 3").first()).toBeVisible(); + + await takeScreenshot(page, _filename); +}); diff --git a/frontend/e2e-tests/layout-grid.spec.ts b/frontend/e2e-tests/layout-grid.spec.ts index 92fd62a932f..af2e85a77c9 100644 --- a/frontend/e2e-tests/layout-grid.spec.ts +++ b/frontend/e2e-tests/layout-grid.spec.ts @@ -1,8 +1,9 @@ -/* Copyright 2024 Marimo. All rights reserved. */ -import { test, expect, type Page } from "@playwright/test"; +/* Copyright 2026 Marimo. All rights reserved. */ + +import { fileURLToPath } from "node:url"; +import { expect, type Page, test } from "@playwright/test"; import { getAppUrl } from "../playwright.config"; import { takeScreenshot } from "./helper"; -import { fileURLToPath } from "node:url"; const _filename = fileURLToPath(import.meta.url); @@ -108,7 +109,11 @@ function expectValidBoundingBox( } async function bbForText(page: Page, text: string) { - const el = page.getByText(text).first(); + // Scope to the visible match: while presenting, the vertical layout renders + // the editable cell tree with its CodeMirror editors mounted but + // `display:none`, so an unscoped `getByText` also resolves the hidden editor + // line, whose text precedes the rendered output in the DOM. + const el = page.getByText(text).filter({ visible: true }).first(); await expect(el).toBeVisible(); const bb = await el.boundingBox(); expectValidBoundingBox(bb); diff --git a/frontend/e2e-tests/mode.spec.ts b/frontend/e2e-tests/mode.spec.ts index 876dc765327..5f7d07a04ff 100644 --- a/frontend/e2e-tests/mode.spec.ts +++ b/frontend/e2e-tests/mode.spec.ts @@ -1,20 +1,24 @@ -/* Copyright 2024 Marimo. All rights reserved. */ -import { test, expect, type Page, type BrowserContext } from "@playwright/test"; +/* Copyright 2026 Marimo. All rights reserved. */ + +import { fileURLToPath } from "node:url"; +import { type BrowserContext, expect, type Page, test } from "@playwright/test"; import { type ApplicationNames, getAppMode, getAppUrl, } from "../playwright.config"; import { maybeRestartKernel, takeScreenshot } from "./helper"; -import { fileURLToPath } from "node:url"; const _filename = fileURLToPath(import.meta.url); -async function gotoPage( - app: ApplicationNames, - page: Page, - context: BrowserContext, -) { +async function gotoPage({ + app, + page, +}: { + app: ApplicationNames; + page: Page; + context: BrowserContext; +}) { const url = getAppUrl(app); const mode = getAppMode(app); @@ -36,7 +40,7 @@ test.afterAll(async () => { }); test.skip("page renders edit feature in edit mode", async ({ context }) => { - await gotoPage("title.py", page, context); + await gotoPage({ app: "title.py", page, context }); // 'title.py' to be in the document. expect(await page.getByText("title.py").count()).toBeGreaterThan(0); @@ -51,7 +55,7 @@ test.skip("page renders edit feature in edit mode", async ({ context }) => { }); test.skip("can bring up the find/replace dialog", async ({ context }) => { - await gotoPage("title.py", page, context); + await gotoPage({ app: "title.py", page, context }); // Wait for the cells to load await expect(page.locator("h1").getByText("Hello Marimo!")).toBeVisible(); @@ -67,7 +71,7 @@ test.skip("can bring up the find/replace dialog", async ({ context }) => { }); test("can toggle to presenter mode", async ({ context }) => { - await gotoPage("title.py", page, context); + await gotoPage({ app: "title.py", page, context }); // Can see output and code await expect(page.locator("h1").getByText("Hello Marimo!")).toBeVisible(); @@ -92,7 +96,7 @@ test("can toggle to presenter mode", async ({ context }) => { }); test("page renders read only view in read mode", async ({ context }) => { - await gotoPage("components.py", page, context); + await gotoPage({ app: "components.py", page, context }); // Filename is not visible await expect(page.getByText("components.py").last()).not.toBeVisible(); diff --git a/frontend/e2e-tests/nav-menu.spec.ts b/frontend/e2e-tests/nav-menu.spec.ts new file mode 100644 index 00000000000..ab8b20105d4 --- /dev/null +++ b/frontend/e2e-tests/nav-menu.spec.ts @@ -0,0 +1,23 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import { expect, test } from "@playwright/test"; +import { getAppUrl } from "../playwright.config"; + +const appUrl = getAppUrl("nav_menu.py"); + +test("nav menu dropdown is not clipped by the cell output area", async ({ + page, +}) => { + await page.goto(appUrl); + + const trigger = page.getByRole("button", { name: "Links" }).first(); + await trigger.scrollIntoViewIfNeeded(); + await trigger.hover(); + + const link = page.getByRole("link", { name: "GitHub" }).first(); + await expect(link).toBeVisible(); + // The dropdown is portaled out of the cell's overflow container; a trial + // click performs Playwright's hit-target check, which fails if the link is + // clipped or covered by the cell output area, without navigating away. + await link.click({ trial: true }); +}); diff --git a/frontend/e2e-tests/output.spec.ts b/frontend/e2e-tests/output.spec.ts index 73ba2655ee4..23f6bc36822 100644 --- a/frontend/e2e-tests/output.spec.ts +++ b/frontend/e2e-tests/output.spec.ts @@ -1,8 +1,9 @@ -/* Copyright 2024 Marimo. All rights reserved. */ -import { test, expect } from "@playwright/test"; +/* Copyright 2026 Marimo. All rights reserved. */ + +import { fileURLToPath } from "node:url"; +import { expect, test } from "@playwright/test"; import { getAppUrl } from "../playwright.config"; import { takeScreenshot } from "./helper"; -import { fileURLToPath } from "node:url"; const _filename = fileURLToPath(import.meta.url); @@ -10,8 +11,8 @@ test("it can clear and append output", async ({ page }) => { const appUrl = getAppUrl("output.py//run"); await page.goto(appUrl); - // Test that Loading replaced exists at least once - await expect(page.getByText("Loading replace")).toBeVisible(); + // Flakey: Test that Loading replaced exists at least once + // await expect(page.getByText("Loading replace")).toBeVisible(); // Now wait for Replaced to be visible await expect(page.getByText("Replaced!")).toBeVisible(); // Test that Loading replaced does not exist diff --git a/frontend/e2e-tests/py/bad_button.py b/frontend/e2e-tests/py/bad_button.py index 38571d8b10e..3ea54faf54e 100644 --- a/frontend/e2e-tests/py/bad_button.py +++ b/frontend/e2e-tests/py/bad_button.py @@ -1,24 +1,29 @@ +# /// script +# [tool.marimo.runtime] +# auto_instantiate = true +# /// + import marimo -__generated_with = "0.0.1" +__generated_with = "0.17.6" app = marimo.App() @app.cell -def __(): +def _(): import marimo as mo - return mo, + return (mo,) @app.cell -def __(mo): - b = mo.ui.button(value=None, label='Bad button', on_click=lambda v: v + 1) +def _(mo): + b = mo.ui.button(value=None, label="Bad button", on_click=lambda v: v + 1) b - return b, + return (b,) @app.cell -def __(b): +def _(b): b.value return diff --git a/frontend/e2e-tests/py/bugs.py b/frontend/e2e-tests/py/bugs.py index a056e100815..3bea23bfc73 100644 --- a/frontend/e2e-tests/py/bugs.py +++ b/frontend/e2e-tests/py/bugs.py @@ -1,3 +1,7 @@ +# /// script +# [tool.marimo.runtime] +# auto_instantiate = true +# /// import marimo __generated_with = "0.0.6" diff --git a/frontend/e2e-tests/py/cells.py b/frontend/e2e-tests/py/cells.py index 6b1a98d3feb..1a74b4958b4 100644 --- a/frontend/e2e-tests/py/cells.py +++ b/frontend/e2e-tests/py/cells.py @@ -1,3 +1,7 @@ +# /// script +# [tool.marimo.runtime] +# auto_instantiate = true +# /// import marimo __generated_with = "0.0.5" diff --git a/frontend/e2e-tests/py/columns.py b/frontend/e2e-tests/py/columns.py new file mode 100644 index 00000000000..ceba2a9b19b --- /dev/null +++ b/frontend/e2e-tests/py/columns.py @@ -0,0 +1,62 @@ +# /// script +# [tool.marimo.runtime] +# auto_instantiate = true +# /// +import marimo + +__generated_with = "0.0.5" +app = marimo.App(width="columns") + + +@app.cell(column=0) +def __(deep_and_far_variable, far_away_variable): + # Two matches in this cell, so find-next must step through both before + # leaving the column. + usage_of_far_away = far_away_variable + 1 + another_usage = far_away_variable * 2 + usage_of_deep_and_far = deep_and_far_variable + 1 + return (another_usage, usage_of_deep_and_far, usage_of_far_away) + + +@app.cell(column=1) +def __(): + filler_one = "filler" + return (filler_one,) + + +@app.cell(column=2) +def __(): + filler_two = "filler" + return (filler_two,) + + +@app.cell(column=3) +def __(): + far_away_variable = 42 + return (far_away_variable,) + + +@app.cell(column=4) +def __(): + import marimo as mo + + return (mo,) + + +@app.cell(column=4) +def __(mo): + # Tall enough to push the cell below into needing a vertical scroll too + # (viewport height is 720px), unlike far_away_variable, which only ever + # needs a horizontal scroll. + mo.ui.text_area(value="filler", rows=80, full_width=True) + return + + +@app.cell(column=4) +def __(): + deep_and_far_variable = 99 + return (deep_and_far_variable,) + + +if __name__ == "__main__": + app.run() diff --git a/frontend/e2e-tests/py/components.py b/frontend/e2e-tests/py/components.py index 86674279b51..c4a35540cf7 100644 --- a/frontend/e2e-tests/py/components.py +++ b/frontend/e2e-tests/py/components.py @@ -1,3 +1,7 @@ +# /// script +# [tool.marimo.runtime] +# auto_instantiate = true +# /// import marimo __generated_with = "0.9.15" @@ -14,7 +18,7 @@ def __(mo): def __(basic_ui_elements, mo): mo.md( f"""### Basic elements - + {basic_ui_elements} """ ) @@ -75,8 +79,9 @@ def __(mo): @app.cell def __(mo): - file_button = lambda: mo.ui.file(kind="button") - file_area = lambda: mo.ui.file(kind="area") + file_button = lambda: mo.ui.file(kind="button", multiple=True) + file_area = lambda: mo.ui.file(kind="area", multiple=True) + file_area_single = lambda: mo.ui.file(kind="area", multiple=False) basic_ui_elements = mo.ui.dropdown( options=dict( @@ -88,6 +93,7 @@ def __(mo): "dropdown": mo.ui.dropdown, "file button": file_button, "file area": file_area, + "file area single": file_area_single, "multiselect": mo.ui.multiselect, "number": mo.ui.number, "radio": mo.ui.radio, @@ -100,11 +106,11 @@ def __(mo): ) ), ) - return basic_ui_elements, file_area, file_button + return basic_ui_elements, file_area, file_area_single, file_button @app.cell -def __(file_area, file_button, mo): +def __(file_area, file_area_single, file_button, mo): def construct_element(value): if value == mo.ui.array: return mo.ui.array([mo.ui.text(), mo.ui.slider(1, 10), mo.ui.date()]) @@ -156,6 +162,8 @@ def construct_element(value): return file_button() elif value == file_area: return file_area() + elif value == file_area_single: + return file_area_single() elif value == mo.ui.form: return mo.ui.text_area(placeholder="...").form() elif value == mo.ui.multiselect: @@ -199,7 +207,7 @@ def all_values_are_strings(values): if values is not None and isinstance(values, list): return all(isinstance(v, str) for v in values) - if element is not None: + if element is not None and hasattr(element, "value"): v = ( element.value if not isinstance(element, mo.ui.file) diff --git a/frontend/e2e-tests/py/disabled_cells.py b/frontend/e2e-tests/py/disabled_cells.py index 0caf00424c9..75d6165cc1f 100644 --- a/frontend/e2e-tests/py/disabled_cells.py +++ b/frontend/e2e-tests/py/disabled_cells.py @@ -1,3 +1,7 @@ +# /// script +# [tool.marimo.runtime] +# auto_instantiate = true +# /// import marimo __generated_with = "0.0.5" diff --git a/frontend/e2e-tests/py/kitchen_sink.py b/frontend/e2e-tests/py/kitchen_sink.py index 4ad45507d7d..cbb4773512a 100644 --- a/frontend/e2e-tests/py/kitchen_sink.py +++ b/frontend/e2e-tests/py/kitchen_sink.py @@ -1,11 +1,24 @@ +# /// script +# dependencies = [ +# "altair==6.0.0", +# "marimo>=0.19.4", +# "matplotlib==3.10.8", +# "numpy==2.4.1", +# "plotly==6.5.2", +# "vega-datasets==0.9.0", +# ] +# [tool.marimo.runtime] +# auto_instantiate = true +# /// + import marimo -__generated_with = "0.1.61" -app = marimo.App() +__generated_with = "0.19.6" +app = marimo.App(auto_download=["ipynb"]) @app.cell -def __(np, plt): +def _(np, plt): # Generate some random data categories = ["A", "B", "C", "D", "E"] values = np.random.rand(5) @@ -14,12 +27,11 @@ def __(np, plt): plt.title("Random Bar Chart") plt.xlabel("Categories") plt.ylabel("Values") - None - return bar, categories, values + return (bar,) @app.cell -def __(mo): +def _(mo): # options callout_kind = mo.ui.dropdown( label="Color", @@ -41,35 +53,73 @@ def __(mo): @app.cell -def __(alt, callout_kind, mo, office_characters, vega_datasets): +def _(alt, callout_kind, mo, office_characters, px, vega_datasets): options = ["Apples", "Oranges", "Pears"] - # inputs + # Basic inputs button = mo.ui.button(label="Click me") + run_button = mo.ui.run_button(label="Run computation") checkbox = mo.ui.checkbox(label="check me") - date = mo.ui.date(label="Start Date") + switch = mo.ui.switch(label="do not disturb") + + # Text inputs + text = mo.ui.text(placeholder="Search...", label="Filter") + text_area = mo.ui.text_area(placeholder="Search...", label="Description") + code_editor = mo.ui.code_editor( + value="def hello():\n return 'world'", + language="python", + min_height=100, + ) + + # Number inputs + number = mo.ui.number(start=1, stop=20, label="Number") + slider = mo.ui.slider(start=1, stop=20, label="Slider", value=3) + range_slider = mo.ui.range_slider( + start=0, stop=100, value=[20, 80], label="Range" + ) + + # Selection inputs dropdown = mo.ui.dropdown(options=options, value=options[0]) - file = mo.vstack([mo.ui.file(kind="button"), mo.ui.file(kind="area")]) multiselect = mo.ui.multiselect(options=options) - number = mo.ui.number(start=1, stop=20, label="Number") radio = mo.ui.radio(options=options) - slider = mo.ui.slider(start=1, stop=20, label="Slider", value=3) - switch = mo.ui.switch(label="do not disturb") - table = mo.ui.table(data=office_characters, pagination=True) - text_area = mo.ui.text_area(placeholder="Search...", label="Description") - text = mo.ui.text(placeholder="Search...", label="Filter") + + # Date/time inputs + date = mo.ui.date(label="Start Date") + date_range = mo.ui.date_range(label="Date Range") + datetime_picker = mo.ui.datetime(label="Date and Time") + + # File inputs + file = mo.vstack([mo.ui.file(kind="button"), mo.ui.file(kind="area")]) refresh = mo.ui.refresh(label="Refresh", options=["1s", "5s", "10s", "30s"]) microphone = mo.ui.microphone(label="Drop a beat!") + + # Data components + table = mo.ui.table(data=office_characters, pagination=True) + _cars_df = vega_datasets.data.cars() + dataframe_transformer = mo.ui.dataframe(_cars_df) + + # Chart components chart = mo.ui.altair_chart( - alt.Chart(vega_datasets.data.cars()) + alt.Chart(_cars_df) .mark_point() - .encode( - x="Horsepower", - y="Miles_per_Gallon", - color="Origin", - ) + .encode(x="Horsepower", y="Miles_per_Gallon", color="Origin") ) + figure = px.scatter( + _cars_df, + x="Horsepower", + y="Miles_per_Gallon", + color="Origin", + hover_data=["Name", "Horsepower", "Miles_per_Gallon", "Origin"], + title="Car Horsepower vs. Miles per Gallon", + labels={ + "Horsepower": "Horsepower", + "Miles_per_Gallon": "Miles per Gallon", + "Origin": "Origin", + }, + ) + plotly_chart = mo.ui.plotly(figure) + # form form = mo.ui.text_area(placeholder="...").form() @@ -84,7 +134,7 @@ def __(alt, callout_kind, mo, office_characters, vega_datasets): # status # TODO(akshayka): this is using an internal API since we don't expose the progress bar progress_bar = mo._plugins.stateless.status._progress.ProgressBar( - title=None, subtitle=None, total=10 + title=None, subtitle=None, total=10, show_rate=True, show_eta=True ) with mo.status.spinner(title="Hang tight!") as spinner: @@ -104,17 +154,23 @@ def __(alt, callout_kind, mo, office_characters, vega_datasets): callout, chart, checkbox, + code_editor, + dataframe_transformer, date, + date_range, + datetime_picker, dropdown, file, form, microphone, multiselect, number, - options, + plotly_chart, progress_bar, radio, + range_slider, refresh, + run_button, slider, spinner, stat, @@ -126,7 +182,7 @@ def __(alt, callout_kind, mo, office_characters, vega_datasets): @app.cell -def __(create_wrapper, mo): +def _(create_wrapper, mo): # array wish = mo.ui.text(placeholder="Wish") @@ -134,11 +190,11 @@ def __(create_wrapper, mo): mo.ui.array([wish] * 3, label="Three wishes"), "array", ) - return wish, + return @app.cell -def __(batch, create_wrapper, mo): +def _(batch, create_wrapper, mo): # batch create_wrapper( mo.hstack([batch, batch.value]), @@ -148,14 +204,14 @@ def __(batch, create_wrapper, mo): @app.cell -def __(create_wrapper, mo, refresh): +def _(create_wrapper, mo, refresh): # refresh create_wrapper(mo.hstack([refresh, refresh.value]), "refresh") return @app.cell -def __(button, create_wrapper, mo): +def _(button, create_wrapper, mo): # button create_wrapper( mo.hstack([button]), @@ -165,7 +221,17 @@ def __(button, create_wrapper, mo): @app.cell -def __(checkbox, create_wrapper, mo): +def _(create_wrapper, mo, run_button): + # run_button + create_wrapper( + mo.hstack([run_button, mo.md(f"Clicked: {run_button.value}")]), + "run_button", + ) + return + + +@app.cell +def _(checkbox, create_wrapper, mo): # checkbox create_wrapper( mo.hstack([checkbox, mo.md(f"Has value: {checkbox.value}")]), @@ -175,7 +241,7 @@ def __(checkbox, create_wrapper, mo): @app.cell -def __(create_wrapper, mo): +def _(create_wrapper, mo): # dictionary first_name = mo.ui.text(placeholder="First name") last_name = mo.ui.text(placeholder="Last name") @@ -191,11 +257,11 @@ def __(create_wrapper, mo): ), "dictionary", ) - return email, first_name, last_name + return @app.cell -def __(callout, callout_kind, create_wrapper, mo): +def _(callout, callout_kind, create_wrapper, mo): create_wrapper( mo.vstack([callout_kind, callout], align="stretch", gap=0), "callout", @@ -204,7 +270,7 @@ def __(callout, callout_kind, create_wrapper, mo): @app.cell -def __(create_wrapper, mo): +def _(create_wrapper, mo): create_wrapper( mo.md( f""" @@ -239,7 +305,7 @@ def __(create_wrapper, mo): @app.cell -def __(create_wrapper, mo): +def _(create_wrapper, mo): create_wrapper( mo.md( r""" @@ -256,7 +322,7 @@ def __(create_wrapper, mo): @app.cell -def __(create_wrapper, file): +def _(create_wrapper, file): create_wrapper( file, "file", @@ -265,7 +331,7 @@ def __(create_wrapper, file): @app.cell -def __(create_wrapper, mo, multiselect): +def _(create_wrapper, mo, multiselect): create_wrapper( mo.hstack([multiselect, mo.md(f"Has value: {multiselect.value}")]), "multiselect", @@ -274,7 +340,7 @@ def __(create_wrapper, mo, multiselect): @app.cell -def __(create_wrapper, dropdown, mo): +def _(create_wrapper, dropdown, mo): create_wrapper( mo.hstack([dropdown, mo.md(f"Has value: {dropdown.value}")]), "dropdown", @@ -283,7 +349,7 @@ def __(create_wrapper, dropdown, mo): @app.cell -def __(create_wrapper, date, mo): +def _(create_wrapper, date, mo): create_wrapper( mo.hstack([date, mo.md(f"Has value: {date.value}")]), "date", @@ -292,7 +358,27 @@ def __(create_wrapper, date, mo): @app.cell -def __(create_wrapper, mo, switch): +def _(create_wrapper, date_range, mo): + # date_range + create_wrapper( + mo.hstack([date_range, mo.md(f"Has value: {date_range.value}")]), + "date_range", + ) + return + + +@app.cell +def _(create_wrapper, datetime_picker, mo): + # datetime + create_wrapper( + mo.hstack([datetime_picker, mo.md(f"Has value: {datetime_picker.value}")]), + "datetime", + ) + return + + +@app.cell +def _(create_wrapper, mo, switch): create_wrapper( mo.hstack([switch, mo.md(f"Has value: {switch.value}")]), "switch", @@ -301,17 +387,25 @@ def __(create_wrapper, mo, switch): @app.cell -def __(create_wrapper, microphone, mo): +def _(create_wrapper, microphone, mo): # microphone - create_wrapper( - mo.hstack([microphone, mo.audio(microphone.value)]), + has_audio_content = len(microphone.value.getbuffer()) != 0 + create_wrapper( + mo.hstack( + [ + microphone, + mo.audio(microphone.value) + if has_audio_content + else mo.md("Waiting..."), + ] + ), "microphone", ) return @app.cell -def __(create_wrapper, mo, slider): +def _(create_wrapper, mo, slider): create_wrapper( mo.hstack([slider, mo.md(f"Has value: {slider.value}")]), "slider", @@ -320,7 +414,17 @@ def __(create_wrapper, mo, slider): @app.cell -def __(create_wrapper, mo): +def _(create_wrapper, mo, range_slider): + # range_slider + create_wrapper( + mo.hstack([range_slider, mo.md(f"Has value: {range_slider.value}")]), + "range_slider", + ) + return + + +@app.cell +def _(create_wrapper, mo): _src = "https://upload.wikimedia.org/wikipedia/commons/8/8c/Ivan_Ili%C4%87-Chopin_-_Prelude_no._1_in_C_major.ogg" create_wrapper( mo.audio(_src), @@ -330,7 +434,7 @@ def __(create_wrapper, mo): @app.cell -def __(create_wrapper, mo): +def _(create_wrapper, mo): create_wrapper( mo.pdf( src="https://arxiv.org/pdf/2104.00282.pdf", @@ -343,7 +447,7 @@ def __(create_wrapper, mo): @app.cell -def __(create_wrapper, mo): +def _(create_wrapper, mo): _src = ( "https://images.pexels.com/photos/86596/owl-bird-eyes-eagle-owl-86596.jpeg" ) @@ -355,7 +459,7 @@ def __(create_wrapper, mo): @app.cell -def __(create_wrapper, mo, number): +def _(create_wrapper, mo, number): create_wrapper( mo.hstack([number, mo.md(f"Has value: {number.value}")]), "number", @@ -364,16 +468,16 @@ def __(create_wrapper, mo, number): @app.cell -def __(mo): +def _(mo): def create_wrapper(element, key, code=""): return mo.vstack( [mo.md(f"## **{key.upper()}**"), element], align="stretch", gap=2 ) - return create_wrapper, + return (create_wrapper,) @app.cell -def __(create_wrapper, mo, text): +def _(create_wrapper, mo, text): create_wrapper( mo.hstack([text, mo.md(f"Has value: {text.value}")]), "text", @@ -382,7 +486,7 @@ def __(create_wrapper, mo, text): @app.cell -def __(create_wrapper, mo, text_area): +def _(create_wrapper, mo, text_area): create_wrapper( mo.hstack([text_area, mo.md(f"Has value: {text_area.value}")]), "text_area", @@ -391,7 +495,17 @@ def __(create_wrapper, mo, text_area): @app.cell -def __(create_wrapper, mo, radio): +def _(code_editor, create_wrapper, mo): + # code_editor + create_wrapper( + mo.vstack([code_editor, mo.md(f"```python\n{code_editor.value}\n```")]), + "code_editor", + ) + return + + +@app.cell +def _(create_wrapper, mo, radio): create_wrapper( mo.hstack([radio, mo.md(f"Has value: {radio.value}")]), "radio", @@ -400,7 +514,7 @@ def __(create_wrapper, mo, radio): @app.cell -def __(create_wrapper, form, mo): +def _(create_wrapper, form, mo): create_wrapper( mo.hstack([form, mo.md(f"Has value: {form.value}")]), "form", @@ -409,7 +523,7 @@ def __(create_wrapper, form, mo): @app.cell -def __(create_wrapper, mo): +def _(create_wrapper, mo): create_wrapper( mo.accordion( { @@ -427,7 +541,7 @@ def __(create_wrapper, mo): @app.cell -def __(bar, create_wrapper, mo): +def _(bar, create_wrapper, mo): create_wrapper( mo.ui.tabs( { @@ -441,7 +555,7 @@ def __(bar, create_wrapper, mo): @app.cell -def __(create_wrapper, mo): +def _(create_wrapper, mo): tree = mo.tree( [ "entry", @@ -455,11 +569,11 @@ def __(create_wrapper, mo): tree, "tree", ) - return tree, + return @app.cell -def __(align, boxes, create_wrapper, gap, justify, mo, wrap): +def _(align, boxes, create_wrapper, gap, justify, mo, wrap): horizontal = mo.hstack( boxes, align=align.value, @@ -488,11 +602,11 @@ def __(align, boxes, create_wrapper, gap, justify, mo, wrap): stacks, "stacks", ) - return horizontal, stacks, vertical + return @app.cell -def __(create_wrapper, table): +def _(create_wrapper, table): create_wrapper( table, "table", "mo.ui.table(data=office_characters, pagination=True)" ) @@ -500,7 +614,19 @@ def __(create_wrapper, table): @app.cell -def __(create_wrapper, spinner): +def _(create_wrapper, dataframe_transformer, mo): + # dataframe transformer + create_wrapper( + mo.vstack( + [dataframe_transformer, mo.ui.table(dataframe_transformer.value)] + ), + "dataframe_transformer", + ) + return + + +@app.cell +def _(create_wrapper, spinner): # spinner create_wrapper( spinner, @@ -510,7 +636,7 @@ def __(create_wrapper, spinner): @app.cell -def __(create_wrapper, progress_bar): +def _(create_wrapper, progress_bar): # progress bar create_wrapper( progress_bar, @@ -520,7 +646,7 @@ def __(create_wrapper, progress_bar): @app.cell -def __(create_wrapper, stat): +def _(create_wrapper, stat): create_wrapper( stat, "stat", @@ -529,7 +655,7 @@ def __(create_wrapper, stat): @app.cell -def __(chart, create_wrapper, mo): +def _(chart, create_wrapper, mo): create_wrapper( mo.vstack([chart, mo.ui.table(chart.value)]), "altair-chart", @@ -538,7 +664,263 @@ def __(chart, create_wrapper, mo): @app.cell -def __(mo): +def _(create_wrapper, mo, plotly_chart): + create_wrapper( + mo.vstack([plotly_chart, mo.ui.table(plotly_chart.value)]), "plotly-chart" + ) + return + + +@app.cell +def _(create_wrapper, mo): + # video + _video_src = "https://www.youtube.com/watch?v=5ZxczGlrkyQ" + create_wrapper( + mo.video(src=_video_src, width=400), + "video", + ) + return + + +@app.cell +def _(create_wrapper, mo): + # download button + data_to_download = "Hello, this is the file content!\nLine 2\nLine 3" + create_wrapper( + mo.download( + data=data_to_download.encode(), + filename="sample.txt", + label="Download Sample File", + ), + "download", + ) + return + + +@app.cell +def _(create_wrapper, mo): + # json display + sample_json = { + "name": "marimo", + "version": "0.19.6", + "features": ["reactive", "interactive", "reproducible"], + "nested": {"key1": "value1", "key2": [1, 2, 3]}, + } + create_wrapper( + mo.json(sample_json), + "json", + ) + return + + +@app.cell +def _(create_wrapper, mo): + # mermaid diagram + create_wrapper( + mo.mermaid( + """ + graph TD + A[Start] --> B{Decision} + B -->|Yes| C[Do Something] + B -->|No| D[Do Something Else] + C --> E[End] + D --> E + """ + ), + "mermaid", + ) + return + + +@app.cell +def _(create_wrapper, mo): + # icon + create_wrapper( + mo.hstack( + [ + mo.icon("lucide:home", size=24), + mo.icon("lucide:settings", size=24), + mo.icon("lucide:user", size=24), + mo.icon("lucide:star", size=24, color="gold"), + mo.icon("lucide:heart", size=24, color="red"), + ], + gap=1, + ), + "icon", + ) + return + + +@app.cell +def _(create_wrapper, mo): + # center, left, right alignment + create_wrapper( + mo.vstack( + [ + mo.left(mo.md("**Left aligned**")), + mo.center(mo.md("**Center aligned**")), + mo.right(mo.md("**Right aligned**")), + ], + align="stretch", + ), + "alignment (left/center/right)", + ) + return + + +@app.cell +def _(create_wrapper, mo): + # carousel + _images = [ + "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400", + "https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=400", + "https://images.unsplash.com/photo-1426604966848-d7adac402bff?w=400", + ] + create_wrapper( + mo.carousel( + [mo.image(src=img, width=300, rounded=True) for img in _images] + ), + "carousel", + ) + return + + +@app.cell +def _(create_wrapper, mo): + # Html component + create_wrapper( + mo.Html( + """ +
+

Custom HTML

+

This is raw HTML content with custom styling.

+
+ """ + ), + "Html", + ) + return + + +@app.cell +def _(create_wrapper, mo): + # style wrapper + styled_content = mo.md("This content has custom styling applied") + create_wrapper( + mo.style( + styled_content, + { + "background": "#f0f9ff", + "padding": "16px", + "border-radius": "8px", + "border": "2px solid #0ea5e9", + }, + ), + "style", + ) + return + + +@app.cell +def _(create_wrapper, mo): + # as_html conversion + class CustomObject: + def __repr__(self): + return "CustomObject(value=42)" + + + create_wrapper( + mo.as_html([1, 2, 3, {"key": "value"}]), + "as_html", + ) + return + + +@app.cell +def _(create_wrapper, mo): + # nav_menu + create_wrapper( + mo.nav_menu( + { + "#section1": "Section 1", + "#section2": "Section 2", + "Links": { + "https://marimo.io": "marimo.io", + "https://github.com/marimo-team/marimo": "GitHub", + }, + }, + orientation="horizontal", + ), + "nav_menu", + ) + return + + +@app.cell +def _(create_wrapper, mo): + # lazy loading + def expensive_component(): + return mo.md("This content was **lazily loaded**!") + + + create_wrapper( + mo.lazy(expensive_component), + "lazy", + ) + return + + +@app.cell +def _(create_wrapper, mo): + # show_code - display code with syntax highlighting + code_sample = ''' + def fibonacci(n): + """Calculate the nth Fibonacci number.""" + if n <= 1: + return n + return fibonacci(n-1) + fibonacci(n-2) + + # Usage + result = fibonacci(10) + print(f"The 10th Fibonacci number is {result}") + ''' + create_wrapper( + mo.show_code(code_sample), + "show_code", + ) + return + + +@app.cell +def _(create_wrapper, mo): + # chat component (without model, just UI) + def simple_chat_model(messages, config): + # Echo bot for demonstration + return f"You said: {messages[-1].content}" + + + chat = mo.ui.chat(simple_chat_model, prompts=["Hello!", "How are you?"]) + create_wrapper(chat, "chat") + return + + +@app.cell +def _(mo): + # sidebar example + mo.sidebar( + mo.vstack( + [ + mo.md("# Menu"), + mo.ui.button(label="Home"), + mo.ui.button(label="Settings"), + ] + ) + ) + return + + +@app.cell +def _(mo): def create_box(num=1): box_size = 30 + num * 10 return mo.Html( @@ -547,11 +929,11 @@ def create_box(num=1): boxes = [create_box(i) for i in range(1, 5)] - return boxes, create_box + return (boxes,) @app.cell -def __(): +def _(): office_characters = [ {"first_name": "Michael", "last_name": "Scott"}, {"first_name": "Jim", "last_name": "Halpert"}, @@ -574,17 +956,18 @@ def __(): {"first_name": "David", "last_name": "Wallace"}, {"first_name": "Holly", "last_name": "Flax"}, ] - return office_characters, + return (office_characters,) @app.cell -def __(): +def _(): import altair as alt - import vega_datasets import marimo as mo import matplotlib.pyplot as plt import numpy as np - return alt, mo, np, plt, vega_datasets + import plotly.express as px + import vega_datasets + return alt, mo, np, plt, px, vega_datasets if __name__ == "__main__": diff --git a/frontend/e2e-tests/py/layout_grid.py b/frontend/e2e-tests/py/layout_grid.py index 80fd98e4b11..28412c43d99 100644 --- a/frontend/e2e-tests/py/layout_grid.py +++ b/frontend/e2e-tests/py/layout_grid.py @@ -1,42 +1,46 @@ +# /// script +# [tool.marimo.runtime] +# auto_instantiate = true +# /// import marimo -__generated_with = "0.9.14" +__generated_with = "0.13.15" app = marimo.App(layout_file="layouts/layout_grid.grid.json") @app.cell -def __(mo): +def _(mo): mo.md("""# Grid Layout""") return @app.cell -def __(mo, search): - mo.md(f"Searching {search.value}") +def _(mo, search): + mo.md(f"""Searching {search.value}""") return @app.cell -def __(mo): +def _(mo): search = mo.ui.text(label="Search") search return (search,) @app.cell -def __(mo): +def _(mo): mo.md("""text 1""") return @app.cell -def __(mo): +def _(mo): mo.md("""text 2""") return @app.cell -def __(): +def _(): import marimo as mo return (mo,) diff --git a/frontend/e2e-tests/py/layout_grid_max_width.py b/frontend/e2e-tests/py/layout_grid_max_width.py index c5c4fabe167..2177fe0a21a 100644 --- a/frontend/e2e-tests/py/layout_grid_max_width.py +++ b/frontend/e2e-tests/py/layout_grid_max_width.py @@ -1,3 +1,7 @@ +# /// script +# [tool.marimo.runtime] +# auto_instantiate = true +# /// import marimo __generated_with = "0.1.50" diff --git a/frontend/e2e-tests/py/layout_grid_with_sidebar.py b/frontend/e2e-tests/py/layout_grid_with_sidebar.py new file mode 100644 index 00000000000..d720a92ee0f --- /dev/null +++ b/frontend/e2e-tests/py/layout_grid_with_sidebar.py @@ -0,0 +1,62 @@ +# /// script +# [tool.marimo.runtime] +# auto_instantiate = true +# /// + +import marimo + +__generated_with = "0.18.0" +app = marimo.App(layout_file="layouts/layout_grid_with_sidebar.grid.json") + + +@app.cell +def _(mo): + mo.md(""" + # Grid with Sidebar + """) + return + + +@app.cell +def _(mo): + mo.md(""" + ## Main Content Area + """) + return + + +@app.cell +def _(mo): + mo.md(""" + ### More content here + """) + return + + +@app.cell +def _(mo): + mo.sidebar( + [ + mo.md("# Sidebar Title"), + mo.md("This sidebar should be visible in run mode"), + mo.nav_menu( + { + "#section1": "Section 1", + "#section2": "Section 2", + "#section3": "Section 3", + }, + orientation="vertical", + ), + ] + ) + return + + +@app.cell +def _(): + import marimo as mo + return (mo,) + + +if __name__ == "__main__": + app.run() diff --git a/frontend/e2e-tests/py/layouts/layout_grid_with_sidebar.grid.json b/frontend/e2e-tests/py/layouts/layout_grid_with_sidebar.grid.json new file mode 100644 index 00000000000..25af801f08f --- /dev/null +++ b/frontend/e2e-tests/py/layouts/layout_grid_with_sidebar.grid.json @@ -0,0 +1,26 @@ +{ + "type": "grid", + "data": { + "columns": 24, + "rowHeight": 20, + "maxWidth": null, + "bordered": true, + "cells": [ + { + "position": [1, 3, 20, 4] + }, + { + "position": [1, 8, 20, 6] + }, + { + "position": [1, 15, 20, 4] + }, + { + "position": null + }, + { + "position": null + } + ] + } +} diff --git a/frontend/e2e-tests/py/layouts/slides.slides.json b/frontend/e2e-tests/py/layouts/slides.slides.json new file mode 100644 index 00000000000..3bcd0f545a9 --- /dev/null +++ b/frontend/e2e-tests/py/layouts/slides.slides.json @@ -0,0 +1,4 @@ +{ + "type": "slides", + "data": {} +} diff --git a/frontend/e2e-tests/py/nav_menu.py b/frontend/e2e-tests/py/nav_menu.py new file mode 100644 index 00000000000..8c3ef672c00 --- /dev/null +++ b/frontend/e2e-tests/py/nav_menu.py @@ -0,0 +1,37 @@ +import marimo + +__generated_with = "0.0.0" +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell +def _(mo): + # A horizontal nav menu with a nested group. The group's dropdown is + # portaled out of the cell's overflow container; this exercises the + # clipping/positioning fix in NavigationMenuPlugin. + mo.nav_menu( + { + "#section1": "Section 1", + "#section2": "Section 2", + "Links": { + "https://marimo.io": "marimo.io", + "https://github.com/marimo-team/marimo": { + "label": "GitHub", + "description": "GitHub repository", + }, + }, + }, + orientation="horizontal", + ) + return + + +if __name__ == "__main__": + app.run() diff --git a/frontend/e2e-tests/py/output.py b/frontend/e2e-tests/py/output.py index 3c3fdf0dc2e..fc8abe4b1a3 100644 --- a/frontend/e2e-tests/py/output.py +++ b/frontend/e2e-tests/py/output.py @@ -1,3 +1,7 @@ +# /// script +# [tool.marimo.runtime] +# auto_instantiate = true +# /// import marimo __generated_with = "0.1.19" diff --git a/frontend/e2e-tests/py/shutdown.py b/frontend/e2e-tests/py/shutdown.py index 8b4eed19f5f..456e87c2687 100644 --- a/frontend/e2e-tests/py/shutdown.py +++ b/frontend/e2e-tests/py/shutdown.py @@ -1,3 +1,9 @@ +# /// script +# [tool.marimo.runtime] +# auto_instantiate = true +# [tool.marimo.save] +# autosave = "off" +# /// import marimo __generated_with = "0.1.88" diff --git a/frontend/e2e-tests/py/slides.py b/frontend/e2e-tests/py/slides.py new file mode 100644 index 00000000000..b1d2740b497 --- /dev/null +++ b/frontend/e2e-tests/py/slides.py @@ -0,0 +1,35 @@ +# /// script +# [tool.marimo.runtime] +# auto_instantiate = true +# /// + +import marimo + +__generated_with = "0.19.4" +app = marimo.App(layout_file="layouts/slides.slides.json") + + +@app.cell +def _(): + import marimo as mo + return (mo,) + + +@app.cell +def _(mo): + mo.md(""" + # Slides! + """) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + We all love slides don't we 🎀 + """) + return + + +if __name__ == "__main__": + app.run() diff --git a/frontend/e2e-tests/py/stdin.py b/frontend/e2e-tests/py/stdin.py index 64d2067beb8..7979bb8dab2 100644 --- a/frontend/e2e-tests/py/stdin.py +++ b/frontend/e2e-tests/py/stdin.py @@ -1,26 +1,29 @@ -# Copyright 2024 Marimo. All rights reserved. +# /// script +# [tool.marimo.runtime] +# auto_instantiate = true +# /// import marimo -__generated_with = "0.1.77" +__generated_with = "0.13.15" app = marimo.App() @app.cell -def __(): +def _(): import marimo as mo return (mo,) @app.cell -def __(): +def _(): value = input("what is your name?") return (value,) @app.cell -def __(mo, value): - mo.md(f"## 👋 Hi {value}") +def _(mo, value): + mo.md(f"""## 👋 Hi {value}""") return diff --git a/frontend/e2e-tests/py/streams.py b/frontend/e2e-tests/py/streams.py index fc9fc570c67..28928883682 100644 --- a/frontend/e2e-tests/py/streams.py +++ b/frontend/e2e-tests/py/streams.py @@ -1,3 +1,7 @@ +# /// script +# [tool.marimo.runtime] +# auto_instantiate = true +# /// import marimo __generated_with = "0.0.1" diff --git a/frontend/e2e-tests/py/title.py b/frontend/e2e-tests/py/title.py index 6b038e93da6..b97d5a97ab8 100644 --- a/frontend/e2e-tests/py/title.py +++ b/frontend/e2e-tests/py/title.py @@ -1,5 +1,10 @@ +# /// script +# [tool.marimo.runtime] +# auto_instantiate = true +# /// import marimo + __generated_with = "0.0.1" app = marimo.App() diff --git a/frontend/e2e-tests/screenshots/slides.0.png b/frontend/e2e-tests/screenshots/slides.0.png new file mode 100644 index 00000000000..608891a6261 Binary files /dev/null and b/frontend/e2e-tests/screenshots/slides.0.png differ diff --git a/frontend/e2e-tests/screenshots/slides.1.png b/frontend/e2e-tests/screenshots/slides.1.png new file mode 100644 index 00000000000..21cbcd15db7 Binary files /dev/null and b/frontend/e2e-tests/screenshots/slides.1.png differ diff --git a/frontend/e2e-tests/shutdown.spec.ts b/frontend/e2e-tests/shutdown.spec.ts index 7ba3bd79e1c..97e70add891 100644 --- a/frontend/e2e-tests/shutdown.spec.ts +++ b/frontend/e2e-tests/shutdown.spec.ts @@ -1,8 +1,9 @@ -/* Copyright 2024 Marimo. All rights reserved. */ -import { test, expect } from "@playwright/test"; +/* Copyright 2026 Marimo. All rights reserved. */ + +import { fileURLToPath } from "node:url"; +import { expect, test } from "@playwright/test"; import { getAppUrl, startServer } from "../playwright.config"; import { takeScreenshot } from "./helper"; -import { fileURLToPath } from "node:url"; const _filename = fileURLToPath(import.meta.url); @@ -10,14 +11,14 @@ test("can resume a session", async ({ page }) => { const appUrl = getAppUrl("shutdown.py"); await page.goto(appUrl); - await expect(page.getByText("None", { exact: true })).toBeVisible(); + await expect(page.getByText("'None'", { exact: true })).toBeVisible(); // type in the form await page.locator("#output-Hbol").getByRole("textbox").fill("12345"); // shift enter to run the form await page.keyboard.press("Meta+Enter"); // wait for the output to appear - let secondCell = await page.locator(".Cell").nth(1); + let secondCell = await page.locator(".marimo-cell").nth(1); await expect(secondCell.getByText("12345")).toBeVisible(); await expect(secondCell.getByText("54321")).toBeVisible(); @@ -27,7 +28,7 @@ test("can resume a session", async ({ page }) => { await expect( page.getByText("You have reconnected to an existing session."), ).toBeVisible(); - secondCell = await page.locator(".Cell").nth(1); + secondCell = await page.locator(".marimo-cell").nth(1); await expect(page.getByText("12345")).toBeVisible(); await expect(page.getByText("54321")).toBeVisible(); }); @@ -36,32 +37,43 @@ test("restart kernel", async ({ page }) => { const appUrl = getAppUrl("shutdown.py"); await page.goto(appUrl); + // Wait for page to be fully loaded + await page.waitForLoadState("networkidle"); + await page.getByTestId("notebook-menu-dropdown").click(); - await page.getByText("Restart kernel").click(); - await page.getByLabel("Confirm Restart").click(); + // Wait for dropdown to be visible and stable + await page.waitForTimeout(100); + + const restartButton = page.getByRole("menuitem", { name: "Restart kernel" }); + await restartButton.waitFor({ state: "visible" }); + await restartButton.click(); - await expect(page.getByText("None", { exact: true })).toBeVisible(); + const confirmButton = page.getByRole("button", { name: "Confirm Restart" }); + await confirmButton.waitFor({ state: "visible" }); + await confirmButton.click(); + + await expect(page.getByText("'None'", { exact: true })).toBeVisible(); }); test("shutdown shows disconnected text", async ({ page }) => { const appUrl = getAppUrl("shutdown.py"); await page.goto(appUrl); + + // make changes without saving + await page + .getByRole("textbox") + .filter({ hasText: "import marimo" }) + .locator("div") + .nth(1) + .fill("1234"); + + // shutdown and confirm await page.getByRole("button", { name: "Shutdown" }).click(); - // confirm shutdown on modal await page.getByRole("button", { name: "Confirm Shutdown" }).click(); // kernel disconnected message to be on the page await expect(page.getByText("kernel not found")).toBeVisible(); - // when no unsaved changes, recovery modal should not be shown - await page.getByRole("button", { name: "Save" }).click(); - await expect(page.getByText("Download unsaved changes?")).toHaveCount(0); - - // when changes are made, recovery modal should be shown - await page - .getByRole("textbox") - .filter({ hasText: "import marimo" }) - .fill("1234"); await page.getByRole("button", { name: "Save" }).click(); await expect(page.getByText("Download unsaved changes?")).toHaveCount(1); diff --git a/frontend/e2e-tests/slides.spec.ts b/frontend/e2e-tests/slides.spec.ts new file mode 100644 index 00000000000..8eb70fa4b29 --- /dev/null +++ b/frontend/e2e-tests/slides.spec.ts @@ -0,0 +1,81 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import { fileURLToPath } from "node:url"; +import { expect, test } from "@playwright/test"; +import { getAppUrl } from "../playwright.config"; +import { openCommandPalette, takeScreenshot } from "./helper"; +import { waitForMarimoApp } from "./test-utils"; + +const __filename = fileURLToPath(import.meta.url); + +const appUrl = getAppUrl("slides.py"); +test.beforeEach(async ({ page }, info) => { + await page.goto(appUrl); + if (info.retry) { + await page.reload(); + } + + // Wait for cells to appear + await waitForMarimoApp(page); +}); + +test("slides", async ({ page }) => { + await expect(page.getByRole("heading", { name: "Slides!" })).toBeVisible(); + + await openCommandPalette({ page, command: "Present as Slides" }); + + // Wait for slides mode - reveal.js adds .reveal class + const slidesContainer = page.locator(".reveal.mo-slides-theme"); + await expect(slidesContainer).toBeVisible(); + + await takeScreenshot(page, __filename); + + // Reveal.js marks the active slide
with `.present`. It only sets + // `data-index-h` in overview / scroll modes, so we identify each slide + // positionally via `.slides > section` and assert which one is active. + const slides = slidesContainer.locator(".slides > section"); + await expect(slides).toHaveCount(2); + await expect(slides.nth(0)).toHaveClass(/present/); + + // Click the deck so DOM focus lands on it (the wrapper is `tabindex="-1"`). + // Without this, focus stays on whatever the command palette dialog + // restored it to (often the speaker-notes textarea), which makes reveal + // ignore the arrow keys. + await slidesContainer.click(); + await expect + .poll(() => + slidesContainer.evaluate((el) => document.activeElement === el), + ) + .toBe(true); + + await page.keyboard.press("ArrowRight"); + await expect(slides.nth(1)).toHaveClass(/present/); + + await takeScreenshot(page, __filename); + + await page.keyboard.press("ArrowLeft"); + await expect(slides.nth(0)).toHaveClass(/present/); +}); + +test("slides fullscreen", async ({ page }) => { + await openCommandPalette({ page, command: "Present as Slides" }); + + // Wait for slides mode - reveal.js adds .reveal class + const slidesContainer = page.locator(".reveal.mo-slides-theme"); + await expect(slidesContainer).toBeVisible(); + + // Fullscreen button is hidden until hover + const fullscreenButton = page.getByTestId("marimo-plugin-slides-fullscreen"); + const slidesWrapper = slidesContainer.locator(".."); + await slidesWrapper.hover(); + await expect(fullscreenButton).toBeVisible(); + + // Enter fullscreen + await fullscreenButton.click(); + + // Exit fullscreen with Escape + await page.keyboard.press("Escape"); + + // Slides container should still be visible after exiting fullscreen + await expect(slidesContainer).toBeVisible(); +}); diff --git a/frontend/e2e-tests/stdin.spec.ts b/frontend/e2e-tests/stdin.spec.ts index 2243ec56c1c..2b67c661fee 100644 --- a/frontend/e2e-tests/stdin.spec.ts +++ b/frontend/e2e-tests/stdin.spec.ts @@ -1,5 +1,5 @@ -/* Copyright 2024 Marimo. All rights reserved. */ -import { test, expect } from "@playwright/test"; +/* Copyright 2026 Marimo. All rights reserved. */ +import { expect, test } from "@playwright/test"; import { getAppUrl } from "../playwright.config"; const appUrl = getAppUrl("stdin.py"); diff --git a/frontend/e2e-tests/streams.spec.ts b/frontend/e2e-tests/streams.spec.ts index 07c0a46a870..4786c498fdf 100644 --- a/frontend/e2e-tests/streams.spec.ts +++ b/frontend/e2e-tests/streams.spec.ts @@ -1,5 +1,5 @@ -/* Copyright 2024 Marimo. All rights reserved. */ -import { test, expect } from "@playwright/test"; +/* Copyright 2026 Marimo. All rights reserved. */ +import { expect, test } from "@playwright/test"; import { getAppUrl } from "../playwright.config"; const appUrl = getAppUrl("streams.py"); diff --git a/frontend/e2e-tests/test-utils.ts b/frontend/e2e-tests/test-utils.ts new file mode 100644 index 00000000000..5037d6c0e0e --- /dev/null +++ b/frontend/e2e-tests/test-utils.ts @@ -0,0 +1,130 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import type { Locator, Page } from "@playwright/test"; + +/** + * Wait for a marimo app to be fully loaded and ready + */ +export async function waitForMarimoApp( + page: Page, + timeout = 30_000, +): Promise { + await page.waitForLoadState("networkidle", { timeout }); + + // Wait for the marimo app to be initialized + await page.waitForFunction( + () => { + // Check if the app is loaded by looking for key elements + return ( + document.querySelector("[data-testid='cell-editor']") !== null || + document.querySelector(".marimo-cell") !== null || + document.querySelector("[data-testid='marimo-static']") !== null + ); + }, + { timeout }, + ); +} + +/** + * Wait for server to be responsive with retries + */ +export async function waitForServerReady( + page: Page, + url: string, + maxRetries = 5, +): Promise { + let retries = 0; + + while (retries < maxRetries) { + try { + await page.goto(url, { + waitUntil: "networkidle", + timeout: 10_000, + }); + + // Additional check to ensure the page is actually loaded + await waitForMarimoApp(page); + return; + } catch (error) { + retries++; + if (retries === maxRetries) { + throw new Error( + `Server not ready after ${maxRetries} retries: ${error}`, + ); + } + + console.log(`Server not ready, retrying... (${retries}/${maxRetries})`); + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } +} + +/** + * Robust element interaction with retry + */ +export async function clickWithRetry( + page: Page, + selector: string, + maxRetries = 3, + timeout = 5000, +): Promise { + let element: Locator | undefined; + for (let i = 0; i < maxRetries; i++) { + try { + element = page.locator(selector); + await element.waitFor({ state: "visible", timeout }); + break; + } catch (error) { + if (i === maxRetries - 1) { + throw error; + } + await page.waitForTimeout(1000); + } + } + if (!element) { + throw new Error(`Element not found: ${selector}`); + } + await element.click({ timeout }); +} + +export async function hoverWithRetry( + page: Page, + selector: string, + maxRetries = 3, + timeout = 5000, +): Promise { + let element: Locator | undefined; + for (let i = 0; i < maxRetries; i++) { + try { + element = page.locator(selector); + await element.waitFor({ state: "visible", timeout }); + break; + } catch (error) { + if (i === maxRetries - 1) { + throw error; + } + await page.waitForTimeout(1000); + } + } + if (!element) { + throw new Error(`Element not found: ${selector}`); + } + await element.hover({ timeout }); +} + +/** + * Safe page navigation with fallback + */ +export async function safeGoto( + page: Page, + url: string, + timeout = 30_000, +): Promise { + try { + await page.goto(url, { waitUntil: "networkidle", timeout }); + } catch (error) { + // Fallback to basic load + console.log(`Network idle failed, trying basic load: ${error}`); + await page.goto(url, { waitUntil: "load", timeout }); + } +} diff --git a/frontend/e2e-tests/toggle-cell-language.spec.ts b/frontend/e2e-tests/toggle-cell-language.spec.ts index f8964896e4d..3380a05040f 100644 --- a/frontend/e2e-tests/toggle-cell-language.spec.ts +++ b/frontend/e2e-tests/toggle-cell-language.spec.ts @@ -1,7 +1,7 @@ -/* Copyright 2024 Marimo. All rights reserved. */ -import { test, expect } from "@playwright/test"; +/* Copyright 2026 Marimo. All rights reserved. */ +import { expect, test } from "@playwright/test"; import { getAppUrl, resetFile } from "../playwright.config"; -import { openCellActions } from "./helper"; +import { maybeRestartKernel, openCellActions } from "./helper"; const appUrl = getAppUrl("title.py"); @@ -10,6 +10,7 @@ test.beforeEach(async ({ page }, info) => { if (info.retry) { await page.reload(); } + await maybeRestartKernel(page); }); test.afterEach(async () => { @@ -28,24 +29,22 @@ test("change the cell to a markdown cell and toggle hide code", async ({ await page.getByText("Convert to Markdown").click(); await expect(title).toBeVisible(); - // Verify markdown content - const markdown = page.getByText("import marimo as mo"); - await expect(markdown).toBeVisible(); + // Code stays visible while the cell is focused; blur it so the markdown + // initial hide-code takes effect before asserting. + await page.keyboard.press("Escape"); + await page.locator("body").click({ position: { x: 4, y: 4 } }); - // Hide code - await openCellActions(page, title); - await page.getByText("Hide code").click(); - await expect(title).toBeVisible(); - - // Verify code editor is hidden + // Expect cell editor to be invisible at first (markdown initial hide code is true) const cellEditor = page.getByTestId("cell-editor"); await expect(cellEditor).toBeHidden(); // Unhide code await openCellActions(page, title); await page.getByText("Show code").click(); - await expect(title).toBeVisible(); - - // Verify code editor is visible await expect(cellEditor).toBeVisible(); + + // Hide code + await openCellActions(page, title); + await page.getByText("Hide code").click(); + await expect(cellEditor).toBeHidden(); }); diff --git a/frontend/e2e-tests/tsconfig.json b/frontend/e2e-tests/tsconfig.json index 25dcabd63b3..0bf959ffb16 100644 --- a/frontend/e2e-tests/tsconfig.json +++ b/frontend/e2e-tests/tsconfig.json @@ -1,20 +1,15 @@ { + "extends": "../tsconfig.json", "compilerOptions": { - "lib": ["ES2022", "DOM"], - "noEmit": true, - "module": "ESNext", + "types": ["node", "@playwright/test"], "moduleResolution": "node", - "types": ["@types/node"], + "allowSyntheticDefaultImports": true, "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "noImplicitAny": true, - "noImplicitThis": true, - "noUnusedLocals": true, - "noFallthroughCasesInSwitch": true, - "noImplicitOverride": true, - "useUnknownInCatchVariables": true, - "skipLibCheck": true + "skipLibCheck": true, + "paths": { + "@/*": ["../src/*"] + } }, - "include": ["*"] + "include": ["**/*.ts", "**/*.spec.ts", "../playwright.config.ts"], + "exclude": ["node_modules", "playwright-report", "test-results"] } diff --git a/frontend/index.html b/frontend/index.html index e77f1710e50..b769b836716 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,12 +8,12 @@ - - - - - - + + + + + + @@ -23,18 +23,22 @@ - - - - - + + + + + {{ title }}
+ diff --git a/frontend/islands/__demo__/index.html b/frontend/islands/__demo__/index.html index 8f5c93d3a26..c6ad26f6840 100644 --- a/frontend/islands/__demo__/index.html +++ b/frontend/islands/__demo__/index.html @@ -8,27 +8,26 @@ 🏝️ - - - - - - - - + + + + + + + + + + + """ + elif mode == "local": + return """ + + + """ + else: + return f""" + {generator.render_head()}""" - stubs = [ - # Basic + +def create_examples() -> list[tuple[str, list[Any]]]: + """Create organized examples showcasing marimo islands features.""" + examples: list[tuple[str, list[Any]]] = [] + + # ============================================================================ + # Getting Started + # ============================================================================ + examples.append(("Getting Started", [ generator.add_code("import marimo as mo"), - generator.add_code("mo.md('Hello, islands!')"), + generator.add_code( + """ + mo.md( + \"\"\" + # 🏝️ marimo Islands Demo - # Slider + Welcome! This page demonstrates marimo islands - interactive Python + code that runs in your browser. Each island is independent and reactive. + \"\"\" + ) + """ + ), + ])) + + # ============================================================================ + # Basic UI Components + # ============================================================================ + examples.append(("Basic UI Components", [ + generator.add_code( + """ + mo.md("## Sliders") + """ + ), generator.add_code( """ - slider = mo.ui.slider(0, 100, 2) - slider - """ + slider = mo.ui.slider(0, 100, value=50, label="Value") + slider + """ ), generator.add_code( """ - mo.md(f"Slider value: {slider.value}") - """ + mo.md(f"**Current value:** {slider.value}") + """ ), - # display_code=True - generator.add_code(""" - mo.md("We can also show the island code!") - """, display_code=True), + generator.add_code( + """ + mo.md("## Buttons") + """ + ), + generator.add_code( + """ + counter = mo.ui.button( + value=0, + label="Click me!", + on_click=lambda v: v + 1 + ) + counter + """ + ), + generator.add_code( + """ + mo.md(f"**Button clicked {counter.value} times**") + """ + ), - # is_reactive=False - generator.add_code(""" - # Also run expensive outputs without performing them in the browser - import matplotlib.pyplot as plt - import numpy as np - x = np.linspace(0, 2*np.pi, 100) - y = np.sin(x) - plt.plot(x, y) - plt.gca() - """, display_code=True, is_reactive=False), + generator.add_code( + """ + mo.md("## Text Inputs") + """ + ), + generator.add_code( + """ + name_input = mo.ui.text(value="", placeholder="Enter your name") + name_input + """ + ), + generator.add_code( + """ + mo.md(f"**Hello, {name_input.value or 'stranger'}!**") + """ + ), - # Error generator.add_code( - """ - import idk_package - "Should raise an error" - """ + """ + mo.md("## Dropdowns") + """ + ), + generator.add_code( + """ + color = mo.ui.dropdown( + options=["red", "blue", "green", "yellow"], + value="blue", + label="Choose a color" + ) + color + """ + ), + generator.add_code( + """ + mo.md(f"**Selected color:** {color.value}") + """ ), - # Markdown generator.add_code( """ - mo.md( - \"\"\" - # Hello, Markdown! + mo.md("## Checkboxes & Switches") + """ + ), + generator.add_code( + """ + checkbox = mo.ui.checkbox(label="Enable feature", value=True) + switch = mo.ui.switch(label="Dark mode", value=False) - Use marimo's "`md`" function to embed rich text into your marimo - apps. This function compiles Markdown into HTML that marimo - can display. + mo.hstack([checkbox, switch], justify="start", gap=2) + """ + ), + generator.add_code( + """ + mo.md( + f\"\"\" + - Checkbox: **{'✓' if checkbox.value else '✗'}** + - Switch: **{'ON' if switch.value else 'OFF'}** + \"\"\" + ) + """ + ), - For example, here's the code that rendered the above title and - paragraph: + generator.add_code( + """ + mo.md("## Radio Buttons") + """ + ), + generator.add_code( + """ + size = mo.ui.radio( + options=["Small", "Medium", "Large"], + value="Medium", + label="Size" + ) + size + """ + ), + generator.add_code( + """ + mo.md(f"**Selected size:** {size.value}") + """ + ), - ```python3 - mo.md( - ''' - # Hello, Markdown! + generator.add_code( + """ + mo.md("## Number Inputs") + """ + ), + generator.add_code( + """ + number = mo.ui.number( + start=0, + stop=100, + step=5, + value=25, + label="Quantity" + ) + number + """ + ), + generator.add_code( + """ + mo.md(f"**Quantity:** {number.value}") + """ + ), + ])) - Use marimo's "`md`" function to embed rich text into your marimo - apps. This function compiles your Markdown into HTML that marimo - can display. - ''' + # ============================================================================ + # Advanced UI Components + # ============================================================================ + examples.append(("Advanced Components", [ + generator.add_code( + """ + mo.md("## Multiselect") + """ + ), + generator.add_code( + """ + fruits = mo.ui.multiselect( + options=["Apple", "Banana", "Cherry", "Date", "Elderberry"], + value=["Apple", "Banana"], + label="Select fruits" ) - ``` - \"\"\" - ) - """ + fruits + """ + ), + generator.add_code( + """ + mo.md(f"**Selected:** {', '.join(fruits.value) if fruits.value else 'None'}") + """ ), - # LaTeX generator.add_code( """ - mo.md( - r\"\"\" - ## LaTeX - You can embed LaTeX in Markdown. + mo.md("## Range Slider") + """ + ), + generator.add_code( + """ + price_range = mo.ui.range_slider( + start=0, + stop=1000, + value=[100, 500], + label="Price range" + ) + price_range + """ + ), + generator.add_code( + """ + mo.md(f"**Price range:** ${price_range.value[0]} - ${price_range.value[1]}") + """ + ), - For example, + generator.add_code( + """ + mo.md("## Tabs") + """ + ), + generator.add_code( + """ + tabs = mo.ui.tabs( + { + "Overview": mo.md("## Overview\\n\\nThis is the overview tab."), + "Details": mo.md("## Details\\n\\nThis is the details tab."), + "Settings": mo.md("## Settings\\n\\nThis is the settings tab."), + } + ) + tabs + """ + ), - ```python3 - mo.md(r'$f : \mathbf{R} \to \mathbf{R}$') - ``` + generator.add_code( + """ + mo.md("## Code Editor") + """ + ), + generator.add_code( + """ + code = mo.ui.code_editor( + value='print("Hello, marimo!")', + language="python", + label="Python code" + ) + code + """ + ), + generator.add_code( + """ + mo.md(f\"\"\" + **Code length:** {len(code.value)} characters + \"\"\") + """ + ), + ])) - renders $f : \mathbf{R} \to \mathbf{R}$, while + # ============================================================================ + # Data Display + # ============================================================================ + examples.append(("Data Display", [ + generator.add_code( + """ + mo.md("## Tables") + """ + ), + generator.add_code( + """ + import pandas as pd + + df = pd.DataFrame({ + "Name": ["Alice", "Bob", "Charlie"], + "Age": [25, 30, 35], + "City": ["New York", "London", "Tokyo"] + }) + + table = mo.ui.table(df, label="User Data") + table + """ + ), - ```python3 + generator.add_code( + """ + mo.md("## Markdown Rendering") + """ + ), + generator.add_code( + """ mo.md( - r''' - \[ - f: \mathbf{R} \to \mathbf{R} - \] - ''' + \"\"\" + ### Rich Text Support + + marimo supports **bold**, *italic*, and `code` formatting. + + - Lists work great + - With multiple items + - And nested sub-items + + > Blockquotes are also supported! + \"\"\" ) - ``` + """ + ), - renders the display math + generator.add_code( + """ + mo.md("## LaTeX Math") + """ + ), + generator.add_code( + """ + mo.md( + r\"\"\" + ### Mathematical Expressions + + Inline math: $E = mc^2$ - \[ - f: \mathbf{R} \to \mathbf{R}. - \] - \"\"\" - ) - """ + Display math: + + $$ + \\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi} + $$ + + Complex equations: + + $$ + \\nabla \\times \\mathbf{F} = \\left( \\frac{\\partial F_z}{\\partial y} - \\frac{\\partial F_y}{\\partial z} \\right) \\mathbf{i} + \\cdots + $$ + \"\"\" + ) + """ ), - ] + ])) - app = asyncio.run(generator.build()) + # ============================================================================ + # Layout & Composition + # ============================================================================ + examples.append(("Layout & Composition", [ + generator.add_code( + """ + mo.md("## Layout Components") + """ + ), + generator.add_code( + """ + x = mo.ui.slider(0, 10, value=5, label="X") + y = mo.ui.slider(0, 10, value=5, label="Y") - NEW_LINE = "\n" - output = f""" - - - - - - - - 🏝️ - - {generator.render_head()} - - - + mo.hstack([x, y], justify="start", gap=2) + """ + ), + generator.add_code( + """ + mo.md(f"**Position:** ({x.value}, {y.value})") + """ + ), + + generator.add_code( + """ + mo.md("## Forms") + """ + ), + generator.add_code( + """ + form = mo.ui.form( + mo.ui.dictionary( + { + "name": mo.ui.text(placeholder="Name"), + "email": mo.ui.text(placeholder="Email"), + "age": mo.ui.number(start=0, stop=120, value=25), + } + ), + submit_button_label="Submit" + ) + form + """ + ), + generator.add_code( + """ + if form.value: + v = form.value + mo.md( + f\"\"\" + **Form submitted!** + + - Name: {v['name']} + - Email: {v['email']} + - Age: {v['age']} + \"\"\" + ) + else: + mo.md("Fill out the form above and click submit.") + """ + ), + ])) - - - - + # ============================================================================ + # Island Features + # ============================================================================ + examples.append(("Island Features", [ + generator.add_code( + """ + mo.md("## Display Code") + """ + ), + generator.add_code( + """ + mo.md("You can show the code that generated this island!") + """, + display_code=True + ), - {dedent(NEW_LINE.join([stub.render() for stub in stubs]))} + generator.add_code( + """ + mo.md("## Non-Reactive Islands") + """ + ), + generator.add_code( + """ + mo.md( + \"\"\" + This island is non-reactive - it runs once and doesn't update + when other islands change. Perfect for expensive computations + or static content. + \"\"\" + ) + """, + is_reactive=False + ), + generator.add_code( + """ + mo.md("## Combined Features") + """ + ), + generator.add_code( + """ + # This island shows code AND is non-reactive + mo.md("Static content with code visible") + """, + display_code=True, + is_reactive=False + ), + ])) -
-
-
-
-
-
- this should not be affected by global tailwind styles + # ============================================================================ + # Error Handling + # ============================================================================ + examples.append(("Error Handling", [ + generator.add_code( + """ + mo.md("## Error Display") + """ + ), + generator.add_code( + """ + # This will raise an error + import nonexistent_module + "This won't execute" + """ + ), + ])) + + return examples + + +def render_section_divider(title: str) -> str: + """Render a section divider.""" + return f""" +
+
+

+ {title} +

-
-
- this should be affected by global tailwind styles + """ + + +def run(mode: str): + """Generate and print the HTML output.""" + examples = create_examples() + + # Build all islands + app = asyncio.run(generator.build()) + + script_tags = get_script_tags(mode) + + # Render all stubs + rendered_sections: list[str] = [] + for section_title, stubs in examples: + rendered_sections.append(render_section_divider(section_title)) + rendered_sections.extend([stub.render() for stub in stubs]) + + # Add Tailwind test section + tailwind_test = """ +
+
+

+ Tailwind CSS Isolation Test +

+
+ This should NOT be affected by global Tailwind styles
-
- this should be affected by global tailwind styles (dark) + This SHOULD be affected by global Tailwind styles
+
+
+
+ This SHOULD be affected by global Tailwind styles (dark mode) +
+
-
+ """ + + output = f""" + + + + + + + + 🏝️ marimo Islands Demo + + {script_tags} + + + + {dedent("".join(rendered_sections))} + + {tailwind_test} - - + + """ print(output) if __name__ == "__main__": - run() + run(os.environ.get("MODE", "cdn")) diff --git a/frontend/islands/validate.sh b/frontend/islands/validate.sh index d7f26cd9b73..65e1bee38c0 100755 --- a/frontend/islands/validate.sh +++ b/frontend/islands/validate.sh @@ -9,10 +9,10 @@ OUT_DIR=$(pwd)/dist echo "validating $OUT_DIR" -echo "[validate: no process.env variables in any of the js files]" -grep -R "process.env" $(pwd)/dist +echo "[validate: no process.env. variables in any of the js files]" +grep -R "process\.env\." $(pwd)/dist if [ $? -eq 0 ]; then - echo "process.env variables found in js files" + echo "process.env. variables found in js files" exit 1 fi diff --git a/frontend/islands/vite.config.mts b/frontend/islands/vite.config.mts index b017b19c261..60ee5f9a3cd 100644 --- a/frontend/islands/vite.config.mts +++ b/frontend/islands/vite.config.mts @@ -1,50 +1,119 @@ -/* Copyright 2024 Marimo. All rights reserved. */ -import { type Plugin, defineConfig } from "vite"; +/* Copyright 2026 Marimo. All rights reserved. */ + +import { execFile, execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import react from "@vitejs/plugin-react"; -import tsconfigPaths from "vite-tsconfig-paths"; +import { defineConfig, type Plugin } from "vite"; +import topLevelAwait from "vite-plugin-top-level-await"; +import wasm from "vite-plugin-wasm"; import packageJson from "../package.json"; +const execFileAsync = promisify(execFile); + +const dirname = path.dirname(fileURLToPath(import.meta.url)); + const htmlDevPlugin = (): Plugin => { - return { - apply: "serve", - name: "html-transform", - transformIndexHtml: async () => { + const generateHtml = async (): Promise => { + // Auto-regenerate HTML in dev mode by running the Python script + const scriptPath = path.resolve(dirname, "generate.py"); + try { + const { stdout } = await execFileAsync("uv", ["run", scriptPath], { + env: { ...process.env, MODE: "dev" }, + }); + return stdout; + } catch (error) { + console.error("Failed to generate demo HTML:", error); + // Fallback to existing file if generation fails const indexHtml = await fs.promises.readFile( - path.resolve(__dirname, "__demo__", "index.html"), + path.resolve(dirname, "__demo__", "index.html"), "utf-8", ); + const preamble = `\n + + + + `; + return `${preamble}\n${indexHtml}`; + } + }; - return `\n${indexHtml}`; + return { + apply: "serve", + name: "html-transform", + transformIndexHtml: async () => { + return await generateHtml(); + }, + // Watch the generate.py file and trigger HMR on changes + configureServer(server) { + const scriptPath = path.resolve(dirname, "generate.py"); + server.watcher.add(scriptPath); + server.watcher.on("change", (file) => { + if (file === scriptPath) { + console.log("Demo script changed, regenerating HTML..."); + server.ws.send({ + type: "full-reload", + path: "*", + }); + } + }); }, }; }; const ReactCompilerConfig = { - target: "18", + target: "19", }; +function getMarimoVersion(): string { + try { + return execFileSync("uv", ["run", "marimo", "--version"]).toString().trim(); + } catch { + return packageJson.version; + } +} + // https://vitejs.dev/config/ export default defineConfig({ resolve: { + tsconfigPaths: true, dedupe: ["react", "react-dom", "@emotion/react", "@emotion/cache"], + alias: [ + // Islands run read-only and never render the slide code editor, so + // swap `SlideCellView` for a no-op stub. This keeps CodeMirror, the + // Codeium adapter, and `@bufbuild/protobuf` out of the islands bundle + // (the latter contains a `process.env.BUF_BIGINT_DISABLE` literal + // that `islands/validate.sh` otherwise flags). + { + find: "@/components/slides/slide-cell-view", + replacement: path.resolve( + dirname, + "../src/core/islands/stubs/slide-cell-view.tsx", + ), + }, + ], + }, + experimental: { + enableNativePlugin: true, }, worker: { format: "es", - plugins: () => [tsconfigPaths()], }, define: { - "process.env": { - NODE_ENV: JSON.stringify(process.env.NODE_ENV), - }, - "import.meta.env.VITE_MARIMO_ISLANDS": JSON.stringify(true), - // Precedence: VITE_MARIMO_VERSION > package.json version > "latest" - "import.meta.env.VITE_MARIMO_VERSION": process.env.VITE_MARIMO_VERSION - ? JSON.stringify(process.env.VITE_MARIMO_VERSION) - : process.env.NODE_ENV === "production" - ? JSON.stringify(packageJson.version) - : JSON.stringify("latest"), + "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV), + "process.env.DEBUG": JSON.stringify(process.env.DEBUG ?? ""), + "process.env.LOG": JSON.stringify(""), + "process.env.VSCODE_TEXTMATE_DEBUG": JSON.stringify(false), + "process.env.NODE_DEBUG": JSON.stringify(false), + // Precedence: VITE_MARIMO_VERSION > uv run marimo --version > package.json + "import.meta.env.VITE_MARIMO_VERSION": JSON.stringify( + process.env.VITE_MARIMO_VERSION || getMarimoVersion(), + ), }, server: { headers: { @@ -59,23 +128,33 @@ export default defineConfig({ presets: ["@babel/preset-typescript"], plugins: [ ["@babel/plugin-proposal-decorators", { legacy: true }], - ["@babel/plugin-proposal-class-properties", { loose: true }], ["babel-plugin-react-compiler", ReactCompilerConfig], ], }, }), - tsconfigPaths(), + wasm(), + topLevelAwait(), ], build: { emptyOutDir: true, lib: { - entry: path.resolve(__dirname, "../src/core/islands/main.ts"), + entry: path.resolve(dirname, "../src/core/islands/main.ts"), formats: ["es"], }, - rollupOptions: { + rolldownOptions: { output: { // Remove hash from entry file name, so it's easier to import entryFileNames: "[name].js", + // Ensure CSS is output as style.css instead of frontend.css + assetFileNames: (assetInfo) => { + if ( + assetInfo.names.includes("frontend.css") || + assetInfo.names.includes("islands.css") + ) { + return "style.css"; + } + return assetInfo.names[0]; + }, }, }, }, diff --git a/frontend/lint/addEventListenerObject.grit b/frontend/lint/addEventListenerObject.grit new file mode 100644 index 00000000000..2f4da498a1d --- /dev/null +++ b/frontend/lint/addEventListenerObject.grit @@ -0,0 +1,7 @@ +`$receiver.addEventListener($event, $handler, $options)` where { + $options <: or { + `true`, + `false` + }, + register_diagnostic(span=$options, message="Use an object instead of a boolean for addEventListener options. Replace 'true' with '{ capture: true }' or 'false' with '{ capture: false }' (or omit entirely for false)", severity="error") +} diff --git a/frontend/lint/atomWithStorageArgs.grit b/frontend/lint/atomWithStorageArgs.grit new file mode 100644 index 00000000000..94c54480007 --- /dev/null +++ b/frontend/lint/atomWithStorageArgs.grit @@ -0,0 +1,8 @@ +// Enforce atomWithStorage to use at least 3 arguments +// This ensures the storage parameter is always explicitly provided +or { + `atomWithStorage($arg1, $arg2)`, + `atomWithStorage<$_>($arg1, $arg2)` +} where { + register_diagnostic(span=$arg1, message="atomWithStorage requires at least 3 arguments (key, defaultValue, storage). Provide the storage parameter explicitly.", severity="error") +} diff --git a/frontend/lint/marimo-plugin.js b/frontend/lint/marimo-plugin.js new file mode 100644 index 00000000000..dfc6263b08a --- /dev/null +++ b/frontend/lint/marimo-plugin.js @@ -0,0 +1,340 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +/** + * Custom oxlint plugin with marimo-specific lint rules. + * Replaces the Biome Grit plugins. + */ + +const addEventListenerObject = { + meta: { + type: "suggestion", + docs: { + description: + "Require object options instead of boolean for addEventListener", + }, + fixable: "code", + }, + createOnce(context) { + return { + CallExpression(node) { + if ( + node.callee.type !== "MemberExpression" || + node.callee.property.name !== "addEventListener" || + node.arguments.length < 3 + ) { + return; + } + const options = node.arguments[2]; + if (options.type === "Literal" && typeof options.value === "boolean") { + context.report({ + node: options, + message: `Use an object instead of a boolean for addEventListener options. Replace '${options.value}' with '{ capture: ${options.value} }'.`, + fix(fixer) { + return fixer.replaceText( + options, + `{ capture: ${options.value} }`, + ); + }, + }); + } + }, + }; + }, +}; + +const removeEventListenerObject = { + meta: { + type: "suggestion", + docs: { + description: + "Require object options instead of boolean for removeEventListener", + }, + fixable: "code", + }, + createOnce(context) { + return { + CallExpression(node) { + if ( + node.callee.type !== "MemberExpression" || + node.callee.property.name !== "removeEventListener" || + node.arguments.length < 3 + ) { + return; + } + const options = node.arguments[2]; + if (options.type === "Literal" && typeof options.value === "boolean") { + context.report({ + node: options, + message: `Use an object instead of a boolean for removeEventListener options. Replace '${options.value}' with '{ capture: ${options.value} }'.`, + fix(fixer) { + return fixer.replaceText( + options, + `{ capture: ${options.value} }`, + ); + }, + }); + } + }, + }; + }, +}; + +function isSimpleParam(param) { + return param.type === "Identifier" || param.type === "AssignmentPattern"; +} + +const MIN_POSITIONAL_ARGS_FOR_WARNING = 5; + +const preferObjectParams = { + meta: { + type: "suggestion", + docs: { + description: `Prefer an options object instead of multiple positional arguments (${MIN_POSITIONAL_ARGS_FOR_WARNING}+)`, + }, + }, + createOnce(context) { + function check(node, nameNode) { + const params = node.params; + if (!params || params.length < MIN_POSITIONAL_ARGS_FOR_WARNING) { + return; + } + if (params.some((p) => p.type === "ObjectPattern")) { + return; + } + if (!params.every(isSimpleParam)) { + return; + } + context.report({ + node: nameNode || node, + message: + "Avoid multiple positional arguments. Prefer an options object instead, e.g., fn(options) or fn(arg, options).", + }); + } + + return { + FunctionDeclaration(node) { + check(node, node.id); + }, + FunctionExpression(node) { + check(node, node.id || node); + }, + ArrowFunctionExpression(node) { + if (node.parent.type === "VariableDeclarator") { + check(node, node.parent.id); + } else { + check(node, node); + } + }, + }; + }, +}; + +const atomWithStorageArgs = { + meta: { + type: "problem", + docs: { + description: + "Require atomWithStorage to have at least 3 arguments (key, defaultValue, storage)", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + const callee = node.callee; + const name = callee.type === "Identifier" ? callee.name : null; + + if (name !== "atomWithStorage") { + return; + } + if (node.arguments.length < 3) { + context.report({ + node, + message: + "atomWithStorage requires at least 3 arguments (key, defaultValue, storage). Provide the storage parameter explicitly.", + }); + } + }, + }; + }, +}; + +const TW_CLASS_ATTRS = new Set(["className", "class"]); +const TW_CLASS_FNS = new Set(["cn", "cva", "clsx", "cx", "twMerge", "twJoin"]); + +// Renamed in Tailwind v4. The old names still compile as aliases, so they are +// deprecated rather than broken, and the replacement is a safe rename. +const TW_RENAMED = new Map([ + ["flex-shrink-0", "shrink-0"], + ["flex-shrink", "shrink"], + ["flex-grow-0", "grow-0"], + ["flex-grow", "grow"], + ["overflow-ellipsis", "text-ellipsis"], + ["decoration-slice", "box-decoration-slice"], + ["decoration-clone", "box-decoration-clone"], +]); + +// Removed in Tailwind v4: these generate no CSS. The opacity modifier +// (e.g. `bg-black/50`) replaces them, but the rewrite needs the base color, +// so it cannot be applied mechanically. +const TW_REMOVED_OPACITY = + /^(bg|text|border|ring|divide|placeholder)-opacity-\d+$/; + +function splitClassToken(token) { + const colon = token.lastIndexOf(":"); + const prefix = token.slice(0, colon + 1); + let core = token.slice(colon + 1); + let lead = ""; + let trail = ""; + if (core.startsWith("!")) { + lead = "!"; + core = core.slice(1); + } + if (core.endsWith("!")) { + trail = "!"; + core = core.slice(0, -1); + } + return { prefix, lead, core, trail }; +} + +function inClassContext(node) { + for (let parent = node.parent; parent; parent = parent.parent) { + if (parent.type === "JSXAttribute") { + const name = parent.name; + return name?.type === "JSXIdentifier" && TW_CLASS_ATTRS.has(name.name); + } + if ( + parent.type === "CallExpression" && + parent.callee.type === "Identifier" && + TW_CLASS_FNS.has(parent.callee.name) + ) { + return true; + } + } + return false; +} + +function classStringNodes(handlers) { + return { + Literal(node) { + if (typeof node.value === "string" && inClassContext(node)) { + handlers.literal(node, node.value); + } + }, + TemplateElement(node) { + if (inClassContext(node)) { + handlers.template(node, node.value.cooked ?? node.value.raw); + } + }, + }; +} + +const noDeprecatedTailwindClasses = { + meta: { + type: "suggestion", + docs: { + description: "Disallow Tailwind utility classes renamed in v4", + }, + fixable: "code", + }, + createOnce(context) { + function renamesIn(value) { + const renames = []; + for (const token of value.split(/\s+/)) { + if (!token) { + continue; + } + const { core } = splitClassToken(token); + const replacement = TW_RENAMED.get(core); + if (replacement) { + renames.push({ from: core, to: replacement }); + } + } + return renames; + } + + function rebuild(value) { + return value + .split(/(\s+)/) + .map((token) => { + if (token.trim() === "") { + return token; + } + const { prefix, lead, core, trail } = splitClassToken(token); + const replacement = TW_RENAMED.get(core); + return replacement ? prefix + lead + replacement + trail : token; + }) + .join(""); + } + + function message(renames) { + const list = renames.map((r) => `${r.from} -> ${r.to}`).join(", "); + return `Tailwind v4 renamed this utility. Use the new name (${list}).`; + } + + return classStringNodes({ + literal(node, value) { + const renames = renamesIn(value); + if (renames.length === 0) { + return; + } + const quote = node.raw[0]; + context.report({ + node, + message: message(renames), + fix(fixer) { + return fixer.replaceText(node, quote + rebuild(value) + quote); + }, + }); + }, + template(node, value) { + const renames = renamesIn(value); + if (renames.length > 0) { + context.report({ node, message: message(renames) }); + } + }, + }); + }, +}; + +const noRemovedTailwindClasses = { + meta: { + type: "problem", + docs: { + description: "Disallow Tailwind utility classes removed in v4", + }, + }, + createOnce(context) { + function check(node, value) { + for (const token of value.split(/\s+/)) { + if (!token) { + continue; + } + const { core } = splitClassToken(token); + if (TW_REMOVED_OPACITY.test(core)) { + context.report({ + node, + message: `'${core}' was removed in Tailwind v4 and generates no CSS. Use the opacity modifier instead, e.g. 'bg-black/50'.`, + }); + } + } + } + + return classStringNodes({ literal: check, template: check }); + }, +}; + +const plugin = { + meta: { + name: "marimo", + }, + rules: { + "add-event-listener-object": addEventListenerObject, + "remove-event-listener-object": removeEventListenerObject, + "prefer-object-params": preferObjectParams, + "atom-with-storage-args": atomWithStorageArgs, + "no-deprecated-tailwind-classes": noDeprecatedTailwindClasses, + "no-removed-tailwind-classes": noRemovedTailwindClasses, + }, +}; + +export default plugin; diff --git a/frontend/lint/noCommentedConsole.grit b/frontend/lint/noCommentedConsole.grit new file mode 100644 index 00000000000..03a350ad5f5 --- /dev/null +++ b/frontend/lint/noCommentedConsole.grit @@ -0,0 +1,6 @@ +language js + +// Match commented out console statements +`// console.$method` where { + register_diagnostic(span=., message="Remove commented out console statement instead of leaving it in the code", severity="error") +} diff --git a/frontend/lint/preferObjectParams.grit b/frontend/lint/preferObjectParams.grit new file mode 100644 index 00000000000..194c7a97841 --- /dev/null +++ b/frontend/lint/preferObjectParams.grit @@ -0,0 +1,19 @@ +// Prefer object parameters pattern: fn(), fn(options), or fn(arg, options) +// This rule enforces using objects for multiple parameters instead of positional arguments +`$declaration` where { + $declaration <: or { + // Regular functions + `function $name($p1, $p2, $p3, $...) { $body }`, + // Async functions + `async function $name($p1, $p2, $p3, $...) { $body }`, + // Generator functions + `function* $name($p1, $p2, $p3, $...) { $body }`, + // Arrow functions + `const $name = ($p1, $p2, $p3, $...) => $body`, + // Function expressions + `const $name = function($p1, $p2, $p3, $...) { $body }`, + // Methods + `$name($p1, $p2, $p3, $...) { $body }` + }, + register_diagnostic(span=$name, message="Avoid multiple positional arguments. Prefer an options object instead, e.g., fn(options) or fn(arg, options).", severity="warn") +} diff --git a/frontend/lint/removeEventListenerObject.grit b/frontend/lint/removeEventListenerObject.grit new file mode 100644 index 00000000000..75fbfc88e24 --- /dev/null +++ b/frontend/lint/removeEventListenerObject.grit @@ -0,0 +1,7 @@ +`$receiver.removeEventListener($event, $handler, $options)` where { + $options <: or { + `true`, + `false` + }, + register_diagnostic(span=$options, message="Use an object instead of a boolean for removeEventListener options. Replace 'true' with '{ capture: true }' or 'false' with '{ capture: false }' (or omit entirely for false)", severity="error") +} diff --git a/frontend/package.json b/frontend/package.json index c82a9bf6386..da2ce683582 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,233 +5,234 @@ "types": "dist/index.d.ts", "type": "module", "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/marimo-team/marimo.git" + }, "sideEffects": [ "*.css", "*.css.ts" ], "files": [ - "dist" + "dist", + "src" ], + "exports": { + "./unstable_internal/*": "./src/*" + }, "dependencies": { - "@codemirror/autocomplete": "^6.18.1", - "@codemirror/commands": "^6.7.1", - "@codemirror/lang-markdown": "^6.3.0", - "@codemirror/lang-python": "^6.1.6", - "@codemirror/lang-sql": "^6.8.0", - "@codemirror/language": "^6.10.3", - "@codemirror/language-data": "^6.5.1", - "@codemirror/lint": "^6.8.2", - "@codemirror/search": "^6.5.6", - "@codemirror/state": "^6.4.1", - "@codemirror/theme-one-dark": "^6.1.2", - "@codemirror/view": "^6.34.1", - "@dagrejs/dagre": "^1.1.4", - "@dnd-kit/core": "^6.2.0", - "@dnd-kit/modifiers": "^8.0.0", - "@dnd-kit/sortable": "^9.0.0", + "@ai-sdk/react": "^3.0.134", + "@anywidget/types": "^0.4.0", + "@codemirror/autocomplete": "^6.20.1", + "@codemirror/commands": "^6.10.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/language": "^6.12.2", + "@codemirror/language-data": "^6.5.2", + "@codemirror/legacy-modes": "^6.5.2", + "@codemirror/lint": "^6.9.5", + "@codemirror/merge": "^6.12.0", + "@codemirror/search": "^6.6.0", + "@codemirror/state": "^6.5.4", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.39.16", + "@dagrejs/dagre": "^1.1.8", + "@date-fns/tz": "^1.4.1", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@emotion/cache": "^11.13.1", - "@emotion/react": "^11.13.0", - "@hookform/resolvers": "^3.9.0", - "@internationalized/date": "^3.5.5", - "@lezer/common": "^1.2.1", - "@lezer/highlight": "^1.2.1", - "@lezer/lr": "^1.4.2", - "@marimo-team/marimo-api": "file:../openapi", - "@marimo-team/react-slotz": "^0.1.8", - "@open-rpc/client-js": "^1.8.1", - "@paralleldrive/cuid2": "^2.2.2", + "@emotion/cache": "^11.14.0", + "@emotion/react": "^11.14.0", + "@glideapps/glide-data-grid": "6.0.4-alpha24", + "@hookform/resolvers": "^5.2.2", + "@img-comparison-slider/react": "^8.0.2", + "@internationalized/date": "^3.10.1", + "@lezer/common": "^1.5.1", + "@lezer/highlight": "^1.2.3", + "@lezer/lr": "^1.4.8", + "@lezer/markdown": "^1.6.3", + "@lezer/python": "^1.1.18", + "@marimo-team/codemirror-ai": "^0.3.7", + "@marimo-team/codemirror-languageserver": "^2.0.0", + "@marimo-team/codemirror-mcp": "^0.1.7", + "@marimo-team/codemirror-sql": "^0.3.0", + "@marimo-team/llm-info": "workspace:*", + "@marimo-team/marimo-api": "workspace:*", + "@marimo-team/react-slotz": "^0.2.0", + "@marimo-team/smart-cells": "workspace:*", + "@mui/material": "^6.5.0", + "@paralleldrive/cuid2": "^2.3.1", "@radix-ui/colors": "^3.0.0", - "@radix-ui/primitive": "~1.0.1", - "@radix-ui/react-accordion": "~1.1.2", - "@radix-ui/react-alert-dialog": "~1.0.5", - "@radix-ui/react-checkbox": "~1.0.4", - "@radix-ui/react-compose-refs": "~1.0.1", - "@radix-ui/react-context-menu": "~2.1.5", - "@radix-ui/react-dialog": "~1.0.5", - "@radix-ui/react-dropdown-menu": "~2.0.6", "@radix-ui/react-icons": "~1.3.2", - "@radix-ui/react-label": "~2.0.2", - "@radix-ui/react-navigation-menu": "~1.1.4", - "@radix-ui/react-popover": "~1.0.7", - "@radix-ui/react-progress": "~1.0.3", - "@radix-ui/react-radio-group": "~1.1.3", - "@radix-ui/react-scroll-area": "^1.2.1", - "@radix-ui/react-select": "~2.0.0", - "@radix-ui/react-slider": "~1.1.2", - "@radix-ui/react-slot": "~1.0.2", - "@radix-ui/react-switch": "~1.0.3", - "@radix-ui/react-tabs": "~1.0.4", - "@radix-ui/react-toast": "~1.1.5", - "@radix-ui/react-toggle": "~1.0.3", - "@radix-ui/react-tooltip": "~1.0.7", - "@radix-ui/react-use-callback-ref": "~1.0.1", - "@radix-ui/react-use-controllable-state": "~1.0.1", - "@react-aria/focus": "^3.18.2", - "@replit/codemirror-vim": "^6.2.1", - "@tailwindcss/typography": "^0.5.14", - "@tanstack/react-table": "^8.20.1", - "@textea/json-viewer": "^3.4.1", + "@radix-ui/react-use-controllable-state": "~1.2.2", + "@replit/codemirror-vim": "^6.3.0", + "@revealjs/react": "^0.2.1", + "@streamdown/math": "^1.0.1", + "@tailwindcss/postcss": "^4.3.0", + "@tailwindcss/typography": "^0.5.19", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.23", + "@textea/json-viewer": "^4.0.1", "@types/humanize-duration": "^3.27.4", "@types/js-cookie": "^3.0.6", "@types/jsdom": "^21.1.7", - "@types/react-grid-layout": "^1.3.5", + "@types/react-grid-layout": "^1.3.6", "@uidotdev/usehooks": "^2.4.1", - "@uiw/codemirror-extensions-langs": "^4.23.5", - "@uiw/codemirror-extensions-mentions": "^4.23.5", - "@uiw/react-codemirror": "^4.23.5", + "@uiw/codemirror-extensions-langs": "^4.25.7", + "@uiw/react-codemirror": "4.25.4", + "@uwdata/flechette": "^1.1.2", "@valtown/codemirror-codeium": "^1.1.1", - "@xterm/addon-attach": "^0.11.0", - "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-attach": "^0.12.0", + "@xterm/addon-canvas": "^0.7.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^5.5.0", - "ag-grid-community": "^32.2.2", - "ag-grid-react": "^32.2.2", - "ai": "^3.3.12", - "ansi_up": "^6.0.2", - "class-variance-authority": "^0.7.0", + "@zed-industries/agent-client-protocol": "^0.4.5", + "ai": "^6.0.129", + "ansi_up": "^6.0.6", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "cmdk": "^1.0.0", - "codemirror-extension-inline-suggestion": "^0.0.3", - "codemirror-languageserver": "^1.11.0", + "cmdk": "^1.1.1", "compassql": "^0.21.2", - "cssnano": "^7.0.5", - "date-fns": "^3.6.0", + "cssnano": "^7.1.2", + "date-fns": "^4.1.0", "dequal": "^2.0.3", - "eslint-plugin-header": "^3.1.1", + "dnd-core": "^16.0.1", + "dompurify": "^3.3.1", "htm": "^3.1.1", - "html-react-parser": "^5.1.12", - "html-to-image": "^1.11.11", - "humanize-duration": "^3.32.1", - "iconify-icon": "^2.1.0", - "jotai": "^2.9.3", + "html-react-parser": "^5.2.14", + "html-to-image": "1.11.13", + "humanize-duration": "^3.33.2", + "iconify-icon": "^2.3.0", + "jotai": "^2.17.0", + "jotai-scope": "^0.10.0", "js-cookie": "^3.0.5", - "katex": "^0.16.11", - "lodash-es": "^4.17.21", - "lucide-react": "^0.428.0", + "katex": "^0.16.28", + "lodash-es": "^4.17.22", + "loro-codemirror": "^0.3.3", + "loro-crdt": "^1.10.5", + "lucide-react": "^0.563.0", "lz-string": "^1.5.0", - "mermaid": "^11.3.0", - "partysocket": "1.0.2", - "path-to-regexp": "^8.0.0", - "plotly.js": "^2.35.2", - "pyodide": "^0.26.2", - "react-arborist": "^3.4.0", - "react-aria-components": "^1.3.2", - "react-codemirror-merge": "^4.23.5", - "react-dropzone": "^14.2.3", - "react-error-boundary": "^4.0.13", - "react-grid-layout": "^1.4.4", - "react-hook-form": "^7.52.2", - "react-markdown": "^9.0.1", - "react-plotly.js": "^2.6.0", - "react-resizable-panels": "2.0.19", + "marked": "^15.0.12", + "mermaid": "^11.12.3", + "partysocket": "1.1.13", + "path-to-regexp": "^8.4.0", + "plotly.js": "^3.3.1", + "pyodide": "314.0.0", + "radix-ui": "1.4.3", + "react-arborist": "^3.4.3", + "react-aria": "3.47.0", + "react-aria-components": "1.16.0", + "react-codemirror-merge": "4.25.4", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", + "react-dropzone": "^14.4.0", + "react-error-boundary": "^5.0.0", + "react-grid-layout": "^1.5.3", + "react-hook-form": "7.54.2", + "react-markdown": "^9.1.0", + "react-resizable-panels": "2.1.9", "react-use-event-hook": "^0.9.6", - "react-vega": "^7.6.0", - "react-virtuoso": "^4.10.1", + "react-vega": "^8.0.0", + "react-virtuoso": "^4.18.1", "reactflow": "^11.11.4", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1", + "reveal.js": "^6.0.0", "rpc-anywhere": "^1.7.0", - "string-dedent": "^3.0.1", - "swiper": "^11.1.9", - "tailwind-merge": "^2.5.2", + "sql-formatter": "^15.7.0", + "streamdown": "^2.1.0", + "string-dedent": "^3.0.2", + "swiper": "^12.0.0", + "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "thememirror": "^2.0.1", "timestring": "^7.0.0", "typescript-memoize": "^1.1.1", + "use-acp": "0.2.6", "use-resize-observer": "^9.1.0", - "vega-lite": "^5.21.0", - "vega-loader": "^4.5.2", + "vega-lite": "6.4.2", + "vega-loader": "^5.1.0", + "vega-parser": "^7.1.0", + "vega-tooltip": "^1.1.0", + "vscode-jsonrpc": "^8.2.1", "vscode-languageserver-protocol": "^3.17.5", - "web-vitals": "^4.2.3", - "zod": "^3.23.8" + "web-vitals": "^4.2.4", + "zod": "^4.3.6" }, "scripts": { "preinstall": "npx only-allow pnpm", - "ci": "CI=true run-s lint typecheck test build", + "ci": "cross-env CI=true run-s lint typecheck test build", "start": "vite", "dev": "vite", "build:watch": "vite build --watch --outDir ../marimo/_static", - "build": "vite build", + "build": "cross-env vite build", "test": "vitest", - "typecheck": "tsc --noEmit", - "lint": "run-s lint:eslint lint:stylelint lint:biome format", - "lint:biome": "biome lint --diagnostic-level=warn --write .", - "lint:eslint": "TIMING=1 eslint src e2e-tests --fix", + "test:coverage": "vitest run --coverage", + "typecheck": "tsgo", + "format": "oxfmt --config ../.oxfmtrc.json", + "lint": "run-s lint:oxlint lint:stylelint", + "lint:oxlint": "oxlint --fix", "lint:stylelint": "stylelint src/**/*.css --fix", - "format": "biome format --write .", "preview": "vite preview", - "dev:islands": "VITE_MARIMO_ISLANDS=true vite --config islands/vite.config.mts", - "build:islands": "VITE_MARIMO_ISLANDS=true vite --config islands/vite.config.mts build", - "preview:islands": "VITE_MARIMO_VERSION='0.4.6' VITE_MARIMO_ISLANDS=true vite --config islands/vite.config.mts build", + "dev:quarto": "VITE_MARIMO_ISLANDS=true vite", + "dev:islands": "cross-env VITE_MARIMO_ISLANDS=true vite --config islands/vite.config.mts", + "build:islands": "cross-env VITE_MARIMO_ISLANDS=true vite --config islands/vite.config.mts build", + "preview:islands": "cross-env VITE_MARIMO_VERSION='0.4.6' VITE_MARIMO_ISLANDS=true vite --config islands/vite.config.mts build", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, "peerDependencies": { - "react": "^17 || ^18", - "react-dom": "^17 || ^18" + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" }, "devDependencies": { - "@babel/plugin-proposal-class-properties": "^7.18.6", - "@babel/plugin-proposal-decorators": "^7.25.9", + "@babel/plugin-proposal-decorators": "^7.28.6", "@babel/preset-typescript": "^7.25.9", - "@biomejs/biome": "1.9.4", - "@csstools/postcss-light-dark-function": "^2.0.5", - "@playwright/test": "^1.46.1", - "@storybook/addon-docs": "^8.4.6", - "@storybook/addon-essentials": "^8.4.6", - "@storybook/addon-interactions": "^8.4.6", - "@storybook/addon-links": "^8.4.6", - "@storybook/blocks": "^8.4.6", - "@storybook/react": "^8.4.6", - "@storybook/react-vite": "^8.4.6", - "@swc-jotai/react-refresh": "^0.1.1", - "@testing-library/react": "^16.0.0", - "@testing-library/react-hooks": "^8.0.1", - "@types/emscripten": "^1.39.13", - "@types/katex": "^0.16.7", + "@codecov/vite-plugin": "^1.9.1", + "@csstools/postcss-light-dark-function": "^2.0.11", + "@playwright/test": "^1.59.1", + "@storybook/addon-docs": "^10.3.5", + "@storybook/addon-links": "^10.3.5", + "@storybook/react-vite": "^10.3.5", + "@swc-jotai/react-refresh": "^0.5.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/dompurify": "^3.2.0", + "@types/katex": "^0.16.8", "@types/lodash-es": "^4.17.12", - "@types/node": "^20.16.1", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@types/react-plotly.js": "^2.6.3", + "@types/node": "^24.10.9", + "@types/plotly.js": "^2.33.4", + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", "@types/timestring": "^6.0.5", - "@typescript-eslint/eslint-plugin": "^7.15.0", - "@typescript-eslint/parser": "^7.15.0", - "@vitejs/plugin-react": "^4.3.3", - "@vitejs/plugin-react-swc": "^3.7.0", - "autoprefixer": "^10.4.20", - "babel-plugin-react-compiler": "19.0.0-beta-8a03594-20241020", + "@vitejs/plugin-react": "^5.2.0", + "@vitest/coverage-v8": "3.2.4", + "babel-plugin-react-compiler": "19.1.0-rc.3", "blob-polyfill": "^7.0.20220408", - "eslint": "^8.57.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-jsx-a11y": "^6.9.0", - "eslint-plugin-react": "^7.34.3", - "eslint-plugin-react-compiler": "19.0.0-beta-8a03594-20241020", - "eslint-plugin-react-hooks": "^4.6.2", - "eslint-plugin-ssr-friendly": "^1.3.0", - "eslint-plugin-storybook": "^0.10.1", - "eslint-plugin-unicorn": "^54.0.0", - "eslint-plugin-vitest": "^0.4.1", - "jsdom": "^24.1.1", - "npm-run-all2": "^6.2.2", - "postcss": "^8.4.39", - "postcss-plugin-namespace": "^0.0.3", - "react": "^18.3.1", - "react-compiler-runtime": "19.0.0-beta-8a03594-20241020", - "react-dom": "^18.3.1", - "react-test-renderer": "^18.3.1", - "storybook": "^8.4.6", - "stylelint": "^16.8.2", + "cross-env": "^7.0.3", + "jsdom": "^24.1.3", + "msw": "^2.12.7", + "npm-run-all2": "^6.2.6", + "oxfmt": "^0.42.0", + "oxlint": "^1.58.0", + "postcss": "^8.5.6", + "postcss-prefix-selector": "^2.1.1", + "react": "^19.2.4", + "react-compiler-runtime": "19.1.0-rc.3", + "react-dom": "^19.2.4", + "storybook": "^10.3.5", + "stylelint": "^16.26.1", "stylelint-config-standard": "^36.0.1", - "tailwindcss": "^3.4.10", - "turbo": "^2.0.14", - "typescript": "^5.5.4", - "vite": "^5.4.2", - "vite-tsconfig-paths": "^4.3.2", - "vitest": "^1.6.0" - }, - "packageManager": "pnpm@9.8.0", - "pnpm": { - "patchedDependencies": { - "react-plotly.js": "patches/react-plotly.js.patch" - } + "tailwindcss": "^4.3.0", + "vega-typings": "^2.1.0", + "vite": "npm:rolldown-vite@7.3.1", + "vite-plugin-top-level-await": "^1.6.0", + "vite-plugin-wasm": "^3.6.0", + "vitest": "^3.2.4" } } diff --git a/frontend/patches/react-plotly.js.patch b/frontend/patches/react-plotly.js.patch deleted file mode 100644 index 96b632c4c82..00000000000 --- a/frontend/patches/react-plotly.js.patch +++ /dev/null @@ -1,15 +0,0 @@ -diff --git a/factory.js b/factory.js -index 40f3edccbebc45b491dd3488ed619ec9208be8d9..f88f451588d656805d1ae76ce4184a486a179eb1 100644 ---- a/factory.js -+++ b/factory.js -@@ -40,8 +40,8 @@ function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.g - // The naming convention is: - // - events are attached as `'plotly_' + eventName.toLowerCase()` - // - react props are `'on' + eventName` --var eventNames = ['AfterExport', 'AfterPlot', 'Animated', 'AnimatingFrame', 'AnimationInterrupted', 'AutoSize', 'BeforeExport', 'BeforeHover', 'ButtonClicked', 'Click', 'ClickAnnotation', 'Deselect', 'DoubleClick', 'Framework', 'Hover', 'LegendClick', 'LegendDoubleClick', 'Relayout', 'Relayouting', 'Restyle', 'Redraw', 'Selected', 'Selecting', 'SliderChange', 'SliderEnd', 'SliderStart', 'SunburstClick', 'Transitioning', 'TransitionInterrupted', 'Unhover', 'WebGlContextLost']; --var updateEvents = ['plotly_restyle', 'plotly_redraw', 'plotly_relayout', 'plotly_relayouting', 'plotly_doubleclick', 'plotly_animated', 'plotly_sunburstclick']; // Check if a window is available since SSR (server-side rendering) -+var eventNames = ['AfterExport', 'AfterPlot', 'Animated', 'AnimatingFrame', 'AnimationInterrupted', 'AutoSize', 'BeforeExport', 'BeforeHover', 'ButtonClicked', 'Click', 'ClickAnnotation', 'Deselect', 'DoubleClick', 'Framework', 'Hover', 'LegendClick', 'LegendDoubleClick', 'Relayout', 'Relayouting', 'Restyle', 'Redraw', 'Selected', 'Selecting', 'SliderChange', 'SliderEnd', 'SliderStart', 'SunburstClick', 'TreemapClick', 'Transitioning', 'TransitionInterrupted', 'Unhover', 'WebGlContextLost']; -+var updateEvents = ['plotly_restyle', 'plotly_redraw', 'plotly_relayout', 'plotly_relayouting', 'plotly_doubleclick', 'plotly_animated', 'plotly_sunburstclick', 'plotly_treemapclick']; // Check if a window is available since SSR (server-side rendering) - // breaks unnecessarily if you try to use it server-side. - - var isBrowser = typeof window !== 'undefined'; diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index d4c4f61972d..5c99dda2fad 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,8 +1,9 @@ -/* Copyright 2024 Marimo. All rights reserved. */ +/* Copyright 2026 Marimo. All rights reserved. */ + +import { exec } from "node:child_process"; +import path from "node:path"; import type { PlaywrightTestConfig } from "@playwright/test"; import { devices } from "@playwright/test"; -import path from "node:path"; -import { exec } from "node:child_process"; export type ServerOptions = | { @@ -36,16 +37,22 @@ const appToOptions = { "bad_button.py": { command: "edit" }, "bugs.py": { command: "edit" }, "cells.py": { command: "edit" }, + "columns.py": { command: "edit" }, "disabled_cells.py": { command: "edit" }, + "disabled_ancestor_error.py": { command: "edit" }, "kitchen_sink.py": { command: "edit" }, "layout_grid.py": { command: "edit" }, "stdin.py": { command: "edit" }, + "layout_grid_with_sidebar.py": { command: "edit" }, + "slides.py": { command: "edit" }, // Custom server for shutdown "shutdown.py": { command: "edit", port: port() }, // Run "components.py": { port: port(), command: "run" }, + "nav_menu.py": { port: port(), command: "run" }, "layout_grid.py//run": { port: port(), command: "run" }, "layout_grid_max_width.py//run": { port: port(), command: "run" }, + "layout_grid_with_sidebar.py//run": { port: port(), command: "run" }, "output.py//run": { port: port(), command: "run", @@ -55,7 +62,12 @@ const appToOptions = { export type ApplicationNames = keyof typeof appToOptions; -function getUrl(port: number, baseUrl = "", queryParams = ""): string { +function getUrl(options: { + port: number; + baseUrl?: string; + queryParams?: string; +}): string { + const { port, baseUrl = "", queryParams = "" } = options; return `http://127.0.0.1:${port}${baseUrl}${queryParams}`; } @@ -67,9 +79,9 @@ export function getAppUrl(app: ApplicationNames): string { } if (options.command === "edit") { const pathToApp = path.join(pydir, app); - return getUrl(EDIT_PORT, "", `?file=${pathToApp}`); + return getUrl({ port: EDIT_PORT, queryParams: `?file=${pathToApp}` }); } - return getUrl(options.port, options.baseUrl); + return getUrl({ port: options.port, baseUrl: options.baseUrl }); } export function getAppMode(app: ApplicationNames): "edit" | "run" { const options: ServerOptions = appToOptions[app]; @@ -95,6 +107,18 @@ export async function resetFile(app: ApplicationNames): Promise { return; } +// All ports that a marimo test server may be started on +export function getTestPorts(): number[] { + const ports = new Set([EDIT_PORT]); + for (const opts of Object.values(appToOptions)) { + const options = opts as ServerOptions; + if (options.port) { + ports.add(options.port); + } + } + return [...ports].toSorted((a, b) => a - b); +} + // Start marimo server for the given app export function startServer(app: ApplicationNames): void { const options: ServerOptions = appToOptions[app]; @@ -103,25 +127,25 @@ export function startServer(app: ApplicationNames): void { } const port = options.port ?? EDIT_PORT; const pathToApp = path.join(pydir, app); - const marimoCmd = `marimo -q ${options.command} ${pathToApp} -p ${port} --headless`; + const marimoCmd = `uv run marimo -q ${options.command} ${pathToApp} -p ${port} --headless`; exec(marimoCmd); } -const WASM_SERVER = { - command: "PYODIDE=true vite --port 3000", - url: "http://localhost:3000", - reuseExistingServer: !!process.env.CI, -}; +// const WASM_SERVER = { +// command: "PYODIDE=true vite --port 3000", +// url: "http://localhost:3000", +// reuseExistingServer: !!process.env.CI, +// }; // See https://playwright.dev/docs/test-configuration. const config: PlaywrightTestConfig = { testDir: "./e2e-tests", // Maximum time one test can run for - timeout: 30 * 1000, + timeout: 30 * 1000, // 30s expect: { // Maximum time expect() should wait for the condition to be met. // For example in `await expect(locator).toHaveText();` - timeout: 5000, + timeout: 5 * 1000, // 5s }, // Run tests in files in parallel fullyParallel: false, @@ -133,23 +157,34 @@ const config: PlaywrightTestConfig = { workers: 1, // Reporter to use. See https://playwright.dev/docs/test-reporters reporter: "html", - // Suppress tests stdout/stderr. - quiet: true, + // Enable stdout/stderr for better debugging + quiet: false, // Shared settings for all the projects below. See // https://playwright.dev/docs/api/class-testoptions. use: { - // Max time each action (eg `click()`) can take. Defaults to 0 (no limit). - actionTimeout: 0, - // Collect trace when retrying the failed test. See - // https://playwright.dev/docs/trace-viewer + // Max time each action can take + actionTimeout: 5 * 1000, // 5s + // Navigation timeout + navigationTimeout: 10 * 1000, // 10s + // Collect trace when retrying the failed test trace: "on-first-retry", + // Take screenshot on failure + screenshot: "only-on-failure", }, + // Global setup for better test isolation + globalSetup: "./e2e-tests/global-setup.ts", + globalTeardown: "./e2e-tests/global-teardown.ts", + // TODO(akshayka): Consider testing on firefox projects: [ { name: "chromium", - use: { ...devices["Desktop Chrome"] }, + use: { + ...devices["Desktop Chrome"], + // Add viewport for consistency + viewport: { width: 1280, height: 720 }, + }, testIgnore: ["**/cells.spec.ts", "**/disabled.spec.ts"], }, // Re-enable later ... @@ -181,24 +216,35 @@ const config: PlaywrightTestConfig = { return []; } - const baseUrl = options.command === "run" ? options.baseUrl : undefined; + const baseUrl = command === "run" ? options.baseUrl : undefined; const pathToApp = path.join(pydir, app); - let marimoCmd = `marimo -q ${command} ${pathToApp} -p ${port} --headless --no-token`; + let marimoCmd = `uv run marimo -q ${command} ${pathToApp} -p ${port} --headless --no-token`; if (baseUrl) { marimoCmd += ` --base-url=${baseUrl}`; } return { command: marimoCmd, - url: getUrl(port, baseUrl), - reuseExistingServer: false, + url: getUrl({ port, baseUrl }), + reuseExistingServer: true, + timeout: 30 * 1000, + ignoreHTTPSErrors: true, + // Use "ignore" to prevent child processes from inheriting pipe FDs. + // With "pipe", orphan kernel workers keep the FDs open after the + // parent is killed, causing Playwright's webServer teardown to hang. + stdout: "ignore" as const, + stderr: "ignore" as const, }; }), { - command: `marimo -q edit -p ${EDIT_PORT} --headless --no-token`, - url: getUrl(EDIT_PORT), - reuseExistingServer: false, + command: `uv run marimo -q edit -p ${EDIT_PORT} --headless --no-token`, + url: getUrl({ port: EDIT_PORT }), + reuseExistingServer: true, + timeout: 30 * 1000, + ignoreHTTPSErrors: true, + stdout: "ignore" as const, + stderr: "ignore" as const, }, // WASM_SERVER, ], diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml deleted file mode 100644 index 385352284f8..00000000000 --- a/frontend/pnpm-lock.yaml +++ /dev/null @@ -1,19371 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -pnpmfileChecksum: eqzj7c6t3bs5v7xcjer2wfwkp4 - -patchedDependencies: - react-plotly.js: - hash: bpeex744nmt6722etqry6essaq - path: patches/react-plotly.js.patch - -importers: - - .: - dependencies: - '@codemirror/autocomplete': - specifier: ^6.18.1 - version: 6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1) - '@codemirror/commands': - specifier: ^6.7.1 - version: 6.7.1 - '@codemirror/lang-markdown': - specifier: ^6.3.0 - version: 6.3.0 - '@codemirror/lang-python': - specifier: ^6.1.6 - version: 6.1.6(@codemirror/view@6.34.3) - '@codemirror/lang-sql': - specifier: ^6.8.0 - version: 6.8.0(@codemirror/view@6.34.3) - '@codemirror/language': - specifier: ^6.10.3 - version: 6.10.3 - '@codemirror/language-data': - specifier: ^6.5.1 - version: 6.5.1(@codemirror/view@6.34.3) - '@codemirror/lint': - specifier: ^6.8.2 - version: 6.8.2 - '@codemirror/search': - specifier: ^6.5.6 - version: 6.5.7 - '@codemirror/state': - specifier: ^6.4.1 - version: 6.4.1 - '@codemirror/theme-one-dark': - specifier: ^6.1.2 - version: 6.1.2 - '@codemirror/view': - specifier: ^6.34.1 - version: 6.34.3 - '@dagrejs/dagre': - specifier: ^1.1.4 - version: 1.1.4 - '@dnd-kit/core': - specifier: ^6.2.0 - version: 6.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@dnd-kit/modifiers': - specifier: ^8.0.0 - version: 8.0.0(@dnd-kit/core@6.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) - '@dnd-kit/sortable': - specifier: ^9.0.0 - version: 9.0.0(@dnd-kit/core@6.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) - '@dnd-kit/utilities': - specifier: ^3.2.2 - version: 3.2.2(react@18.3.1) - '@emotion/cache': - specifier: ^11.13.1 - version: 11.13.5 - '@emotion/react': - specifier: ^11.13.0 - version: 11.13.0(@types/react@18.3.12)(react@18.3.1) - '@hookform/resolvers': - specifier: ^3.9.0 - version: 3.9.0(react-hook-form@7.52.2(react@18.3.1)) - '@internationalized/date': - specifier: ^3.5.5 - version: 3.5.5 - '@lezer/common': - specifier: ^1.2.1 - version: 1.2.1 - '@lezer/highlight': - specifier: ^1.2.1 - version: 1.2.1 - '@lezer/lr': - specifier: ^1.4.2 - version: 1.4.2 - '@marimo-team/marimo-api': - specifier: file:../openapi - version: file:../openapi - '@marimo-team/react-slotz': - specifier: ^0.1.8 - version: 0.1.8(react@18.3.1) - '@open-rpc/client-js': - specifier: ^1.8.1 - version: 1.8.1 - '@paralleldrive/cuid2': - specifier: ^2.2.2 - version: 2.2.2 - '@radix-ui/colors': - specifier: ^3.0.0 - version: 3.0.0 - '@radix-ui/primitive': - specifier: ~1.0.1 - version: 1.0.1 - '@radix-ui/react-accordion': - specifier: ~1.1.2 - version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-alert-dialog': - specifier: ~1.0.5 - version: 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-checkbox': - specifier: ~1.0.4 - version: 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': - specifier: ~1.0.1 - version: 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-context-menu': - specifier: ~2.1.5 - version: 2.1.5(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-dialog': - specifier: ~1.0.5 - version: 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-dropdown-menu': - specifier: ~2.0.6 - version: 2.0.6(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-icons': - specifier: ~1.3.2 - version: 1.3.2(react@18.3.1) - '@radix-ui/react-label': - specifier: ~2.0.2 - version: 2.0.2(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-navigation-menu': - specifier: ~1.1.4 - version: 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-popover': - specifier: ~1.0.7 - version: 1.0.7(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-progress': - specifier: ~1.0.3 - version: 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-radio-group': - specifier: ~1.1.3 - version: 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-scroll-area': - specifier: ^1.2.1 - version: 1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-select': - specifier: ~2.0.0 - version: 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slider': - specifier: ~1.1.2 - version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': - specifier: ~1.0.2 - version: 1.0.2(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-switch': - specifier: ~1.0.3 - version: 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-tabs': - specifier: ~1.0.4 - version: 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-toast': - specifier: ~1.1.5 - version: 1.1.5(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-toggle': - specifier: ~1.0.3 - version: 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-tooltip': - specifier: ~1.0.7 - version: 1.0.7(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': - specifier: ~1.0.1 - version: 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-controllable-state': - specifier: ~1.0.1 - version: 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@react-aria/focus': - specifier: ^3.18.2 - version: 3.18.2(react@18.3.1) - '@replit/codemirror-vim': - specifier: ^6.2.1 - version: 6.2.1(@codemirror/commands@6.7.1)(@codemirror/language@6.10.3)(@codemirror/search@6.5.7)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3) - '@tailwindcss/typography': - specifier: ^0.5.14 - version: 0.5.14(tailwindcss@3.4.10) - '@tanstack/react-table': - specifier: ^8.20.1 - version: 8.20.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@textea/json-viewer': - specifier: ^3.4.1 - version: 3.4.1(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@mui/material@5.16.7(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@types/humanize-duration': - specifier: ^3.27.4 - version: 3.27.4 - '@types/js-cookie': - specifier: ^3.0.6 - version: 3.0.6 - '@types/jsdom': - specifier: ^21.1.7 - version: 21.1.7 - '@types/react-grid-layout': - specifier: ^1.3.5 - version: 1.3.5 - '@uidotdev/usehooks': - specifier: ^2.4.1 - version: 2.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@uiw/codemirror-extensions-langs': - specifier: ^4.23.5 - version: 4.23.5(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1))(@codemirror/language-data@6.5.1(@codemirror/view@6.34.3))(@codemirror/language@6.10.3)(@codemirror/legacy-modes@6.4.1)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1)(@lezer/highlight@1.2.1)(@lezer/javascript@1.4.18)(@lezer/lr@1.4.2) - '@uiw/codemirror-extensions-mentions': - specifier: ^4.23.5 - version: 4.23.5(@codemirror/state@6.4.1)(@codemirror/view@6.34.3) - '@uiw/react-codemirror': - specifier: ^4.23.5 - version: 4.23.5(@babel/runtime@7.25.6)(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1))(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.34.3)(codemirror@6.0.1(@lezer/common@1.2.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@valtown/codemirror-codeium': - specifier: ^1.1.1 - version: 1.1.1(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1))(@codemirror/state@6.4.1)(@codemirror/view@6.34.3) - '@xterm/addon-attach': - specifier: ^0.11.0 - version: 0.11.0(@xterm/xterm@5.5.0) - '@xterm/addon-fit': - specifier: ^0.10.0 - version: 0.10.0(@xterm/xterm@5.5.0) - '@xterm/xterm': - specifier: ^5.5.0 - version: 5.5.0 - ag-grid-community: - specifier: ^32.2.2 - version: 32.2.2 - ag-grid-react: - specifier: ^32.2.2 - version: 32.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - ai: - specifier: ^3.3.12 - version: 3.3.12(react@18.3.1)(sswr@2.1.0)(zod@3.23.8) - ansi_up: - specifier: ^6.0.2 - version: 6.0.2 - class-variance-authority: - specifier: ^0.7.0 - version: 0.7.0 - clsx: - specifier: ^2.1.1 - version: 2.1.1 - cmdk: - specifier: ^1.0.0 - version: 1.0.0(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - codemirror-extension-inline-suggestion: - specifier: ^0.0.3 - version: 0.0.3(@codemirror/state@6.4.1)(@codemirror/view@6.34.3) - codemirror-languageserver: - specifier: ^1.11.0 - version: 1.11.0(@codemirror/language@6.10.3)(@lezer/common@1.2.1) - compassql: - specifier: ^0.21.2 - version: 0.21.2(vega@5.30.0) - cssnano: - specifier: ^7.0.5 - version: 7.0.5(postcss@8.4.49) - date-fns: - specifier: ^3.6.0 - version: 3.6.0 - dequal: - specifier: ^2.0.3 - version: 2.0.3 - eslint-plugin-header: - specifier: ^3.1.1 - version: 3.1.1(eslint@8.57.0) - htm: - specifier: ^3.1.1 - version: 3.1.1 - html-react-parser: - specifier: ^5.1.12 - version: 5.1.12(@types/react@18.3.12)(react@18.3.1) - html-to-image: - specifier: ^1.11.11 - version: 1.11.11 - humanize-duration: - specifier: ^3.32.1 - version: 3.32.1 - iconify-icon: - specifier: ^2.1.0 - version: 2.1.0 - jotai: - specifier: ^2.9.3 - version: 2.9.3(@types/react@18.3.12)(react@18.3.1) - js-cookie: - specifier: ^3.0.5 - version: 3.0.5 - katex: - specifier: ^0.16.11 - version: 0.16.11 - lodash-es: - specifier: ^4.17.21 - version: 4.17.21 - lucide-react: - specifier: ^0.428.0 - version: 0.428.0(react@18.3.1) - lz-string: - specifier: ^1.5.0 - version: 1.5.0 - mermaid: - specifier: ^11.3.0 - version: 11.3.0 - partysocket: - specifier: 1.0.2 - version: 1.0.2 - path-to-regexp: - specifier: ^8.0.0 - version: 8.1.0 - plotly.js: - specifier: ^2.35.2 - version: 2.35.2(mapbox-gl@1.13.3)(webpack@5.96.1(esbuild@0.21.5)) - pyodide: - specifier: ^0.26.2 - version: 0.26.2 - react-arborist: - specifier: ^3.4.0 - version: 3.4.0(@types/node@20.17.6)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-aria-components: - specifier: ^1.3.2 - version: 1.3.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-codemirror-merge: - specifier: ^4.23.5 - version: 4.23.5(@babel/runtime@7.25.6)(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1))(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.34.3)(codemirror@6.0.1(@lezer/common@1.2.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-dropzone: - specifier: ^14.2.3 - version: 14.2.3(react@18.3.1) - react-error-boundary: - specifier: ^4.0.13 - version: 4.0.13(react@18.3.1) - react-grid-layout: - specifier: ^1.4.4 - version: 1.4.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-hook-form: - specifier: ^7.52.2 - version: 7.52.2(react@18.3.1) - react-markdown: - specifier: ^9.0.1 - version: 9.0.1(@types/react@18.3.12)(react@18.3.1) - react-plotly.js: - specifier: ^2.6.0 - version: 2.6.0(patch_hash=bpeex744nmt6722etqry6essaq)(plotly.js@2.35.2(mapbox-gl@1.13.3)(webpack@5.96.1(esbuild@0.21.5)))(react@18.3.1) - react-resizable-panels: - specifier: 2.0.19 - version: 2.0.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-use-event-hook: - specifier: ^0.9.6 - version: 0.9.6(react@18.3.1) - react-vega: - specifier: ^7.6.0 - version: 7.6.0(react@18.3.1)(vega-lite@5.21.0(vega@5.30.0))(vega@5.30.0) - react-virtuoso: - specifier: ^4.10.1 - version: 4.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - reactflow: - specifier: ^11.11.4 - version: 11.11.4(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - rpc-anywhere: - specifier: ^1.7.0 - version: 1.7.0 - string-dedent: - specifier: ^3.0.1 - version: 3.0.1 - swiper: - specifier: ^11.1.9 - version: 11.1.9 - tailwind-merge: - specifier: ^2.5.2 - version: 2.5.2 - tailwindcss-animate: - specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.10) - thememirror: - specifier: ^2.0.1 - version: 2.0.1(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3) - timestring: - specifier: ^7.0.0 - version: 7.0.0 - typescript-memoize: - specifier: ^1.1.1 - version: 1.1.1 - use-resize-observer: - specifier: ^9.1.0 - version: 9.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - vega-lite: - specifier: ^5.21.0 - version: 5.21.0(vega@5.30.0) - vega-loader: - specifier: ^4.5.2 - version: 4.5.2 - vscode-languageserver-protocol: - specifier: ^3.17.5 - version: 3.17.5 - web-vitals: - specifier: ^4.2.3 - version: 4.2.3 - zod: - specifier: ^3.23.8 - version: 3.23.8 - devDependencies: - '@babel/plugin-proposal-class-properties': - specifier: ^7.18.6 - version: 7.18.6(@babel/core@7.25.9) - '@babel/plugin-proposal-decorators': - specifier: ^7.25.9 - version: 7.25.9(@babel/core@7.25.9) - '@babel/preset-typescript': - specifier: ^7.25.9 - version: 7.25.9(@babel/core@7.25.9) - '@biomejs/biome': - specifier: 1.9.4 - version: 1.9.4 - '@csstools/postcss-light-dark-function': - specifier: ^2.0.5 - version: 2.0.5(postcss@8.4.49) - '@playwright/test': - specifier: ^1.46.1 - version: 1.46.1 - '@storybook/addon-docs': - specifier: ^8.4.6 - version: 8.4.6(@types/react@18.3.12)(storybook@8.4.6) - '@storybook/addon-essentials': - specifier: ^8.4.6 - version: 8.4.6(@types/react@18.3.12)(storybook@8.4.6) - '@storybook/addon-interactions': - specifier: ^8.4.6 - version: 8.4.6(storybook@8.4.6) - '@storybook/addon-links': - specifier: ^8.4.6 - version: 8.4.6(react@18.3.1)(storybook@8.4.6) - '@storybook/blocks': - specifier: ^8.4.6 - version: 8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6) - '@storybook/react': - specifier: ^8.4.6 - version: 8.4.6(@storybook/test@8.4.6(storybook@8.4.6))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6)(typescript@5.5.4) - '@storybook/react-vite': - specifier: ^8.4.6 - version: 8.4.6(@storybook/test@8.4.6(storybook@8.4.6))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6)(typescript@5.5.4)(vite@5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0)) - '@swc-jotai/react-refresh': - specifier: ^0.1.1 - version: 0.1.1 - '@testing-library/react': - specifier: ^16.0.0 - version: 16.0.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@testing-library/react-hooks': - specifier: ^8.0.1 - version: 8.0.1(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react-test-renderer@18.3.1(react@18.3.1))(react@18.3.1) - '@types/emscripten': - specifier: ^1.39.13 - version: 1.39.13 - '@types/katex': - specifier: ^0.16.7 - version: 0.16.7 - '@types/lodash-es': - specifier: ^4.17.12 - version: 4.17.12 - '@types/node': - specifier: ^20.16.1 - version: 20.17.6 - '@types/react': - specifier: ^18.3.3 - version: 18.3.12 - '@types/react-dom': - specifier: ^18.3.0 - version: 18.3.0 - '@types/react-plotly.js': - specifier: ^2.6.3 - version: 2.6.3 - '@types/timestring': - specifier: ^6.0.5 - version: 6.0.5 - '@typescript-eslint/eslint-plugin': - specifier: ^7.15.0 - version: 7.15.0(@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4) - '@typescript-eslint/parser': - specifier: ^7.15.0 - version: 7.15.0(eslint@8.57.0)(typescript@5.5.4) - '@vitejs/plugin-react': - specifier: ^4.3.3 - version: 4.3.3(vite@5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0)) - '@vitejs/plugin-react-swc': - specifier: ^3.7.0 - version: 3.7.1(@swc/helpers@0.5.1)(vite@5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0)) - autoprefixer: - specifier: ^10.4.20 - version: 10.4.20(postcss@8.4.49) - babel-plugin-react-compiler: - specifier: 19.0.0-beta-8a03594-20241020 - version: 19.0.0-beta-8a03594-20241020 - blob-polyfill: - specifier: ^7.0.20220408 - version: 7.0.20220408 - eslint: - specifier: ^8.57.0 - version: 8.57.0 - eslint-config-prettier: - specifier: ^9.1.0 - version: 9.1.0(eslint@8.57.0) - eslint-plugin-jsx-a11y: - specifier: ^6.9.0 - version: 6.9.0(eslint@8.57.0) - eslint-plugin-react: - specifier: ^7.34.3 - version: 7.34.3(eslint@8.57.0) - eslint-plugin-react-compiler: - specifier: 19.0.0-beta-8a03594-20241020 - version: 19.0.0-beta-8a03594-20241020(eslint@8.57.0) - eslint-plugin-react-hooks: - specifier: ^4.6.2 - version: 4.6.2(eslint@8.57.0) - eslint-plugin-ssr-friendly: - specifier: ^1.3.0 - version: 1.3.0(eslint@8.57.0) - eslint-plugin-storybook: - specifier: ^0.10.1 - version: 0.10.1(eslint@8.57.0)(typescript@5.5.4) - eslint-plugin-unicorn: - specifier: ^54.0.0 - version: 54.0.0(eslint@8.57.0) - eslint-plugin-vitest: - specifier: ^0.4.1 - version: 0.4.1(@typescript-eslint/eslint-plugin@7.15.0(@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4)(vitest@1.6.0(@types/node@20.17.6)(jsdom@24.1.1)(less@4.2.0)(terser@5.36.0)) - jsdom: - specifier: ^24.1.1 - version: 24.1.1 - npm-run-all2: - specifier: ^6.2.2 - version: 6.2.2 - postcss: - specifier: ^8.4.39 - version: 8.4.49 - postcss-plugin-namespace: - specifier: ^0.0.3 - version: 0.0.3 - react: - specifier: ^18.3.1 - version: 18.3.1 - react-compiler-runtime: - specifier: 19.0.0-beta-8a03594-20241020 - version: 19.0.0-beta-8a03594-20241020(react@18.3.1) - react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) - react-test-renderer: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) - storybook: - specifier: ^8.4.6 - version: 8.4.6 - stylelint: - specifier: ^16.8.2 - version: 16.8.2(typescript@5.5.4) - stylelint-config-standard: - specifier: ^36.0.1 - version: 36.0.1(stylelint@16.8.2(typescript@5.5.4)) - tailwindcss: - specifier: ^3.4.10 - version: 3.4.10 - turbo: - specifier: ^2.0.14 - version: 2.0.14 - typescript: - specifier: ^5.5.4 - version: 5.5.4 - vite: - specifier: ^5.4.2 - version: 5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0) - vite-tsconfig-paths: - specifier: ^4.3.2 - version: 4.3.2(typescript@5.5.4)(vite@5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0)) - vitest: - specifier: ^1.6.0 - version: 1.6.0(@types/node@20.17.6)(jsdom@24.1.1)(less@4.2.0)(terser@5.36.0) - -packages: - - '@aashutoshrathi/word-wrap@1.2.6': - resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} - engines: {node: '>=0.10.0'} - - '@adobe/css-tools@4.4.0': - resolution: {integrity: sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==} - - '@ai-sdk/provider-utils@1.0.14': - resolution: {integrity: sha512-6jKYgg/iitJiz9ivlTx1CDrQBx1BeSd0IlRJ/Fl5LcdGAc3gnsMVR+R1w1jxzyhjVyh6g+NqlOZenW0tctNZnA==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.0.0 - peerDependenciesMeta: - zod: - optional: true - - '@ai-sdk/provider@0.0.21': - resolution: {integrity: sha512-9j95uaPRxwYkzQdkl4XO/MmWWW5c5vcVSXtqvALpD9SMB9fzH46dO3UN4VbOJR2J3Z84CZAqgZu5tNlkptT9qQ==} - engines: {node: '>=18'} - - '@ai-sdk/react@0.0.47': - resolution: {integrity: sha512-lvH5vscfiH93zbJCTR4GZoX0FuiCShEKZVYVQSaE8wod+lc/LaFp9u7sXgHeZOCQj06C68WB88h90V2l9P6cig==} - engines: {node: '>=18'} - peerDependencies: - react: ^18 || ^19 - zod: ^3.0.0 - peerDependenciesMeta: - react: - optional: true - zod: - optional: true - - '@ai-sdk/solid@0.0.37': - resolution: {integrity: sha512-KfRHEjBNmtm78Ch1MmTjuvUb7EHOCWiZrypqZt+R0EZJ6X9SIy4fwlXpY/Mn5WuOweAK7vL+woIAlk0SIRSF0w==} - engines: {node: '>=18'} - peerDependencies: - solid-js: '*' - peerDependenciesMeta: - solid-js: - optional: true - - '@ai-sdk/svelte@0.0.39': - resolution: {integrity: sha512-7t/DfxlsWqA+3gDNEUX9ONgdzCpPfPKzkxsE3UGtHvrRLknbLa692yNGJNi0OVB5V++vhtpPw4LViPEmj6DV2w==} - engines: {node: '>=18'} - peerDependencies: - svelte: '*' - peerDependenciesMeta: - svelte: - optional: true - - '@ai-sdk/ui-utils@0.0.34': - resolution: {integrity: sha512-8nTBsQklLrp6r/AJyeWxD8D4pvhQhlGfrDaBAfo7OEdBLwF6bNdnh6CJFSwUMiXUtUPOBqPV5tFgY2pJmGQikg==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.0.0 - peerDependenciesMeta: - zod: - optional: true - - '@ai-sdk/vue@0.0.39': - resolution: {integrity: sha512-lIcDV1PieneN6yxb7LDdYmXEOearRxdvAgVvMdtAS+Fc4s8basaidh6AA6OFOPAVc16PchpfZCVREfve0r6pUw==} - engines: {node: '>=18'} - peerDependencies: - vue: '*' - peerDependenciesMeta: - vue: - optional: true - - '@alloc/quick-lru@5.2.0': - resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} - engines: {node: '>=10'} - - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} - - '@antfu/install-pkg@0.4.1': - resolution: {integrity: sha512-T7yB5QNG29afhWVkVq7XeIMBa5U/vs9mX69YqayXypPRmYzUmzwnYltplHmPtZ4HPCn+sQKeXW8I47wCbuBOjw==} - - '@antfu/utils@0.7.10': - resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} - - '@babel/code-frame@7.25.9': - resolution: {integrity: sha512-z88xeGxnzehn2sqZ8UdGQEvYErF1odv2CftxInpSYJt6uHuPe9YjahKZITGs3l5LeI9d2ROG+obuDAoSlqbNfQ==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.25.9': - resolution: {integrity: sha512-yD+hEuJ/+wAJ4Ox2/rpNv5HIuPG82x3ZlQvYVn8iYCprdxzE7P1udpGF1jyjQVBU4dgznN+k2h103vxZ7NdPyw==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.25.9': - resolution: {integrity: sha512-WYvQviPw+Qyib0v92AwNIrdLISTp7RfDkM7bPqBvpbnhY4wq8HvHBZREVdYDXk98C8BkOIVnHAY3yvj7AVISxQ==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.2.0': - resolution: {integrity: sha512-BA75MVfRlFQG2EZgFYIwyT1r6xSkwfP2bdkY/kLZusEYWiJs4xCowab/alaEaT0wSvmVuXGqiefeBlP+7V1yKg==} - - '@babel/generator@7.25.9': - resolution: {integrity: sha512-omlUGkr5EaoIJrhLf9CJ0TvjBRpd9+AXRG//0GEQ9THSo8wPiTlbpy1/Ow8ZTrbXpjd9FHXfbFQx32I04ht0FA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-annotate-as-pure@7.25.9': - resolution: {integrity: sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.25.9': - resolution: {integrity: sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-create-class-features-plugin@7.25.9': - resolution: {integrity: sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-member-expression-to-functions@7.25.9': - resolution: {integrity: sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.25.9': - resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.25.9': - resolution: {integrity: sha512-TvLZY/F3+GvdRYFZFyxMvnsKi+4oJdgZzU3BoGN9Uc2d9C6zfNwJcKKhjqLAhK8i46mv93jsO74fDh3ih6rpHA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-optimise-call-expression@7.25.9': - resolution: {integrity: sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-plugin-utils@7.25.9': - resolution: {integrity: sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-replace-supers@7.25.9': - resolution: {integrity: sha512-IiDqTOTBQy0sWyeXyGSC5TBJpGFXBkRynjBeXsvbhQFKj2viwJC76Epz35YLU1fpe/Am6Vppb7W7zM4fPQzLsQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-simple-access@7.25.9': - resolution: {integrity: sha512-c6WHXuiaRsJTyHYLJV75t9IqsmTbItYfdj99PnzYGQZkYKvan5/2jKJ7gu31J3/BJ/A18grImSPModuyG/Eo0Q==} - engines: {node: '>=6.9.0'} - - '@babel/helper-skip-transparent-expression-wrappers@7.25.9': - resolution: {integrity: sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.25.9': - resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.25.9': - resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.25.9': - resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.25.9': - resolution: {integrity: sha512-oKWp3+usOJSzDZOucZUAMayhPz/xVjzymyDzUN8dk0Wd3RWMlGLXi07UCQ/CgQVb8LvXx3XBajJH4XGgkt7H7g==} - engines: {node: '>=6.9.0'} - - '@babel/highlight@7.25.9': - resolution: {integrity: sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.25.9': - resolution: {integrity: sha512-aI3jjAAO1fh7vY/pBGsn1i9LDbRP43+asrRlkPuTXW5yHXtd1NgTEMudbBoDDxrf1daEEfPJqR+JBMakzrR4Dg==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-proposal-class-properties@7.18.6': - resolution: {integrity: sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==} - engines: {node: '>=6.9.0'} - deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-properties instead. - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-proposal-decorators@7.25.9': - resolution: {integrity: sha512-smkNLL/O1ezy9Nhy4CNosc4Va+1wo5w4gzSZeLe6y6dM4mmHfYOCPolXQPHQxonZCF+ZyebxN9vqOolkYrSn5g==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-proposal-private-methods@7.18.6': - resolution: {integrity: sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==} - engines: {node: '>=6.9.0'} - deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead. - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-decorators@7.25.9': - resolution: {integrity: sha512-ryzI0McXUPJnRCvMo4lumIKZUzhYUO/ScI+Mz4YVaTLt04DHNSjEUjKVvbzQjZFLuod/cYEc07mJWhzl6v4DPg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-jsx@7.25.9': - resolution: {integrity: sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-typescript@7.25.9': - resolution: {integrity: sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-modules-commonjs@7.25.9': - resolution: {integrity: sha512-dwh2Ol1jWwL2MgkCzUSOvfmKElqQcuswAZypBSUsScMXvgdT8Ekq5YA6TtqpTVWH+4903NmboMuH1o9i8Rxlyg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-self@7.25.9': - resolution: {integrity: sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.25.9': - resolution: {integrity: sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-typescript@7.25.9': - resolution: {integrity: sha512-7PbZQZP50tzv2KGGnhh82GSyMB01yKY9scIjf1a+GfZCtInOWqUH5+1EBU4t9fyR5Oykkkc9vFTs4OHrhHXljQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/preset-typescript@7.25.9': - resolution: {integrity: sha512-XWxw1AcKk36kgxf4C//fl0ikjLeqGUWn062/Fd8GtpTfDJOX6Ud95FK+4JlDA36BX4bNGndXi3a6Vr4Jo5/61A==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/runtime@7.25.6': - resolution: {integrity: sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==} - engines: {node: '>=6.9.0'} - - '@babel/template@7.25.9': - resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.25.9': - resolution: {integrity: sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.25.9': - resolution: {integrity: sha512-OwS2CM5KocvQ/k7dFJa8i5bNGJP0hXWfVCfDkqRFP1IreH1JDC7wG6eCYCi0+McbfT8OR/kNqsI0UU0xP9H6PQ==} - engines: {node: '>=6.9.0'} - - '@biomejs/biome@1.9.4': - resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} - engines: {node: '>=14.21.3'} - hasBin: true - - '@biomejs/cli-darwin-arm64@1.9.4': - resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [darwin] - - '@biomejs/cli-darwin-x64@1.9.4': - resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [darwin] - - '@biomejs/cli-linux-arm64-musl@1.9.4': - resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [linux] - - '@biomejs/cli-linux-arm64@1.9.4': - resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [linux] - - '@biomejs/cli-linux-x64-musl@1.9.4': - resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [linux] - - '@biomejs/cli-linux-x64@1.9.4': - resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [linux] - - '@biomejs/cli-win32-arm64@1.9.4': - resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [win32] - - '@biomejs/cli-win32-x64@1.9.4': - resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [win32] - - '@braintree/sanitize-url@7.1.0': - resolution: {integrity: sha512-o+UlMLt49RvtCASlOMW0AkHnabN9wR9rwCCherxO0yG4Npy34GkvrAqdXQvrhNs+jh+gkK8gB8Lf05qL/O7KWg==} - - '@bufbuild/protobuf@1.10.0': - resolution: {integrity: sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==} - - '@chevrotain/cst-dts-gen@11.0.3': - resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} - - '@chevrotain/gast@11.0.3': - resolution: {integrity: sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==} - - '@chevrotain/regexp-to-ast@11.0.3': - resolution: {integrity: sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==} - - '@chevrotain/types@11.0.3': - resolution: {integrity: sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==} - - '@chevrotain/utils@11.0.3': - resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} - - '@choojs/findup@0.2.1': - resolution: {integrity: sha512-YstAqNb0MCN8PjdLCDfRsBcGVRN41f3vgLvaI0IrIcBp4AqILRSS0DeWNGkicC+f/zRIPJLc+9RURVSepwvfBw==} - hasBin: true - - '@codemirror/autocomplete@6.18.3': - resolution: {integrity: sha512-1dNIOmiM0z4BIBwxmxEfA1yoxh1MF/6KPBbh20a5vphGV0ictKlgQsbJs6D6SkR6iJpGbpwRsa6PFMNlg9T9pQ==} - peerDependencies: - '@codemirror/language': ^6.0.0 - '@codemirror/state': ^6.0.0 - '@codemirror/view': ^6.0.0 - '@lezer/common': ^1.0.0 - - '@codemirror/commands@6.7.1': - resolution: {integrity: sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==} - - '@codemirror/lang-angular@0.1.2': - resolution: {integrity: sha512-Nq7lmx9SU+JyoaRcs6SaJs7uAmW2W06HpgJVQYeZptVGNWDzDvzhjwVb/ZuG1rwTlOocY4Y9GwNOBuKCeJbKtw==} - - '@codemirror/lang-cpp@6.0.2': - resolution: {integrity: sha512-6oYEYUKHvrnacXxWxYa6t4puTlbN3dgV662BDfSH8+MfjQjVmP697/KYTDOqpxgerkvoNm7q5wlFMBeX8ZMocg==} - - '@codemirror/lang-css@6.2.1': - resolution: {integrity: sha512-/UNWDNV5Viwi/1lpr/dIXJNWiwDxpw13I4pTUAsNxZdg6E0mI2kTQb0P2iHczg1Tu+H4EBgJR+hYhKiHKko7qg==} - - '@codemirror/lang-go@6.0.0': - resolution: {integrity: sha512-mMT4YeYdKGjnffDBOhr1ur1glee4oV/rfMe28vzazNHZkSt7vSiuHiBcgr3L/79Cl2RIjFdpQ1XMD0/T8Rx64g==} - - '@codemirror/lang-html@6.4.7': - resolution: {integrity: sha512-y9hWSSO41XlcL4uYwWyk0lEgTHcelWWfRuqmvcAmxfCs0HNWZdriWo/EU43S63SxEZpc1Hd50Itw7ktfQvfkUg==} - - '@codemirror/lang-java@6.0.1': - resolution: {integrity: sha512-OOnmhH67h97jHzCuFaIEspbmsT98fNdhVhmA3zCxW0cn7l8rChDhZtwiwJ/JOKXgfm4J+ELxQihxaI7bj7mJRg==} - - '@codemirror/lang-javascript@6.2.1': - resolution: {integrity: sha512-jlFOXTejVyiQCW3EQwvKH0m99bUYIw40oPmFjSX2VS78yzfe0HELZ+NEo9Yfo1MkGRpGlj3Gnu4rdxV1EnAs5A==} - - '@codemirror/lang-json@6.0.1': - resolution: {integrity: sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==} - - '@codemirror/lang-less@6.0.1': - resolution: {integrity: sha512-ABcsKBjLbyPZwPR5gePpc8jEKCQrFF4pby2WlMVdmJOOr7OWwwyz8DZonPx/cKDE00hfoSLc8F7yAcn/d6+rTQ==} - - '@codemirror/lang-lezer@6.0.1': - resolution: {integrity: sha512-WHwjI7OqKFBEfkunohweqA5B/jIlxaZso6Nl3weVckz8EafYbPZldQEKSDb4QQ9H9BUkle4PVELP4sftKoA0uQ==} - - '@codemirror/lang-liquid@6.2.0': - resolution: {integrity: sha512-DRmtaBHtAP63I5IDa1OEk00oh3NsR7DFGkvFmLc9ODqdy2uEGkcjNn+QqgbLuy3zSjQcl5cdiX2FSjOXzPx5BA==} - - '@codemirror/lang-markdown@6.3.0': - resolution: {integrity: sha512-lYrI8SdL/vhd0w0aHIEvIRLRecLF7MiiRfzXFZY94dFwHqC9HtgxgagJ8fyYNBldijGatf9wkms60d8SrAj6Nw==} - - '@codemirror/lang-php@6.0.1': - resolution: {integrity: sha512-ublojMdw/PNWa7qdN5TMsjmqkNuTBD3k6ndZ4Z0S25SBAiweFGyY68AS3xNcIOlb6DDFDvKlinLQ40vSLqf8xA==} - - '@codemirror/lang-python@6.1.6': - resolution: {integrity: sha512-ai+01WfZhWqM92UqjnvorkxosZ2aq2u28kHvr+N3gu012XqY2CThD67JPMHnGceRfXPDBmn1HnyqowdpF57bNg==} - - '@codemirror/lang-rust@6.0.1': - resolution: {integrity: sha512-344EMWFBzWArHWdZn/NcgkwMvZIWUR1GEBdwG8FEp++6o6vT6KL9V7vGs2ONsKxxFUPXKI0SPcWhyYyl2zPYxQ==} - - '@codemirror/lang-sass@6.0.2': - resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==} - - '@codemirror/lang-sql@6.8.0': - resolution: {integrity: sha512-aGLmY4OwGqN3TdSx3h6QeA1NrvaYtF7kkoWR/+W7/JzB0gQtJ+VJxewlnE3+VImhA4WVlhmkJr109PefOOhjLg==} - - '@codemirror/lang-vue@0.1.2': - resolution: {integrity: sha512-D4YrefiRBAr+CfEIM4S3yvGSbYW+N69mttIfGMEf7diHpRbmygDxS+R/5xSqjgtkY6VO6qmUrre1GkRcWeZa9A==} - - '@codemirror/lang-wast@6.0.1': - resolution: {integrity: sha512-sQLsqhRjl2MWG3rxZysX+2XAyed48KhLBHLgq9xcKxIJu3npH/G+BIXW5NM5mHeDUjG0jcGh9BcjP0NfMStuzA==} - - '@codemirror/lang-xml@6.0.2': - resolution: {integrity: sha512-JQYZjHL2LAfpiZI2/qZ/qzDuSqmGKMwyApYmEUUCTxLM4MWS7sATUEfIguZQr9Zjx/7gcdnewb039smF6nC2zw==} - - '@codemirror/lang-yaml@6.0.0': - resolution: {integrity: sha512-fVPapdX1oYr5HMC5bou1MHscGnNCvOHuhUW6C+V2gfIeIRcughvVfznV0OuUyHy0AdXoBCjOehjzFcmLRumu2Q==} - - '@codemirror/language-data@6.5.1': - resolution: {integrity: sha512-0sWxeUSNlBr6OmkqybUTImADFUP0M3P0IiSde4nc24bz/6jIYzqYSgkOSLS+CBIoW1vU8Q9KUWXscBXeoMVC9w==} - - '@codemirror/language@6.10.3': - resolution: {integrity: sha512-kDqEU5sCP55Oabl6E7m5N+vZRoc0iWqgDVhEKifcHzPzjqCegcO4amfrYVL9PmPZpl4G0yjkpTpUO/Ui8CzO8A==} - - '@codemirror/legacy-modes@6.4.1': - resolution: {integrity: sha512-vdg3XY7OAs5uLDx2Iw+cGfnwtd7kM+Et/eMsqAGTfT/JKiVBQZXosTzjEbWAi/FrY6DcQIz8mQjBozFHZEUWQA==} - - '@codemirror/lint@6.8.2': - resolution: {integrity: sha512-PDFG5DjHxSEjOXk9TQYYVjZDqlZTFaDBfhQixHnQOEVDDNHUbEh/hstAjcQJaA6FQdZTD1hquXTK0rVBLADR1g==} - - '@codemirror/merge@6.6.0': - resolution: {integrity: sha512-VXKxm8Jrv2HpEVW9CcYiV4VMVWsFz2XOk34Ve9ouqFT4iKc2r9+IGuMjyeH+g5T0HKUnRGYIv2wayMsPtao7Zw==} - - '@codemirror/search@6.5.7': - resolution: {integrity: sha512-6+iLsXvITWKHYlkgHPCs/qiX4dNzn8N78YfhOFvPtPYCkuXqZq10rAfsUMhOq7O/1VjJqdXRflyExlfVcu/9VQ==} - - '@codemirror/state@6.4.1': - resolution: {integrity: sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==} - - '@codemirror/theme-one-dark@6.1.2': - resolution: {integrity: sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==} - - '@codemirror/view@6.34.3': - resolution: {integrity: sha512-Ph5d+u8DxIeSgssXEakaakImkzBV4+slwIbcxl9oc9evexJhImeu/G8TK7+zp+IFK9KuJ0BdSn6kTBJeH2CHvA==} - - '@connectrpc/connect-web@1.4.0': - resolution: {integrity: sha512-13aO4psFbbm7rdOFGV0De2Za64DY/acMspgloDlcOKzLPPs0yZkhp1OOzAQeiAIr7BM/VOHIA3p8mF0inxCYTA==} - peerDependencies: - '@bufbuild/protobuf': ^1.4.2 - '@connectrpc/connect': 1.4.0 - - '@connectrpc/connect@1.4.0': - resolution: {integrity: sha512-vZeOkKaAjyV4+RH3+rJZIfDFJAfr+7fyYr6sLDKbYX3uuTVszhFe9/YKf5DNqrDb5cKdKVlYkGn6DTDqMitAnA==} - peerDependencies: - '@bufbuild/protobuf': ^1.4.2 - - '@csstools/css-parser-algorithms@3.0.2': - resolution: {integrity: sha512-6tC/MnlEvs5suR4Ahef4YlBccJDHZuxGsAlxXmybWjZ5jPxlzLSMlRZ9mVHSRvlD+CmtE7+hJ+UQbfXrws/rUQ==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-tokenizer': ^3.0.2 - - '@csstools/css-tokenizer@3.0.2': - resolution: {integrity: sha512-IuTRcD53WHsXPCZ6W7ubfGqReTJ9Ra0yRRFmXYP/Re8hFYYfoIYIK4080X5luslVLWimhIeFq0hj09urVMQzTw==} - engines: {node: '>=18'} - - '@csstools/media-query-list-parser@3.0.1': - resolution: {integrity: sha512-HNo8gGD02kHmcbX6PvCoUuOQvn4szyB9ca63vZHKX5A81QytgDG4oxG4IaEfHTlEZSZ6MjPEMWIVU+zF2PZcgw==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.1 - '@csstools/css-tokenizer': ^3.0.1 - - '@csstools/postcss-light-dark-function@2.0.5': - resolution: {integrity: sha512-mSqqxuwlBg10YyErq2YYB71KtvWDueBYE9WAnC6B7GHU+z0ECcGf+sR9zxpvePGzesuBNDB+cp15cW2CvOyszA==} - engines: {node: '>=18'} - peerDependencies: - postcss: ^8.4 - - '@csstools/postcss-progressive-custom-properties@4.0.0': - resolution: {integrity: sha512-XQPtROaQjomnvLUSy/bALTR5VCtTVUFwYs1SblvYgLSeTo2a/bMNwUwo2piXw5rTv/FEYiy5yPSXBqg9OKUx7Q==} - engines: {node: '>=18'} - peerDependencies: - postcss: ^8.4 - - '@csstools/selector-specificity@4.0.0': - resolution: {integrity: sha512-189nelqtPd8++phaHNwYovKZI0FOzH1vQEE3QhHHkNIGrg5fSs9CbYP3RvfEH5geztnIA9Jwq91wyOIwAW5JIQ==} - engines: {node: '>=18'} - peerDependencies: - postcss-selector-parser: ^6.1.0 - - '@csstools/utilities@2.0.0': - resolution: {integrity: sha512-5VdOr0Z71u+Yp3ozOx8T11N703wIFGVRgOWbOZMKgglPJsWA54MRIoMNVMa7shUToIhx5J8vX4sOZgD2XiihiQ==} - engines: {node: '>=18'} - peerDependencies: - postcss: ^8.4 - - '@dagrejs/dagre@1.1.4': - resolution: {integrity: sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg==} - - '@dagrejs/graphlib@2.2.4': - resolution: {integrity: sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==} - engines: {node: '>17.0.0'} - - '@dnd-kit/accessibility@3.1.1': - resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} - peerDependencies: - react: '>=16.8.0' - - '@dnd-kit/core@6.2.0': - resolution: {integrity: sha512-KVK/CJmaYGTxTPU6P0+Oy4itgffTUa80B8317sXzfOr1qUzSL29jE7Th11llXiu2haB7B9Glpzo2CDElin+geQ==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@dnd-kit/modifiers@8.0.0': - resolution: {integrity: sha512-oPZ0JoKtVSK9hVHSBDKG1oCLgnZbpxuH/SMnyDtXkNhn9+SF1+98DlWILFYxIT8faOS/GgfhlNFREmym6oqrEg==} - peerDependencies: - '@dnd-kit/core': ^6.2.0 - react: '>=16.8.0' - - '@dnd-kit/sortable@9.0.0': - resolution: {integrity: sha512-3/9r8Mmba0nKTbo8kPnVSFZKf/VSy94nXZ3aUwzPEh78j/LooQ/EFKRZENak4PHKBkN53mgTF/z+Sd8H+FcAnQ==} - peerDependencies: - '@dnd-kit/core': ^6.2.0 - react: '>=16.8.0' - - '@dnd-kit/utilities@3.2.2': - resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} - peerDependencies: - react: '>=16.8.0' - - '@dual-bundle/import-meta-resolve@4.1.0': - resolution: {integrity: sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg==} - - '@emotion/babel-plugin@11.13.5': - resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} - - '@emotion/cache@11.13.5': - resolution: {integrity: sha512-Z3xbtJ+UcK76eWkagZ1onvn/wAVb1GOMuR15s30Fm2wrMgC7jzpnO2JZXr4eujTTqoQFUrZIw/rT0c6Zzjca1g==} - - '@emotion/hash@0.9.2': - resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} - - '@emotion/is-prop-valid@1.3.1': - resolution: {integrity: sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==} - - '@emotion/memoize@0.9.0': - resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} - - '@emotion/react@11.13.0': - resolution: {integrity: sha512-WkL+bw1REC2VNV1goQyfxjx1GYJkcc23CRQkXX+vZNLINyfI7o+uUn/rTGPt/xJ3bJHd5GcljgnxHf4wRw5VWQ==} - peerDependencies: - '@types/react': '*' - react: '>=16.8.0' - peerDependenciesMeta: - '@types/react': - optional: true - - '@emotion/serialize@1.3.3': - resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} - - '@emotion/sheet@1.4.0': - resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} - - '@emotion/styled@11.13.0': - resolution: {integrity: sha512-tkzkY7nQhW/zC4hztlwucpT8QEZ6eUzpXDRhww/Eej4tFfO0FxQYWRyg/c5CCXa4d/f174kqeXYjuQRnhzf6dA==} - peerDependencies: - '@emotion/react': ^11.0.0-rc.0 - '@types/react': '*' - react: '>=16.8.0' - peerDependenciesMeta: - '@types/react': - optional: true - - '@emotion/unitless@0.10.0': - resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} - - '@emotion/use-insertion-effect-with-fallbacks@1.1.0': - resolution: {integrity: sha512-+wBOcIV5snwGgI2ya3u99D7/FJquOIniQT1IKyDsBmEgwvpxMNeS65Oib7OnE2d2aY+3BU4OiH+0Wchf8yk3Hw==} - peerDependencies: - react: '>=16.8.0' - - '@emotion/utils@1.4.2': - resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} - - '@emotion/weak-memoize@0.4.0': - resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} - - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - - '@eslint-community/eslint-utils@4.4.0': - resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.10.0': - resolution: {integrity: sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/eslintrc@2.1.4': - resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - '@eslint/eslintrc@3.1.0': - resolution: {integrity: sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/js@8.57.0': - resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - '@floating-ui/core@1.6.0': - resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==} - - '@floating-ui/dom@1.6.3': - resolution: {integrity: sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==} - - '@floating-ui/react-dom@2.0.8': - resolution: {integrity: sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@floating-ui/utils@0.2.1': - resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==} - - '@formatjs/ecma402-abstract@1.18.2': - resolution: {integrity: sha512-+QoPW4csYALsQIl8GbN14igZzDbuwzcpWrku9nyMXlaqAlwRBgl5V+p0vWMGFqHOw37czNXaP/lEk4wbLgcmtA==} - - '@formatjs/fast-memoize@2.2.0': - resolution: {integrity: sha512-hnk/nY8FyrL5YxwP9e4r9dqeM6cAbo8PeU9UjyXojZMNvVad2Z06FAVHyR3Ecw6fza+0GH7vdJgiKIVXTMbSBA==} - - '@formatjs/icu-messageformat-parser@2.7.6': - resolution: {integrity: sha512-etVau26po9+eewJKYoiBKP6743I1br0/Ie00Pb/S/PtmYfmjTcOn2YCh2yNkSZI12h6Rg+BOgQYborXk46BvkA==} - - '@formatjs/icu-skeleton-parser@1.8.0': - resolution: {integrity: sha512-QWLAYvM0n8hv7Nq5BEs4LKIjevpVpbGLAJgOaYzg9wABEoX1j0JO1q2/jVkO6CVlq0dbsxZCngS5aXbysYueqA==} - - '@formatjs/intl-localematcher@0.5.4': - resolution: {integrity: sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==} - - '@hookform/resolvers@3.9.0': - resolution: {integrity: sha512-bU0Gr4EepJ/EQsH/IwEzYLsT/PEj5C0ynLQ4m+GSHS+xKH4TfSelhluTgOaoc4kA5s7eCsQbM4wvZLzELmWzUg==} - peerDependencies: - react-hook-form: ^7.0.0 - - '@humanwhocodes/config-array@0.11.14': - resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} - engines: {node: '>=10.10.0'} - deprecated: Use @eslint/config-array instead - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/object-schema@2.0.3': - resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} - deprecated: Use @eslint/object-schema instead - - '@iconify/types@2.0.0': - resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - - '@iconify/utils@2.1.33': - resolution: {integrity: sha512-jP9h6v/g0BIZx0p7XGJJVtkVnydtbgTgt9mVNcGDYwaa7UhdHdI9dvoq+gKj9sijMSJKxUPEG2JyjsgXjxL7Kw==} - - '@internationalized/date@3.5.5': - resolution: {integrity: sha512-H+CfYvOZ0LTJeeLOqm19E3uj/4YjrmOFtBufDHPfvtI80hFAMqtrp7oCACpe4Cil5l8S0Qu/9dYfZc/5lY8WQQ==} - - '@internationalized/message@3.1.4': - resolution: {integrity: sha512-Dygi9hH1s7V9nha07pggCkvmRfDd3q2lWnMGvrJyrOwYMe1yj4D2T9BoH9I6MGR7xz0biQrtLPsqUkqXzIrBOw==} - - '@internationalized/number@3.5.3': - resolution: {integrity: sha512-rd1wA3ebzlp0Mehj5YTuTI50AQEx80gWFyHcQu+u91/5NgdwBecO8BH6ipPfE+lmQ9d63vpB3H9SHoIUiupllw==} - - '@internationalized/string@3.2.3': - resolution: {integrity: sha512-9kpfLoA8HegiWTeCbR2livhdVeKobCnVv8tlJ6M2jF+4tcMqDo94ezwlnrUANBWPgd8U7OXIHCk2Ov2qhk4KXw==} - - '@jest/schemas@29.6.3': - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/types@24.9.0': - resolution: {integrity: sha512-XKK7ze1apu5JWQ5eZjHITP66AX+QsLlbaJRBGYr8pNzwcAE2JVkwnf0yqjHTsDRcjR0mujy/NmZMXw5kl+kGBw==} - engines: {node: '>= 6'} - - '@joshwooding/vite-plugin-react-docgen-typescript@0.4.2': - resolution: {integrity: sha512-feQ+ntr+8hbVudnsTUapiMN9q8T90XA1d5jn9QzY09sNoj4iD9wi0PY1vsBFTda4ZjEaxRK9S81oarR2nj7TFQ==} - peerDependencies: - typescript: '>= 4.3.x' - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 - peerDependenciesMeta: - typescript: - optional: true - - '@jridgewell/gen-mapping@0.3.5': - resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} - engines: {node: '>=6.0.0'} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/set-array@1.2.1': - resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} - engines: {node: '>=6.0.0'} - - '@jridgewell/source-map@0.3.6': - resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} - - '@jridgewell/sourcemap-codec@1.5.0': - resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} - - '@jridgewell/trace-mapping@0.3.25': - resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - - '@juggle/resize-observer@3.4.0': - resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} - - '@lezer/common@1.2.1': - resolution: {integrity: sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==} - - '@lezer/cpp@1.1.1': - resolution: {integrity: sha512-eS1M3L3U2mDowoFVPG7tEp01SWu9/68Nx3HEBgLJVn3N9ku7g5S7WdFv0jzmcTipAyONYfZJ+7x4WRkfdB2Ung==} - - '@lezer/css@1.1.4': - resolution: {integrity: sha512-CuUwjidrU7FOBokqASRJc72SmJ9g1PsHXDOWMoKg4md6+2u/Zxzwx5YsYrAFxRDsLrjLlsIyEF1rZHK3gFEJbw==} - - '@lezer/go@1.0.0': - resolution: {integrity: sha512-co9JfT3QqX1YkrMmourYw2Z8meGC50Ko4d54QEcQbEYpvdUvN4yb0NBZdn/9ertgvjsySxHsKzH3lbm3vqJ4Jw==} - - '@lezer/highlight@1.2.1': - resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==} - - '@lezer/html@1.3.7': - resolution: {integrity: sha512-Wo+rZ5UjLP0VqUTyXjzgmTYRW5bvTJUFn4Uw0K3HCQjX2/+f+zRo9GLN5BCAojwHQISPvaQk8BWSv2SSKx/UcQ==} - - '@lezer/java@1.1.0': - resolution: {integrity: sha512-RmBS+P9LJ4P9SU7ZewahMkBUNOEz1zQTMOKLq8OEa7ge8FZxz7mKjoEIGewRSn9kom1z5GKQjtxNE++m6u8SYw==} - - '@lezer/javascript@1.4.18': - resolution: {integrity: sha512-Y8BeHOt4LtcxJgXwadtfSeWPrh0XzklcCHnCVT+vOsxqH4gWmunP2ykX+VVOlM/dusyVyiNfG3lv0f10UK+mgA==} - - '@lezer/json@1.0.1': - resolution: {integrity: sha512-nkVC27qiEZEjySbi6gQRuMwa2sDu2PtfjSgz0A4QF81QyRGm3kb2YRzLcOPcTEtmcwvrX/cej7mlhbwViA4WJw==} - - '@lezer/lezer@1.1.2': - resolution: {integrity: sha512-O8yw3CxPhzYHB1hvwbdozjnAslhhR8A5BH7vfEMof0xk3p+/DFDfZkA9Tde6J+88WgtwaHy4Sy6ThZSkaI0Evw==} - - '@lezer/lr@1.4.2': - resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==} - - '@lezer/markdown@1.1.1': - resolution: {integrity: sha512-kmxvLnXCogUx2mexslWwVME1W2GQP76pwUODcFXLsuubuK41TcEJhKRm2FTKugNfEkUIspJNq9+jNo6b7dxVLA==} - - '@lezer/php@1.0.1': - resolution: {integrity: sha512-aqdCQJOXJ66De22vzdwnuC502hIaG9EnPK2rSi+ebXyUd+j7GAX1mRjWZOVOmf3GST1YUfUCu6WXDiEgDGOVwA==} - - '@lezer/python@1.1.7': - resolution: {integrity: sha512-RbhKQ9+Y/r/Xv6OcJmETEM5tBFdpdAJRqrgi3akJkWBLCuiAaLP/jKdYzu+ICljaSXPCQeznrv+r9HUEnjq3HQ==} - - '@lezer/rust@1.0.1': - resolution: {integrity: sha512-j+ToFKM6Wpglv3OQ4ebHYdYIMT2dh0ziCCV0rTf47AWiHOVhR0WjaKrBq+yuvDQNEhr5sxPxVI7+naJIgpqcsQ==} - - '@lezer/sass@1.0.3': - resolution: {integrity: sha512-n4l2nVOB7gWiGU/Cg2IVxpt2Ic9Hgfgy/7gk+p/XJibAsPXs0lSbsfGwQgwsAw9B/euYo3oS6lEFr9WytoqcZg==} - - '@lezer/xml@1.0.3': - resolution: {integrity: sha512-lYT+0TKiDW4jQyz+R/JXQpbWavjIKqyFMvJ73JklyxFmAUnD6Iford98dk0OeVZG0j4MNnR7JQlmwTMpJGrU6A==} - - '@lezer/yaml@1.0.2': - resolution: {integrity: sha512-XCkwuxe+eumJ28nA9e1S6XKsXz9W7V/AG+WBiWOtiIuUpKcZ/bHuvN8bLxSDREIcybSRpEd/jvphh4vgm6Ed2g==} - - '@mapbox/geojson-rewind@0.5.2': - resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==} - hasBin: true - - '@mapbox/geojson-types@1.0.2': - resolution: {integrity: sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==} - - '@mapbox/jsonlint-lines-primitives@2.0.2': - resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==} - engines: {node: '>= 0.6'} - - '@mapbox/mapbox-gl-supported@1.5.0': - resolution: {integrity: sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==} - peerDependencies: - mapbox-gl: '>=0.32.1 <2.0.0' - - '@mapbox/point-geometry@0.1.0': - resolution: {integrity: sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==} - - '@mapbox/tiny-sdf@1.2.5': - resolution: {integrity: sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==} - - '@mapbox/tiny-sdf@2.0.6': - resolution: {integrity: sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA==} - - '@mapbox/unitbezier@0.0.0': - resolution: {integrity: sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==} - - '@mapbox/unitbezier@0.0.1': - resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==} - - '@mapbox/vector-tile@1.3.1': - resolution: {integrity: sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==} - - '@mapbox/whoots-js@3.1.0': - resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==} - engines: {node: '>=6.0.0'} - - '@maplibre/maplibre-gl-style-spec@20.4.0': - resolution: {integrity: sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==} - hasBin: true - - '@marimo-team/marimo-api@file:../openapi': - resolution: {directory: ../openapi, type: directory} - - '@marimo-team/react-slotz@0.1.8': - resolution: {integrity: sha512-Lp3EobxEXKoYBv7jpyv6FwnplYxOQTPAFnAjiAOw4PgwL5DvULLCVpxa5C57AQ06e05Ykz57dUVbBE6iYo5+QA==} - peerDependencies: - react: ^18 - - '@mdx-js/react@3.0.1': - resolution: {integrity: sha512-9ZrPIU4MGf6et1m1ov3zKf+q9+deetI51zprKB1D/z3NOb+rUxxtEl3mCjW5wTGh6VhRdwPueh1oRzi6ezkA8A==} - peerDependencies: - '@types/react': '>=16' - react: '>=16' - - '@mermaid-js/parser@0.3.0': - resolution: {integrity: sha512-HsvL6zgE5sUPGgkIDlmAWR1HTNHz2Iy11BAWPTa4Jjabkpguy4Ze2gzfLrg6pdRuBvFwgUYyxiaNqZwrEEXepA==} - - '@mui/core-downloads-tracker@5.16.7': - resolution: {integrity: sha512-RtsCt4Geed2/v74sbihWzzRs+HsIQCfclHeORh5Ynu2fS4icIKozcSubwuG7vtzq2uW3fOR1zITSP84TNt2GoQ==} - - '@mui/material@5.16.7': - resolution: {integrity: sha512-cwwVQxBhK60OIOqZOVLFt55t01zmarKJiJUWbk0+8s/Ix5IaUzAShqlJchxsIQ4mSrWqgcKCCXKtIlG5H+/Jmg==} - engines: {node: '>=12.0.0'} - peerDependencies: - '@emotion/react': ^11.5.0 - '@emotion/styled': ^11.3.0 - '@types/react': ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 - react-dom: ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@emotion/react': - optional: true - '@emotion/styled': - optional: true - '@types/react': - optional: true - - '@mui/private-theming@5.16.6': - resolution: {integrity: sha512-rAk+Rh8Clg7Cd7shZhyt2HGTTE5wYKNSJ5sspf28Fqm/PZ69Er9o6KX25g03/FG2dfpg5GCwZh/xOojiTfm3hw==} - engines: {node: '>=12.0.0'} - peerDependencies: - '@types/react': ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - '@mui/styled-engine@5.16.6': - resolution: {integrity: sha512-zaThmS67ZmtHSWToTiHslbI8jwrmITcN93LQaR2lKArbvS7Z3iLkwRoiikNWutx9MBs8Q6okKvbZq1RQYB3v7g==} - engines: {node: '>=12.0.0'} - peerDependencies: - '@emotion/react': ^11.4.1 - '@emotion/styled': ^11.3.0 - react: ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@emotion/react': - optional: true - '@emotion/styled': - optional: true - - '@mui/system@5.16.7': - resolution: {integrity: sha512-Jncvs/r/d/itkxh7O7opOunTqbbSSzMTHzZkNLM+FjAOg+cYAZHrPDlYe1ZGKUYORwwb2XexlWnpZp0kZ4AHuA==} - engines: {node: '>=12.0.0'} - peerDependencies: - '@emotion/react': ^11.5.0 - '@emotion/styled': ^11.3.0 - '@types/react': ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@emotion/react': - optional: true - '@emotion/styled': - optional: true - '@types/react': - optional: true - - '@mui/types@7.2.19': - resolution: {integrity: sha512-6XpZEM/Q3epK9RN8ENoXuygnqUQxE+siN/6rGRi2iwJPgBUR25mphYQ9ZI87plGh58YoZ5pp40bFvKYOCDJ3tA==} - peerDependencies: - '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - '@mui/utils@5.16.6': - resolution: {integrity: sha512-tWiQqlhxAt3KENNiSRL+DIn9H5xNVK6Jjf70x3PnfQPz1MPBdh7yyIcAyVBT9xiw7hP3SomRhPR7hzBMBCjqEA==} - engines: {node: '>=12.0.0'} - peerDependencies: - '@types/react': ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - '@nextjournal/lang-clojure@1.0.0': - resolution: {integrity: sha512-gOCV71XrYD0DhwGoPMWZmZ0r92/lIHsqQu9QWdpZYYBwiChNwMO4sbVMP7eTuAqffFB2BTtCSC+1skSH9d3bNg==} - - '@nextjournal/lezer-clojure@1.0.0': - resolution: {integrity: sha512-VZyuGu4zw5mkTOwQBTaGVNWmsOZAPw5ZRxu1/Knk/Xfs7EDBIogwIs5UXTYkuECX5ZQB8eOB+wKA2pc7VyqaZQ==} - - '@noble/hashes@1.4.0': - resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} - engines: {node: '>= 16'} - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@open-rpc/client-js@1.8.1': - resolution: {integrity: sha512-vV+Hetl688nY/oWI9IFY0iKDrWuLdYhf7OIKI6U1DcnJV7r4gAgwRJjEr1QVYszUc0gjkHoQJzqevmXMGLyA0g==} - - '@opentelemetry/api@1.9.0': - resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} - engines: {node: '>=8.0.0'} - - '@paralleldrive/cuid2@2.2.2': - resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==} - - '@playwright/test@1.46.1': - resolution: {integrity: sha512-Fq6SwLujA/DOIvNC2EL/SojJnkKf/rAwJ//APpJJHRyMi1PdKrY3Az+4XNQ51N4RTbItbIByQ0jgd1tayq1aeA==} - engines: {node: '>=18'} - hasBin: true - - '@plotly/d3-sankey-circular@0.33.1': - resolution: {integrity: sha512-FgBV1HEvCr3DV7RHhDsPXyryknucxtfnLwPtCKKxdolKyTFYoLX/ibEfX39iFYIL7DYbVeRtP43dbFcrHNE+KQ==} - - '@plotly/d3-sankey@0.7.2': - resolution: {integrity: sha512-2jdVos1N3mMp3QW0k2q1ph7Gd6j5PY1YihBrwpkFnKqO+cqtZq3AdEYUeSGXMeLsBDQYiqTVcihYfk8vr5tqhw==} - - '@plotly/d3@3.8.2': - resolution: {integrity: sha512-wvsNmh1GYjyJfyEBPKJLTMzgf2c2bEbSIL50lmqVUi+o1NHaLPi1Lb4v7VxXXJn043BhNyrxUrWI85Q+zmjOVA==} - - '@plotly/mapbox-gl@1.13.4': - resolution: {integrity: sha512-sR3/Pe5LqT/fhYgp4rT4aSFf1rTsxMbGiH6Hojc7PH36ny5Bn17iVFUjpzycafETURuFbLZUfjODO8LvSI+5zQ==} - engines: {node: '>=6.4.0'} - - '@plotly/point-cluster@3.1.9': - resolution: {integrity: sha512-MwaI6g9scKf68Orpr1pHZ597pYx9uP8UEFXLPbsCmuw3a84obwz6pnMXGc90VhgDNeNiLEdlmuK7CPo+5PIxXw==} - - '@plotly/regl@2.1.2': - resolution: {integrity: sha512-Mdk+vUACbQvjd0m/1JJjOOafmkp/EpmHjISsopEz5Av44CBq7rPC05HHNbYGKVyNUF2zmEoBS/TT0pd0SPFFyw==} - - '@popperjs/core@2.11.8': - resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} - - '@radix-ui/colors@3.0.0': - resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} - - '@radix-ui/number@1.0.1': - resolution: {integrity: sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==} - - '@radix-ui/number@1.1.0': - resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==} - - '@radix-ui/primitive@1.0.1': - resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==} - - '@radix-ui/primitive@1.1.0': - resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==} - - '@radix-ui/react-accordion@1.1.2': - resolution: {integrity: sha512-fDG7jcoNKVjSK6yfmuAs0EnPDro0WMXIhMtXdTBWqEioVW206ku+4Lw07e+13lUkFkpoEQ2PdeMIAGpdqEAmDg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-alert-dialog@1.0.5': - resolution: {integrity: sha512-OrVIOcZL0tl6xibeuGt5/+UxoT2N27KCFOPjFyfXMnchxSHZ/OW7cCX2nGlIYJrbHK/fczPcFzAwvNBB6XBNMA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-arrow@1.0.3': - resolution: {integrity: sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-checkbox@1.0.4': - resolution: {integrity: sha512-CBuGQa52aAYnADZVt/KBQzXrwx6TqnlwtcIPGtVt5JkkzQwMOLJjPukimhfKEr4GQNd43C+djUh5Ikopj8pSLg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-collapsible@1.0.3': - resolution: {integrity: sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-collection@1.0.3': - resolution: {integrity: sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-compose-refs@1.0.1': - resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-compose-refs@1.1.0': - resolution: {integrity: sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-context-menu@2.1.5': - resolution: {integrity: sha512-R5XaDj06Xul1KGb+WP8qiOh7tKJNz2durpLBXAGZjSVtctcRFCuEvy2gtMwRJGePwQQE5nV77gs4FwRi8T+r2g==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-context@1.0.1': - resolution: {integrity: sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-context@1.1.1': - resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-dialog@1.0.5': - resolution: {integrity: sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-direction@1.0.1': - resolution: {integrity: sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-direction@1.1.0': - resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-dismissable-layer@1.0.5': - resolution: {integrity: sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-dropdown-menu@2.0.6': - resolution: {integrity: sha512-i6TuFOoWmLWq+M/eCLGd/bQ2HfAX1RJgvrBQ6AQLmzfvsLdefxbWu8G9zczcPFfcSPehz9GcpF6K9QYreFV8hA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-focus-guards@1.0.1': - resolution: {integrity: sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-focus-scope@1.0.4': - resolution: {integrity: sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-icons@1.3.2': - resolution: {integrity: sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==} - peerDependencies: - react: ^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc - - '@radix-ui/react-id@1.0.1': - resolution: {integrity: sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-label@2.0.2': - resolution: {integrity: sha512-N5ehvlM7qoTLx7nWPodsPYPgMzA5WM8zZChQg8nyFJKnDO5WHdba1vv5/H6IO5LtJMfD2Q3wh1qHFGNtK0w3bQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-menu@2.0.6': - resolution: {integrity: sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-navigation-menu@1.1.4': - resolution: {integrity: sha512-Cc+seCS3PmWmjI51ufGG7zp1cAAIRqHVw7C9LOA2TZ+R4hG6rDvHcTqIsEEFLmZO3zNVH72jOOE7kKNy8W+RtA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-popover@1.0.7': - resolution: {integrity: sha512-shtvVnlsxT6faMnK/a7n0wptwBD23xc1Z5mdrtKLwVEfsEMXodS0r5s0/g5P0hX//EKYZS2sxUjqfzlg52ZSnQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-popper@1.1.3': - resolution: {integrity: sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-portal@1.0.4': - resolution: {integrity: sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-presence@1.0.1': - resolution: {integrity: sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-presence@1.1.1': - resolution: {integrity: sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-primitive@1.0.3': - resolution: {integrity: sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-primitive@2.0.0': - resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-progress@1.0.3': - resolution: {integrity: sha512-5G6Om/tYSxjSeEdrb1VfKkfZfn/1IlPWd731h2RfPuSbIfNUgfqAwbKfJCg/PP6nuUCTrYzalwHSpSinoWoCag==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-radio-group@1.1.3': - resolution: {integrity: sha512-x+yELayyefNeKeTx4fjK6j99Fs6c4qKm3aY38G3swQVTN6xMpsrbigC0uHs2L//g8q4qR7qOcww8430jJmi2ag==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-roving-focus@1.0.4': - resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-scroll-area@1.2.1': - resolution: {integrity: sha512-FnM1fHfCtEZ1JkyfH/1oMiTcFBQvHKl4vD9WnpwkLgtF+UmnXMCad6ECPTaAjcDjam+ndOEJWgHyKDGNteWSHw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-select@2.0.0': - resolution: {integrity: sha512-RH5b7af4oHtkcHS7pG6Sgv5rk5Wxa7XI8W5gvB1N/yiuDGZxko1ynvOiVhFM7Cis2A8zxF9bTOUVbRDzPepe6w==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-slider@1.1.2': - resolution: {integrity: sha512-NKs15MJylfzVsCagVSWKhGGLNR1W9qWs+HtgbmjjVUB3B9+lb3PYoXxVju3kOrpf0VKyVCtZp+iTwVoqpa1Chw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-slot@1.0.2': - resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-slot@1.1.0': - resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-switch@1.0.3': - resolution: {integrity: sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-tabs@1.0.4': - resolution: {integrity: sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-toast@1.1.5': - resolution: {integrity: sha512-fRLn227WHIBRSzuRzGJ8W+5YALxofH23y0MlPLddaIpLpCDqdE0NZlS2NRQDRiptfxDeeCjgFIpexB1/zkxDlw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-toggle@1.0.3': - resolution: {integrity: sha512-Pkqg3+Bc98ftZGsl60CLANXQBBQ4W3mTFS9EJvNxKMZ7magklKV69/id1mlAlOFDDfHvlCms0fx8fA4CMKDJHg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-tooltip@1.0.7': - resolution: {integrity: sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-use-callback-ref@1.0.1': - resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-callback-ref@1.1.0': - resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-controllable-state@1.0.1': - resolution: {integrity: sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-escape-keydown@1.0.3': - resolution: {integrity: sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-layout-effect@1.0.1': - resolution: {integrity: sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-layout-effect@1.1.0': - resolution: {integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-previous@1.0.1': - resolution: {integrity: sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-rect@1.0.1': - resolution: {integrity: sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-size@1.0.1': - resolution: {integrity: sha512-ibay+VqrgcaI6veAojjofPATwledXiSmX+C0KrBk/xgpX9rBzPV3OsfwlhQdUOFbh+LKQorLYT+xTXW9V8yd0g==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-visually-hidden@1.0.3': - resolution: {integrity: sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/rect@1.0.1': - resolution: {integrity: sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ==} - - '@react-aria/breadcrumbs@3.5.16': - resolution: {integrity: sha512-OXLKKu4SmjnSaSHkk4kow5/aH/SzlHWPJt+Uq3xec9TwDOr/Ob8aeFVGFoY0HxfGozuQlUz+4e+d29vfA0jNWg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/button@3.9.8': - resolution: {integrity: sha512-MdbMQ3t5KSCkvKtwYd/Z6sgw0v+r1VQFRYOZ4L53xOkn+u140z8vBpNeWKZh/45gxGv7SJn9s2KstLPdCWmIxw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/calendar@3.5.11': - resolution: {integrity: sha512-VLhBovLVu3uJXBkHbgEippmo/K58QLcc/tSJQ0aJUNyHsrvPgHEcj484cb+Uj/yOirXEIzaoW6WEvhcdKrb49Q==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/checkbox@3.14.6': - resolution: {integrity: sha512-LICY1PR3WsW/VbuLMjZbxo75+poeo3XCXGcUnk6hxMlWfp/Iy/XHVsHlGu9stRPKRF8BSuOGteaHWVn6IXfwtA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/collections@3.0.0-alpha.4': - resolution: {integrity: sha512-chMNAlsubnpErBWN7sLhmAMOnE7o17hSfq3s0VDHlvRN9K/mPOPlYokmyWkkPqi7fYiR50EPVHDtwTWLJoqfnw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/color@3.0.0-rc.2': - resolution: {integrity: sha512-h4P7LocDEHPOEWgHYb8VPJLRGkyMhcsXemmvGao6G23zGTpTX8Nr6pEuJhcXQlGWt8hXvj/ASnC750my+zb1yA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/combobox@3.10.2': - resolution: {integrity: sha512-2srEtgf4mJNniSISvgYB7nZDQldMRoqSNjAFX7ftr1TBRTeiEdo27WFAA9G8UHwDGtE8+8llEePxG4FkTVYPIw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/datepicker@3.11.2': - resolution: {integrity: sha512-6sbLln3VXSBcBRDgSACBzIzF/5KV5NlNOhZvXPFE6KqFw6GbevjZQTv5BNDXiwA3CQoawIRF7zgRvTANw8HkNA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/dialog@3.5.17': - resolution: {integrity: sha512-lvfEgaqg922J1hurscqCS600OZQVitGtdpo81kAefJaUzMnCxzrYviyT96aaW0simHOlimbYF5js8lxBLZJRaw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/dnd@3.7.2': - resolution: {integrity: sha512-NuE3EGqoBbe9aXAO9mDfbu4kMO7S4MCgkjkCqYi16TWfRUf38ajQbIlqodCx91b3LVN3SYvNbE3D4Tj5ebkljw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/focus@3.18.2': - resolution: {integrity: sha512-Jc/IY+StjA3uqN73o6txKQ527RFU7gnG5crEl5Xy3V+gbYp2O5L3ezAo/E0Ipi2cyMbG6T5Iit1IDs7hcGu8aw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/form@3.0.8': - resolution: {integrity: sha512-8S2QiyUdAgK43M3flohI0R+2rTyzH088EmgeRArA8euvJTL16cj/oSOKMEgWVihjotJ9n6awPb43ZhKboyNsMg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/grid@3.10.2': - resolution: {integrity: sha512-Uj/+m8924fdu3TDR/LMoGEs+Fo+UcMsu02sQix1RgVWialvc0JUpt85rjC79+7ZzwPDGOHsyWLeQcDqv930yqA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/gridlist@3.9.2': - resolution: {integrity: sha512-PED9DaOL51o/1dtgsjl7WM52TAR6hmekOaHBD2085OmppnmnwRUBGWJb7lG939+fNKO26NJPSVqnI36Oq8wwtA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/i18n@3.12.2': - resolution: {integrity: sha512-PvEyC6JWylTpe8dQEWqQwV6GiA+pbTxHQd//BxtMSapRW3JT9obObAnb/nFhj3HthkUvqHyj0oO1bfeN+mtD8A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/interactions@3.22.2': - resolution: {integrity: sha512-xE/77fRVSlqHp2sfkrMeNLrqf2amF/RyuAS6T5oDJemRSgYM3UoxTbWjucPhfnoW7r32pFPHHgz4lbdX8xqD/g==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/label@3.7.11': - resolution: {integrity: sha512-REgejE5Qr8cXG/b8H2GhzQmjQlII/0xQW/4eDzydskaTLvA7lF5HoJUE6biYTquH5va38d8XlH465RPk+bvHzA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/link@3.7.4': - resolution: {integrity: sha512-E8SLDuS9ssm/d42+3sDFNthfMcNXMUrT2Tq1DIZt22EsMcuEzmJ9B0P7bDP5RgvIw05xVGqZ20nOpU4mKTxQtA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/listbox@3.13.2': - resolution: {integrity: sha512-8heK/6c1ooO2vT9bXtrSniOCOoNEMREsI/GE/lUfw4slo4obbzIx34B5rX2uEXvPUAuF+8NvESNv4APmRNo89g==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/live-announcer@3.3.4': - resolution: {integrity: sha512-w8lxs35QrRrn6pBNzVfyGOeqWdxeVKf9U6bXIVwhq7rrTqRULL8jqy8RJIMfIs1s8G5FpwWYjyBOjl2g5Cu1iA==} - - '@react-aria/menu@3.15.2': - resolution: {integrity: sha512-r4Rl+0yuSI+MAnDvqZTiTzJH6B6QDHaQEqUpzRdYPUqd7QY+DcziVz+CcId85V9hrZSuxWMCpQIy9QzxTPWAgQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/meter@3.4.16': - resolution: {integrity: sha512-hJqKnEE6mmK2Psx5kcI7NZ44OfTg0Bp7DatQSQ4zZE4yhnykRRwxqSKjze37tPR63cCqgRXtQ5LISfBfG54c0Q==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/numberfield@3.11.6': - resolution: {integrity: sha512-nvEWiQcWRwj6O2JXmkXEeWoBX/GVZT9zumFJcew3XknGTWJUr3h2AOymIQFt9g4mpag8IgOFEpSIlwhtZHdp1A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/overlays@3.23.2': - resolution: {integrity: sha512-vjlplr953YAuJfHiP4O+CyrTlr6OaFgXAGrzWq4MVMjnpV/PT5VRJWYFHR0sUGlHTPqeKS4NZbi/xCSgl/3pGQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/progress@3.4.16': - resolution: {integrity: sha512-RbDIFQg4+/LG+KYZeLAijt2zH7K2Gp0CY9RKWdho3nU5l3/w57Fa7NrfDGWtpImrt7bR2nRmXMA6ESfr7THfrg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/radio@3.10.7': - resolution: {integrity: sha512-o2tqIe7xd1y4HeCBQfz/sXIwLJuI6LQbVoCQ1hgk/5dGhQ0LiuXohRYitGRl9zvxW8jYdgLULmOEDt24IflE8A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/searchfield@3.7.8': - resolution: {integrity: sha512-SsF5xwH8Us548QgzivvbM7nhFbw7pu23xnRRIuhlP3MwOR3jRUFh17NKxf3Z0jvrDv/u0xfm3JKHIgaUN0KJ2A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/select@3.14.8': - resolution: {integrity: sha512-8piIhc/kT11+joBde845WHd2c82M2rm0yi3QSU/OR6YAgmTM+kz4nW0Lsns/9Jcz9I0DUvYYjzlhi1zSDbu+Ig==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/selection@3.19.2': - resolution: {integrity: sha512-twQ/mOIfdtcq6edh13Fc9G8AqNShGwnDkRV1m0xo++5Ru/uUytWDBGbUa7iTGR3yOPvijMyQPNqIjbCL+Q1ApA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/separator@3.4.2': - resolution: {integrity: sha512-Xql9Kg3VlGesEUC7QheE+L5b3KgBv0yxiUU+/4JP8V2vfU/XSz4xmprHEeq7KVQVOetn38iiXU8gA5g26SEsUA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/slider@3.7.11': - resolution: {integrity: sha512-2WAwjANXPsA2LHJ5nxxV4c7ihFAzz2spaBz8+FJ7MDYE7WroYnE8uAXElea1aGo+Lk0DTiAdepLpBkggqPNanw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/spinbutton@3.6.8': - resolution: {integrity: sha512-OJMAYRIZ0WrWE+5tZsywrSg4t+aOwl6vl/e1+J64YcGMM+p+AKd61KGG5T0OgNSORXjoVIZOmj6wZ6Od4xfPMw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/ssr@3.9.5': - resolution: {integrity: sha512-xEwGKoysu+oXulibNUSkXf8itW0npHHTa6c4AyYeZIJyRoegeteYuFpZUBPtIDE8RfHdNsSmE1ssOkxRnwbkuQ==} - engines: {node: '>= 12'} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/switch@3.6.7': - resolution: {integrity: sha512-yBNvKylhc3ZRQ0+7mD0mIenRRe+1yb8YaqMMZr8r3Bf87LaiFtQyhRFziq6ZitcwTJz5LEWjBihxbSVvUrf49w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/table@3.15.2': - resolution: {integrity: sha512-Vsfk5xwvY+bjyiusvr497A/cKnF9PyC8idGdnEI01FSSRYEXy+gtm7AhXe7REyAAqm7e7zAoGyVEXEpXnJJajg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/tabs@3.9.4': - resolution: {integrity: sha512-2RhkYvQhvd72MyADIxxp4jzDWzOLelLB1TwcZIJGO7znn2OGDJNgvuxYeWGqe8r4ComK/Z8wnFeqxQ+Ed1UNQw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/tag@3.4.4': - resolution: {integrity: sha512-yYu34xk7zBUhV17xzH/LF6JplWN0l9CtaFsGIroEznrLELMCymBkglcIPyWR5OOVTWkn1aW+smN0q7pdkAjCaA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/textfield@3.14.8': - resolution: {integrity: sha512-FHEvsHdE1cMR2B7rlf+HIneITrC40r201oLYbHAp3q26jH/HUujzFBB9I20qhXjyBohMWfQLqJhSwhs1VW1RJQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/toggle@3.10.7': - resolution: {integrity: sha512-/RJQU8QlPZXRElZ3Tt10F5K5STgUBUGPpfuFUGuwF3Kw3GpPxYsA1YAVjxXz2MMGwS0+y6+U/J1xIs1AF0Jwzg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/toolbar@3.0.0-beta.8': - resolution: {integrity: sha512-nMlA1KK54/Kohb3HlHAzobg69PVIEr8Q1j5P3tLd9apY8FgGvnz7yLpcj6kO1GA872gseEzgiO0Rzk+yRHQRCA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/tooltip@3.7.7': - resolution: {integrity: sha512-UOTTDbbUz7OaE48VjNSWl+XQbYCUs5Gss4I3Tv1pfRLXzVtGYXv3ur/vRayvZR0xd12ANY26fZPNkSmCFpmiXw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/tree@3.0.0-alpha.4': - resolution: {integrity: sha512-a35/clOnkMIF6vLJQWRHJfUzZLAld+XYRZ/vbs+FihZZYgVxgfGT6QKxf4yMPKVx5JKrtkFJ/UCxV+pUPcSyyg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/utils@3.25.2': - resolution: {integrity: sha512-GdIvG8GBJJZygB4L2QJP1Gabyn2mjFsha73I2wSe+o4DYeGWoJiMZRM06PyTIxLH4S7Sn7eVDtsSBfkc2VY/NA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/virtualizer@4.0.2': - resolution: {integrity: sha512-HNhpZl53UM2Z8g0DNvjAW7aZRwOReYgKRxdTF/IlYHNMLpdqWZinKwLbxZCsbgX3SCjdIGns90YhkMSKVpfrpw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/visually-hidden@3.8.15': - resolution: {integrity: sha512-l+sJ7xTdD5Sd6+rDNDaeJCSPnHOsI+BaJyApvb/YcVgHa7rB47lp6TXCWUCDItcPY4JqRGyeByRJVrtzBFTWCw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-dnd/asap@4.0.1': - resolution: {integrity: sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==} - - '@react-dnd/invariant@2.0.0': - resolution: {integrity: sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==} - - '@react-dnd/shallowequal@2.0.0': - resolution: {integrity: sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==} - - '@react-stately/calendar@3.5.4': - resolution: {integrity: sha512-R2011mtFSXIjzMXaA+CZ1sflPm9XkTBMqVk77Bnxso2ZsG7FUX8nqFmaDavxwTuHFC6OUexAGSMs8bP9KycTNg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/checkbox@3.6.8': - resolution: {integrity: sha512-c8TWjU67XHHBCpqj6+FXXhQUWGr2Pil1IKggX81pkedhWiJl3/7+WHJuZI0ivGnRjp3aISNOG8UNVlBEjS9E8A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/collections@3.10.9': - resolution: {integrity: sha512-plyrng6hOQMG8LrjArMA6ts/DgWyXln3g90/hFNbqe/hdVYF53sDVsj8Jb+5LtoYTpiAlV6eOvy1XR0vPZUf8w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/color@3.7.2': - resolution: {integrity: sha512-tNJ7pQjBqXtfASdLRjIYzeI8q0b3JtxqkJbusyEEdLAumpcWkbOvl3Vp9un0Bu/XXWihDa4v2dEdpKxjM+pPxg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/combobox@3.9.2': - resolution: {integrity: sha512-ZsbAcD58IvxZqwYxg9d2gOf8R/k5RUB2TPUiGKD6wgWfEKH6SDzY3bgRByHGOyMCyJB62cHjih/ZShizNTguqA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/data@3.11.6': - resolution: {integrity: sha512-S8q1Ejuhijl8SnyVOdDNFrMrWWnLk/Oh1ZT3KHSbTdpfMRtvhi5HukoiP06jlzz75phnpSPQL40npDtUB/kk3Q==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/datepicker@3.10.2': - resolution: {integrity: sha512-pa5IZUw+49AyOnddwu4XwU2kI5eo/1thbiIVNHP8uDpbbBrBkquSk3zVFDAGX1cu/I1U2VUkt64U/dxgkwaMQw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/dnd@3.4.2': - resolution: {integrity: sha512-VrHmNoNdVGrx5JHdz/zewmN+N8rlZe+vL/iAOLmvQ74RRLEz8KDFnHdlhgKg1AZqaSg3JJ18BlHEkS7oL1n+tA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/flags@3.0.3': - resolution: {integrity: sha512-/ha7XFA0RZTQsbzSPwu3KkbNMgbvuM0GuMTYLTBWpgBrovBNTM+QqI/PfZTdHg8PwCYF4H5Y8gjdSpdulCvJFw==} - - '@react-stately/form@3.0.5': - resolution: {integrity: sha512-J3plwJ63HQz109OdmaTqTA8Qhvl3gcYYK7DtgKyNP6mc/Me2Q4tl2avkWoA+22NRuv5m+J8TpBk4AVHUEOwqeQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/grid@3.9.2': - resolution: {integrity: sha512-2gK//sqAqg2Xaq6UITTFQwFUJnBRgcW+cKBVbFt+F8d152xB6UwwTS/K79E5PUkOotwqZgTEpkrSFs/aVxCLpw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/layout@4.0.2': - resolution: {integrity: sha512-g3IOrYQcaWxWKW44fYCOLoLMYKEmoOAcT9vQIbgK8MLTQV9Zgt9sGREwn4WJPm85N58Ij6yP72aQ7og/PSymvg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/list@3.10.8': - resolution: {integrity: sha512-rHCiPLXd+Ry3ztR9DkLA5FPQeH4Zd4/oJAEDWJ77W3oBBOdiMp3ZdHDLP7KBRh17XGNLO/QruYoHWAQTPiMF4g==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/menu@3.8.2': - resolution: {integrity: sha512-lt6hIHmSixMzkKx1rKJf3lbAf01EmEvvIlENL20GLiU9cRbpPnPJ1aJMZ5Ad5ygglA7wAemAx+daPhlTQfF2rg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/numberfield@3.9.6': - resolution: {integrity: sha512-p2R9admGLI439qZzB39dyANhkruprJJtZwuoGVtxW/VD0ficw6BrPVqAaKG25iwKPkmveleh9p8o+yRqjGedcQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/overlays@3.6.10': - resolution: {integrity: sha512-XxZ2qScT5JPwGk9qiVJE4dtVh3AXTcYwGRA5RsHzC26oyVVsegPqY2PmNJGblAh6Q57VyodoVUyebE0Eo5CzRw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/radio@3.10.7': - resolution: {integrity: sha512-ZwGzFR+sGd42DxRlDTp3G2vLZyhMVtgHkwv2BxazPHxPMvLO9yYl7+3PPNxAmhMB4tg2u9CrzffpGX2rmEJEXA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/searchfield@3.5.6': - resolution: {integrity: sha512-gVzU0FeWiLYD8VOYRgWlk79Qn7b2eirqOnWhtI5VNuGN8WyNaCIuBp6SkXTW2dY8hs2Hzn8HlMbgy1MIc7130Q==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/select@3.6.7': - resolution: {integrity: sha512-hCUIddw0mPxVy1OH6jhyaDwgNea9wESjf+MYdnnTG/abRB+OZv/dWScd87OjzVsHTHWcw7CN4ZzlJoXm0FJbKQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/selection@3.16.2': - resolution: {integrity: sha512-C4eSKw7BIZHJLPzwqGqCnsyFHiUIEyryVQZTJDt6d0wYBOHU6k1pW+Q4VhrZuzSv+IMiI2RkiXeJKc55f0ZXrg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/slider@3.5.7': - resolution: {integrity: sha512-gEIGTcpBLcXixd8LYiLc8HKrBiGQJltrrEGoOvvTP8KVItXQxmeL+JiSsh8qgOoUdRRpzmAoFNUKGEg2/gtN8A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/table@3.12.2': - resolution: {integrity: sha512-dUcsrdALylhWz6exqIoqtR/dnrzjIAptMyAUPT378Y/mCYs4PxKkHSvtPEQrZhdQS1ALIIgfeg9KUVIempoXPw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/tabs@3.6.9': - resolution: {integrity: sha512-YZDqZng3HrRX+uXmg6u78x73Oi24G5ICpiXVqDKKDkO333XCA5H8MWItiuPZkYB2h3SbaCaLqSobLkvCoWYpNQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/toggle@3.7.7': - resolution: {integrity: sha512-AS+xB4+hHWa3wzYkbS6pwBkovPfIE02B9SnuYTe0stKcuejpWKo5L3QMptW0ftFYsW3ZPCXuneImfObEw2T01A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/tooltip@3.4.12': - resolution: {integrity: sha512-QKYT/cze7n9qaBsk7o5ais3jRfhYCzcVRfps+iys/W+/9FFbbhjfQG995Lwi6b+vGOHWfXxXpwmyIO2tzM1Iog==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/tree@3.8.4': - resolution: {integrity: sha512-HFNclIXJ/3QdGQWxXbj+tdlmIX/XwCfzAMB5m26xpJ6HtJhia6dtx3GLfcdyHNjmuRbAsTBsAAnnVKBmNRUdIQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/utils@3.10.3': - resolution: {integrity: sha512-moClv7MlVSHpbYtQIkm0Cx+on8Pgt1XqtPx6fy9rQFb2DNc9u1G3AUVnqA17buOkH1vLxAtX4MedlxMWyRCYYA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/virtualizer@4.0.2': - resolution: {integrity: sha512-LiSr6E6OoL/cKVFO088zEzkNGj41g02nlOAgLluYONncNEjoYiHmb8Yw0otPgViVLKiFjO6Kk4W+dbt8EZ51Ag==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/breadcrumbs@3.7.7': - resolution: {integrity: sha512-ZmhXwD2LLzfEA2OvOCp/QvXu8A/Edsrn5q0qUDGsmOZj9SCVeT82bIv8P+mQnATM13mi2gyoik6102Jc1OscJA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/button@3.9.6': - resolution: {integrity: sha512-8lA+D5JLbNyQikf8M/cPP2cji91aVTcqjrGpDqI7sQnaLFikM8eFR6l1ZWGtZS5MCcbfooko77ha35SYplSQvw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/calendar@3.4.9': - resolution: {integrity: sha512-O/PS9c21HgO9qzxOyZ7/dTccxabFZdF6tj3UED4DrBw7AN3KZ7JMzwzYbwHinOcO7nUcklGgNoAIHk45UAKR9g==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/checkbox@3.8.3': - resolution: {integrity: sha512-f4c1mnLEt0iS1NMkyZXgT3q3AgcxzDk7w6MSONOKydcnh0xG5L2oefY14DhVDLkAuQS7jThlUFwiAs+MxiO3MA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/color@3.0.0-rc.1': - resolution: {integrity: sha512-aw6FzrBlZTWKrFaFskM7e3AFICe6JqH10wO0E919goa3LZDDFbyYEwRpatwjIyiZH1elEUkFPgwqpv3ZcPPn8g==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/combobox@3.12.1': - resolution: {integrity: sha512-bd5YwHZWtgnJx4jGbplWbYzXj7IbO5w3IY5suNR7r891rx6IktquZ8GQwyYH0pQ/x+X5LdK2xI59i6+QC2PmlA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/datepicker@3.8.2': - resolution: {integrity: sha512-Ih4F0bNVGrEuwCD8XmmBAspuuOBsj/Svn/pDFtC2RyAZjXfWh+sI+n4XLz/sYKjvARh5TUI8GNy9smYS4vYXug==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/dialog@3.5.12': - resolution: {integrity: sha512-JmpQbSpXltqEyYfEwoqDolABIiojeExkqolHNdQlayIsfFuSxZxNwXZPOpz58Ri/iwv21JP7K3QF0Gb2Ohxl9w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/form@3.7.6': - resolution: {integrity: sha512-lhS2y1bVtRnyYjkM+ylJUp2g663ZNbeZxu2o+mFfD5c2wYmVLA58IWR90c7DL8IVUitoANnZ1JPhhXvutiFpQQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/grid@3.2.8': - resolution: {integrity: sha512-6PJrpukwMqlv3IhJSDkJuVbhHM8Oe6hd2supWqd9adMXrlSP7QHt9a8SgFcFblCCTx8JzUaA0PvY5sTudcEtOQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/link@3.5.7': - resolution: {integrity: sha512-2WyaVmm1qr9UrSG3Dq6iz+2ziuVp+DH8CsYZ9CA6aNNb6U18Hxju3LTPb4a5gM0eC7W0mQGNBmrgGlAdDZEJOw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/listbox@3.5.1': - resolution: {integrity: sha512-n5bOgD9lgfK1qaLtag9WPnu151SwXBCNn/OgGY/Br9mWRl+nPUEYtFcPX+2VCld7uThf54kwrTmzlFnaraIlcw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/menu@3.9.11': - resolution: {integrity: sha512-IguQVF70d7aHXgWB1Rd2a/PiIuLZ2Nt7lyayJshLcy/NLOYmgpTmTyn2WCtlA5lTfQwmQrNFf4EvnWkeljJXdA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/meter@3.4.3': - resolution: {integrity: sha512-Y2fX5CTAPGRKxVSeepbeyN6/K+wlF9pMRcNxTSU2qDwdoFqNCtTWMcWuCsU/Y2L/zU0jFWu4x0Vo7WkrcsgcMA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/numberfield@3.8.5': - resolution: {integrity: sha512-LVWggkxwd1nyVZomXBPfQA1E4I4/i4PBifjcDs2AfcV7q5RE9D+DVIDXsYucVOBxPlDOxiAq/T9ypobspWSwHw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/overlays@3.8.9': - resolution: {integrity: sha512-9ni9upQgXPnR+K9cWmbYWvm3ll9gH8P/XsEZprqIV5zNLMF334jADK48h4jafb1X9RFnj0WbHo6BqcSObzjTig==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/progress@3.5.6': - resolution: {integrity: sha512-Nh43sjQ5adyN1bTHBPRaIPhXUdBqP0miYeJpeMY3V/KUl4qmouJLwDnccwFG4xLm6gBfYe22lgbbV7nAfNnuTQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/radio@3.8.3': - resolution: {integrity: sha512-fUVJt4Bb6jOReFqnhHVNxWXH7t6c60uSFfoPKuXt/xI9LL1i2jhpur0ggpTfIn3qLIAmNBU6bKBCWAdr4KjeVQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/searchfield@3.5.8': - resolution: {integrity: sha512-EcdqalHNIC6BJoRfmqUhAvXRd3aHkWlV1cFCz57JJKgUEFYyXPNrXd1b73TKLzTXEk+X/D6LKV15ILYpEaxu8w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/select@3.9.6': - resolution: {integrity: sha512-cVSFR0eJLup/ht1Uto+y8uyLmHO89J6wNh65SIHb3jeVz9oLBAedP3YNI2qB+F9qFMUcA8PBSLXIIuT6gXzLgQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/shared@3.24.1': - resolution: {integrity: sha512-AUQeGYEm/zDTN6zLzdXolDxz3Jk5dDL7f506F07U8tBwxNNI3WRdhU84G0/AaFikOZzDXhOZDr3MhQMzyE7Ydw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/slider@3.7.5': - resolution: {integrity: sha512-bRitwQRQjQoOcKEdPMljnvm474dwrmsc6pdsVQDh/qynzr+KO9IHuYc3qPW53WVE2hMQJDohlqtCAWQXWQ5Vcg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/switch@3.5.5': - resolution: {integrity: sha512-SZx1Bd+COhAOs/RTifbZG+uq/llwba7VAKx7XBeX4LeIz1dtguy5bigOBgFTMQi4qsIVCpybSWEEl+daj4XFPw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/table@3.10.1': - resolution: {integrity: sha512-xsNh0Gm4GtNeSknZqkMsfGvc94fycmfhspGO+FzQKim2hB5k4yILwd+lHYQ2UKW6New9GVH/zN2Pd3v67IeZ2g==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/tabs@3.3.9': - resolution: {integrity: sha512-3Q9kRVvg/qDyeJR/W1+C2z2OyvDWQrSLvOCvAezX5UKzww4rBEAA8OqBlyDwn7q3fiwrh/m64l6p+dbln+RdxQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/textfield@3.9.6': - resolution: {integrity: sha512-0uPqjJh4lYp1aL1HL9IlV8Cgp8eT0PcsNfdoCktfkLytvvBPmox2Pfm57W/d0xTtzZu2CjxhYNTob+JtGAOeXA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/tooltip@3.4.11': - resolution: {integrity: sha512-WPikHQxeT5Lb09yJEaW6Ja3ecE0g1YM6ukWYS2v/iZLUPn5YlYrGytspuCYQNSh/u7suCz4zRLEHYCl7OCigjw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@reactflow/background@11.3.14': - resolution: {integrity: sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==} - peerDependencies: - react: '>=17' - react-dom: '>=17' - - '@reactflow/controls@11.2.14': - resolution: {integrity: sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==} - peerDependencies: - react: '>=17' - react-dom: '>=17' - - '@reactflow/core@11.11.4': - resolution: {integrity: sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==} - peerDependencies: - react: '>=17' - react-dom: '>=17' - - '@reactflow/minimap@11.7.14': - resolution: {integrity: sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==} - peerDependencies: - react: '>=17' - react-dom: '>=17' - - '@reactflow/node-resizer@2.2.14': - resolution: {integrity: sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==} - peerDependencies: - react: '>=17' - react-dom: '>=17' - - '@reactflow/node-toolbar@1.3.14': - resolution: {integrity: sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==} - peerDependencies: - react: '>=17' - react-dom: '>=17' - - '@replit/codemirror-lang-csharp@6.2.0': - resolution: {integrity: sha512-6utbaWkoymhoAXj051mkRp+VIJlpwUgCX9Toevz3YatiZsz512fw3OVCedXQx+WcR0wb6zVHjChnuxqfCLtFVQ==} - peerDependencies: - '@codemirror/autocomplete': ^6.0.0 - '@codemirror/language': ^6.0.0 - '@codemirror/state': ^6.0.0 - '@codemirror/view': ^6.0.0 - '@lezer/common': ^1.0.0 - '@lezer/highlight': ^1.0.0 - '@lezer/lr': ^1.0.0 - - '@replit/codemirror-lang-nix@6.0.1': - resolution: {integrity: sha512-lvzjoYn9nfJzBD5qdm3Ut6G3+Or2wEacYIDJ49h9+19WSChVnxv4ojf+rNmQ78ncuxIt/bfbMvDLMeMP0xze6g==} - peerDependencies: - '@codemirror/autocomplete': ^6.0.0 - '@codemirror/language': ^6.0.0 - '@codemirror/state': ^6.0.0 - '@codemirror/view': ^6.0.0 - '@lezer/common': ^1.0.0 - '@lezer/highlight': ^1.0.0 - '@lezer/lr': ^1.0.0 - - '@replit/codemirror-lang-solidity@6.0.1': - resolution: {integrity: sha512-kDnak0xZelGmvzJwKTpMTl6gYSfFq9hnxrkbLaMV0CARq/MFvDQJmcmYon/k8uZqXy6DfzewKDV8tx9kY2WUZg==} - peerDependencies: - '@codemirror/language': ^6.0.0 - - '@replit/codemirror-lang-svelte@6.0.0': - resolution: {integrity: sha512-U2OqqgMM6jKelL0GNWbAmqlu1S078zZNoBqlJBW+retTc5M4Mha6/Y2cf4SVg6ddgloJvmcSpt4hHrVoM4ePRA==} - peerDependencies: - '@codemirror/autocomplete': ^6.0.0 - '@codemirror/lang-css': ^6.0.1 - '@codemirror/lang-html': ^6.2.0 - '@codemirror/lang-javascript': ^6.1.1 - '@codemirror/language': ^6.0.0 - '@codemirror/state': ^6.0.0 - '@codemirror/view': ^6.0.0 - '@lezer/common': ^1.0.0 - '@lezer/highlight': ^1.0.0 - '@lezer/javascript': ^1.2.0 - '@lezer/lr': ^1.0.0 - - '@replit/codemirror-vim@6.2.1': - resolution: {integrity: sha512-qDAcGSHBYU5RrdO//qCmD8K9t6vbP327iCj/iqrkVnjbrpFhrjOt92weGXGHmTNRh16cUtkUZ7Xq7rZf+8HVow==} - peerDependencies: - '@codemirror/commands': ^6.0.0 - '@codemirror/language': ^6.1.0 - '@codemirror/search': ^6.2.0 - '@codemirror/state': ^6.0.1 - '@codemirror/view': ^6.0.3 - - '@rollup/pluginutils@5.0.2': - resolution: {integrity: sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0 - peerDependenciesMeta: - rollup: - optional: true - - '@rollup/rollup-android-arm-eabi@4.21.3': - resolution: {integrity: sha512-MmKSfaB9GX+zXl6E8z4koOr/xU63AMVleLEa64v7R0QF/ZloMs5vcD1sHgM64GXXS1csaJutG+ddtzcueI/BLg==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.21.3': - resolution: {integrity: sha512-zrt8ecH07PE3sB4jPOggweBjJMzI1JG5xI2DIsUbkA+7K+Gkjys6eV7i9pOenNSDJH3eOr/jLb/PzqtmdwDq5g==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.21.3': - resolution: {integrity: sha512-P0UxIOrKNBFTQaXTxOH4RxuEBVCgEA5UTNV6Yz7z9QHnUJ7eLX9reOd/NYMO3+XZO2cco19mXTxDMXxit4R/eQ==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.21.3': - resolution: {integrity: sha512-L1M0vKGO5ASKntqtsFEjTq/fD91vAqnzeaF6sfNAy55aD+Hi2pBI5DKwCO+UNDQHWsDViJLqshxOahXyLSh3EA==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-linux-arm-gnueabihf@4.21.3': - resolution: {integrity: sha512-btVgIsCjuYFKUjopPoWiDqmoUXQDiW2A4C3Mtmp5vACm7/GnyuprqIDPNczeyR5W8rTXEbkmrJux7cJmD99D2g==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.21.3': - resolution: {integrity: sha512-zmjbSphplZlau6ZTkxd3+NMtE4UKVy7U4aVFMmHcgO5CUbw17ZP6QCgyxhzGaU/wFFdTfiojjbLG3/0p9HhAqA==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.21.3': - resolution: {integrity: sha512-nSZfcZtAnQPRZmUkUQwZq2OjQciR6tEoJaZVFvLHsj0MF6QhNMg0fQ6mUOsiCUpTqxTx0/O6gX0V/nYc7LrgPw==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.21.3': - resolution: {integrity: sha512-MnvSPGO8KJXIMGlQDYfvYS3IosFN2rKsvxRpPO2l2cum+Z3exiExLwVU+GExL96pn8IP+GdH8Tz70EpBhO0sIQ==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-powerpc64le-gnu@4.21.3': - resolution: {integrity: sha512-+W+p/9QNDr2vE2AXU0qIy0qQE75E8RTwTwgqS2G5CRQ11vzq0tbnfBd6brWhS9bCRjAjepJe2fvvkvS3dno+iw==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.21.3': - resolution: {integrity: sha512-yXH6K6KfqGXaxHrtr+Uoy+JpNlUlI46BKVyonGiaD74ravdnF9BUNC+vV+SIuB96hUMGShhKV693rF9QDfO6nQ==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.21.3': - resolution: {integrity: sha512-R8cwY9wcnApN/KDYWTH4gV/ypvy9yZUHlbJvfaiXSB48JO3KpwSpjOGqO4jnGkLDSk1hgjYkTbTt6Q7uvPf8eg==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.21.3': - resolution: {integrity: sha512-kZPbX/NOPh0vhS5sI+dR8L1bU2cSO9FgxwM8r7wHzGydzfSjLRCFAT87GR5U9scj2rhzN3JPYVC7NoBbl4FZ0g==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.21.3': - resolution: {integrity: sha512-S0Yq+xA1VEH66uiMNhijsWAafffydd2X5b77eLHfRmfLsRSpbiAWiRHV6DEpz6aOToPsgid7TI9rGd6zB1rhbg==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-win32-arm64-msvc@4.21.3': - resolution: {integrity: sha512-9isNzeL34yquCPyerog+IMCNxKR8XYmGd0tHSV+OVx0TmE0aJOo9uw4fZfUuk2qxobP5sug6vNdZR6u7Mw7Q+Q==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.21.3': - resolution: {integrity: sha512-nMIdKnfZfzn1Vsk+RuOvl43ONTZXoAPUUxgcU0tXooqg4YrAqzfKzVenqqk2g5efWh46/D28cKFrOzDSW28gTA==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.21.3': - resolution: {integrity: sha512-fOvu7PCQjAj4eWDEuD8Xz5gpzFqXzGlxHZozHP4b9Jxv9APtdxL6STqztDzMLuRXEc4UpXGGhx029Xgm91QBeA==} - cpu: [x64] - os: [win32] - - '@sinclair/typebox@0.27.8': - resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - - '@storybook/addon-actions@8.4.6': - resolution: {integrity: sha512-vbplwjMj7UXbdzoFhQkqFHLQAPJX8OVGTM9Q+yjuWDHViaKKUlgRWp0jclT7aIDNJQU2a6wJbTimHgJeF16Vhg==} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/addon-backgrounds@8.4.6': - resolution: {integrity: sha512-RSjJ3iElxlQXebZrz1s5LeoLpAXr9LAGifX7w0abMzN5sg6QSwNeUHko2eT3V57M3k1Fa/5Eelso/QBQifFEog==} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/addon-controls@8.4.6': - resolution: {integrity: sha512-70pEGWh0C2g8s0DYsISElOzsMbQS6p/K9iU5EqfotDF+hvEqstjsV/bTbR5f3OK4vR/7Gxamk7j8RVd14Nql6A==} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/addon-docs@8.4.6': - resolution: {integrity: sha512-olxz61W7PW/EsXrKhLrYbI3rn9GMBhY3KIOF/6tumbRkh0Siu/qe4EAImaV9NNwiC1R7+De/1OIVMY6o0EIZVw==} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/addon-essentials@8.4.6': - resolution: {integrity: sha512-TbFqyvWFUKw8LBpVcZuGQydzVB/3kSuHxDHi+Wj3Qas3cxBl7+w4/HjwomT2D2Tni1dZ1uPDOsAtNLmwp1POsg==} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/addon-highlight@8.4.6': - resolution: {integrity: sha512-m8wedbqDMbwkP99dNHkHAiAUkx5E7FEEEyLPX1zfkhZWOGtTkavXHH235SGp50zD75LQ6eC/BvgegrzxSQa9Wg==} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/addon-interactions@8.4.6': - resolution: {integrity: sha512-sR2oUSYIGUoAdrHT+fM1zgykhad98bsJ11c79r7HfBMXEPWc1yRcjIMmz8Xz06FMROMfebqduYDf60V++/I0Jw==} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/addon-links@8.4.6': - resolution: {integrity: sha512-1KoG9ytEWWwdF/dheu1O0dayQTMsHw++Qk8afqw7bwW1Cxz5LuAJH5ZscFWMiE5f4Xq1NgaJdeAUaIavyoOcdg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.6 - peerDependenciesMeta: - react: - optional: true - - '@storybook/addon-measure@8.4.6': - resolution: {integrity: sha512-N2IRpr39g5KpexCAS1vIHJT+phc9Yilwm3PULds2rQ66VMTbkxobXJDdt0NS05g5n9/eDniroNQwdCeLg4tkpw==} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/addon-outline@8.4.6': - resolution: {integrity: sha512-EhcWx8OpK85HxQulLWzpWUHEwQpDYuAiKzsFj9ivAbfeljkIWNTG04mierfaH1xX016uL9RtLJL/zwBS5ChnFg==} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/addon-toolbars@8.4.6': - resolution: {integrity: sha512-+Xao/uGa8FnYsyUiREUkYXWNysm3Aba8tL/Bwd+HufHtdiKJGa9lrXaC7VLCqBUaEjwqM3aaPwqEWIROsthmPQ==} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/addon-viewport@8.4.6': - resolution: {integrity: sha512-BuQll5YzOCpMS7p5Rsw9wcmi8hTnEKyg6+qAbkZNfiZ2JhXCa1GFUqX725fF1whpYVQULtkQxU8r+vahoRn7Yg==} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/blocks@8.4.6': - resolution: {integrity: sha512-Gzbx8hM7ZQIHlQELcFIMbY1v+r1Po4mlinq0QVPtKS4lBcW4eZIsesbxOaL+uFNrxb583TLFzXo0DbRPzS46sg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.6 - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - - '@storybook/builder-vite@8.4.6': - resolution: {integrity: sha512-PyJsaEPyuRFFEplpNUi+nbuJd7d1DC2dAZjpsaHTXyqg5iPIbkIgsbCJLUDeIXnUDqM/utjmMpN0sQKJuhIc6w==} - peerDependencies: - storybook: ^8.4.6 - vite: ^4.0.0 || ^5.0.0 || ^6.0.0 - - '@storybook/components@8.4.6': - resolution: {integrity: sha512-9tKSJJCyFT5RZMRGyozTBJkr9C9Yfk1nuOE9XbDEE1Z+3/IypKR9+iwc5mfNBStDNY+rxtYWNLKBb5GPR2yhzA==} - peerDependencies: - storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - - '@storybook/core@8.4.6': - resolution: {integrity: sha512-WeojVtHy0/t50tzw/15S+DLzKsj8BN9yWdo3vJMvm+nflLFvfq1XvD9WGOWeaFp8E/o3AP+4HprXG0r42KEJtA==} - peerDependencies: - prettier: ^2 || ^3 - peerDependenciesMeta: - prettier: - optional: true - - '@storybook/csf-plugin@8.4.6': - resolution: {integrity: sha512-JDIT0czC4yMgKGNf39KTZr3zm5MusAZdn6LBrTfvWb7CrTCR4iVHa4lp2yb7EJk41vHsBec0QUYDDuiFH/vV0g==} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/csf@0.1.11': - resolution: {integrity: sha512-dHYFQH3mA+EtnCkHXzicbLgsvzYjcDJ1JWsogbItZogkPHgSJM/Wr71uMkcvw8v9mmCyP4NpXJuu6bPoVsOnzg==} - - '@storybook/global@5.0.0': - resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} - - '@storybook/icons@1.2.12': - resolution: {integrity: sha512-UxgyK5W3/UV4VrI3dl6ajGfHM4aOqMAkFLWe2KibeQudLf6NJpDrDMSHwZj+3iKC4jFU7dkKbbtH2h/al4sW3Q==} - engines: {node: '>=14.0.0'} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - - '@storybook/instrumenter@8.4.6': - resolution: {integrity: sha512-snXjlgbp065A6KoK9zkjBYEIMCSlN5JefPKzt1FC0rbcbtahhD+iPpqISKhDSczwgOku/JVhVUDp/vU7AIf4mg==} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/manager-api@8.4.6': - resolution: {integrity: sha512-TsXlQ5m5rTl2KNT9icPFyy822AqXrx1QplZBt/L7cFn7SpqQKDeSta21FH7MG0piAvzOweXebVSqKngJ6cCWWQ==} - peerDependencies: - storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - - '@storybook/preview-api@8.4.6': - resolution: {integrity: sha512-LbD+lR1FGvWaJBXteVx5xdgs1x1D7tyidBg2CsW2ex+cP0iJ176JgjPfutZxlWOfQnhfRYNnJ3WKoCIfxFOTKA==} - peerDependencies: - storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - - '@storybook/react-dom-shim@8.4.6': - resolution: {integrity: sha512-f7RM8GO++fqMxbjNdEzeGS1P821jXuwRnAraejk5hyjB5SqetauFxMwoFYEYfJXPaLX2qIubnIJ78hdJ/IBaEA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.6 - - '@storybook/react-vite@8.4.6': - resolution: {integrity: sha512-bVoYj3uJRz0SknK2qN3vBVSoEXsvyARQLuHjP9eX0lWBd9XSxZinmVbexPdD0OeJYcJIdmbli2/Gw7/hu5CjFA==} - engines: {node: '>=18.0.0'} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.6 - vite: ^4.0.0 || ^5.0.0 || ^6.0.0 - - '@storybook/react@8.4.6': - resolution: {integrity: sha512-QAT23beoYNLhFGAXPimtuMErvpcI7eZbZ4AlLqW1fhiTZrRYw06cjC1bs9H3tODMcHH9LS5p3Wz9b29jtV2XGw==} - engines: {node: '>=18.0.0'} - peerDependencies: - '@storybook/test': 8.4.6 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.6 - typescript: '>= 4.2.x' - peerDependenciesMeta: - '@storybook/test': - optional: true - typescript: - optional: true - - '@storybook/test@8.4.6': - resolution: {integrity: sha512-MeU1g65YgU66M2NtmEIL9gVeHk+en0k9Hp0wfxEO7NT/WLfaOD5RXLRDJVhbAlrH/6tLeWKIPNh/D26y27vO/g==} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/theming@8.4.6': - resolution: {integrity: sha512-q7vDPN/mgj7cXIVQ9R1/V75hrzNgKkm2G0LjMo57//9/djQ+7LxvBsR1iScbFIRSEqppvMiBFzkts+2uXidySA==} - peerDependencies: - storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - - '@swc-jotai/react-refresh@0.1.1': - resolution: {integrity: sha512-Zke+Wd8tJIpGkzXhoWA9yLVcfV57hSef2EE5K/Cp1n3A1yl9E+Umpz/b1OyIuwXAALeiHah+MQM4JtXEN0tMsg==} - - '@swc/core-darwin-arm64@1.7.39': - resolution: {integrity: sha512-o2nbEL6scMBMCTvY9OnbyVXtepLuNbdblV9oNJEFia5v5eGj9WMrnRQiylH3Wp/G2NYkW7V1/ZVW+kfvIeYe9A==} - engines: {node: '>=10'} - cpu: [arm64] - os: [darwin] - - '@swc/core-darwin-x64@1.7.39': - resolution: {integrity: sha512-qMlv3XPgtPi/Fe11VhiPDHSLiYYk2dFYl747oGsHZPq+6tIdDQjIhijXPcsUHIXYDyG7lNpODPL8cP/X1sc9MA==} - engines: {node: '>=10'} - cpu: [x64] - os: [darwin] - - '@swc/core-linux-arm-gnueabihf@1.7.39': - resolution: {integrity: sha512-NP+JIkBs1ZKnpa3Lk2W1kBJMwHfNOxCUJXuTa2ckjFsuZ8OUu2gwdeLFkTHbR43dxGwH5UzSmuGocXeMowra/Q==} - engines: {node: '>=10'} - cpu: [arm] - os: [linux] - - '@swc/core-linux-arm64-gnu@1.7.39': - resolution: {integrity: sha512-cPc+/HehyHyHcvAsk3ML/9wYcpWVIWax3YBaA+ScecJpSE04l/oBHPfdqKUPslqZ+Gcw0OWnIBGJT/fBZW2ayw==} - engines: {node: '>=10'} - cpu: [arm64] - os: [linux] - - '@swc/core-linux-arm64-musl@1.7.39': - resolution: {integrity: sha512-8RxgBC6ubFem66bk9XJ0vclu3exJ6eD7x7CwDhp5AD/tulZslTYXM7oNPjEtje3xxabXuj/bEUMNvHZhQRFdqA==} - engines: {node: '>=10'} - cpu: [arm64] - os: [linux] - - '@swc/core-linux-x64-gnu@1.7.39': - resolution: {integrity: sha512-3gtCPEJuXLQEolo9xsXtuPDocmXQx12vewEyFFSMSjOfakuPOBmOQMa0sVL8Wwius8C1eZVeD1fgk0omMqeC+Q==} - engines: {node: '>=10'} - cpu: [x64] - os: [linux] - - '@swc/core-linux-x64-musl@1.7.39': - resolution: {integrity: sha512-mg39pW5x/eqqpZDdtjZJxrUvQNSvJF4O8wCl37fbuFUqOtXs4TxsjZ0aolt876HXxxhsQl7rS+N4KioEMSgTZw==} - engines: {node: '>=10'} - cpu: [x64] - os: [linux] - - '@swc/core-win32-arm64-msvc@1.7.39': - resolution: {integrity: sha512-NZwuS0mNJowH3e9bMttr7B1fB8bW5svW/yyySigv9qmV5VcQRNz1kMlCvrCLYRsa93JnARuiaBI6FazSeG8mpA==} - engines: {node: '>=10'} - cpu: [arm64] - os: [win32] - - '@swc/core-win32-ia32-msvc@1.7.39': - resolution: {integrity: sha512-qFmvv5UExbJPXhhvCVDBnjK5Duqxr048dlVB6ZCgGzbRxuarOlawCzzLK4N172230pzlAWGLgn9CWl3+N6zfHA==} - engines: {node: '>=10'} - cpu: [ia32] - os: [win32] - - '@swc/core-win32-x64-msvc@1.7.39': - resolution: {integrity: sha512-o+5IMqgOtj9+BEOp16atTfBgCogVak9svhBpwsbcJQp67bQbxGYhAPPDW/hZ2rpSSF7UdzbY9wudoX9G4trcuQ==} - engines: {node: '>=10'} - cpu: [x64] - os: [win32] - - '@swc/core@1.7.39': - resolution: {integrity: sha512-jns6VFeOT49uoTKLWIEfiQqJAlyqldNAt80kAr8f7a5YjX0zgnG3RBiLMpksx4Ka4SlK4O6TJ/lumIM3Trp82g==} - engines: {node: '>=10'} - peerDependencies: - '@swc/helpers': '*' - peerDependenciesMeta: - '@swc/helpers': - optional: true - - '@swc/counter@0.1.3': - resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} - - '@swc/helpers@0.5.1': - resolution: {integrity: sha512-sJ902EfIzn1Fa+qYmjdQqh8tPsoxyBz+8yBKC2HKUxyezKJFwPGOn7pv4WY6QuQW//ySQi5lJjA/ZT9sNWWNTg==} - - '@swc/types@0.1.13': - resolution: {integrity: sha512-JL7eeCk6zWCbiYQg2xQSdLXQJl8Qoc9rXmG2cEKvHe3CKwMHwHGpfOb8frzNLmbycOo6I51qxnLnn9ESf4I20Q==} - - '@tailwindcss/typography@0.5.14': - resolution: {integrity: sha512-ZvOCjUbsJBjL9CxQBn+VEnFpouzuKhxh2dH8xMIWHILL+HfOYtlAkWcyoon8LlzE53d2Yo6YO6pahKKNW3q1YQ==} - peerDependencies: - tailwindcss: '>=3.0.0 || insiders' - - '@tanstack/react-table@8.20.1': - resolution: {integrity: sha512-PJK+07qbengObe5l7c8vCdtefXm8cyR4i078acWrHbdm8JKw1ES7YpmOtVt9ALUVEEFAHscdVpGRhRgikgFMbQ==} - engines: {node: '>=12'} - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - - '@tanstack/table-core@8.20.1': - resolution: {integrity: sha512-5Ly5TIRHnWH7vSDell9B/OVyV380qqIJVg7H7R7jU4fPEmOD4smqAX7VRflpYI09srWR8aj5OLD2Ccs1pI5mTg==} - engines: {node: '>=12'} - - '@testing-library/dom@10.4.0': - resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} - engines: {node: '>=18'} - - '@testing-library/jest-dom@6.5.0': - resolution: {integrity: sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==} - engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - - '@testing-library/react-hooks@8.0.1': - resolution: {integrity: sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==} - engines: {node: '>=12'} - peerDependencies: - '@types/react': ^16.9.0 || ^17.0.0 - react: ^16.9.0 || ^17.0.0 - react-dom: ^16.9.0 || ^17.0.0 - react-test-renderer: ^16.9.0 || ^17.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - react-dom: - optional: true - react-test-renderer: - optional: true - - '@testing-library/react@16.0.0': - resolution: {integrity: sha512-guuxUKRWQ+FgNX0h0NS0FIq3Q3uLtWVpBzcLOggmfMoUpgBnzBzvLLd4fbm6yS8ydJd94cIfY4yP9qUQjM2KwQ==} - engines: {node: '>=18'} - peerDependencies: - '@testing-library/dom': ^10.0.0 - '@types/react': ^18.0.0 - '@types/react-dom': ^18.0.0 - react: ^18.0.0 - react-dom: ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@testing-library/user-event@14.5.2': - resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==} - engines: {node: '>=12', npm: '>=6'} - peerDependencies: - '@testing-library/dom': '>=7.21.4' - - '@textea/json-viewer@3.4.1': - resolution: {integrity: sha512-8cLptaqOZVw025/iF5Cb+4nX2jjLRlGfKxGd8D6Gm9pOzB/ZDgih+xl9zoWcVXwVlRj85uLae5oorKV8Yc+vog==} - peerDependencies: - '@emotion/react': ^11 - '@emotion/styled': ^11 - '@mui/material': ^5 - react: ^17 || ^18 - react-dom: ^17 || ^18 - - '@trysound/sax@0.2.0': - resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} - engines: {node: '>=10.13.0'} - - '@turf/area@7.1.0': - resolution: {integrity: sha512-w91FEe02/mQfMPRX2pXua48scFuKJ2dSVMF2XmJ6+BJfFiCPxp95I3+Org8+ZsYv93CDNKbf0oLNEPnuQdgs2g==} - - '@turf/bbox@7.1.0': - resolution: {integrity: sha512-PdWPz9tW86PD78vSZj2fiRaB8JhUHy6piSa/QXb83lucxPK+HTAdzlDQMTKj5okRCU8Ox/25IR2ep9T8NdopRA==} - - '@turf/centroid@7.1.0': - resolution: {integrity: sha512-1Y1b2l+ZB1CZ+ITjUCsGqC4/tSjwm/R4OUfDztVqyyCq/VvezkLmTNqvXTGXgfP0GXkpv68iCfxF5M7QdM5pJQ==} - - '@turf/helpers@7.1.0': - resolution: {integrity: sha512-dTeILEUVeNbaEeoZUOhxH5auv7WWlOShbx7QSd4s0T4Z0/iz90z9yaVCtZOLbU89umKotwKaJQltBNO9CzVgaQ==} - - '@turf/meta@7.1.0': - resolution: {integrity: sha512-ZgGpWWiKz797Fe8lfRj7HKCkGR+nSJ/5aKXMyofCvLSc2PuYJs/qyyifDPWjASQQCzseJ7AlF2Pc/XQ/3XkkuA==} - - '@types/aria-query@5.0.4': - resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} - - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.6.4': - resolution: {integrity: sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==} - - '@types/babel__template@7.4.1': - resolution: {integrity: sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==} - - '@types/babel__traverse@7.18.5': - resolution: {integrity: sha512-enCvTL8m/EHS/zIvJno9nE+ndYPh1/oNFzRYRmtUqJICG2VnCSBzMLW5VN2KCQU91f23tsNKR8v7VJJQMatl7Q==} - - '@types/clone@0.1.30': - resolution: {integrity: sha512-vcxBr+ybljeSiasmdke1cQ9ICxoEwaBgM1OQ/P5h4MPj/kRyLcDl5L8PrftlbyV1kBbJIs3M3x1A1+rcWd4mEA==} - - '@types/d3-array@3.2.1': - resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} - - '@types/d3-axis@3.0.6': - resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} - - '@types/d3-brush@3.0.6': - resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} - - '@types/d3-chord@3.0.6': - resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} - - '@types/d3-color@3.1.3': - resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} - - '@types/d3-contour@3.0.6': - resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} - - '@types/d3-delaunay@6.0.4': - resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} - - '@types/d3-dispatch@3.0.6': - resolution: {integrity: sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==} - - '@types/d3-drag@3.0.7': - resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} - - '@types/d3-dsv@3.0.7': - resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} - - '@types/d3-ease@3.0.2': - resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} - - '@types/d3-fetch@3.0.7': - resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} - - '@types/d3-force@3.0.9': - resolution: {integrity: sha512-IKtvyFdb4Q0LWna6ymywQsEYjK/94SGhPrMfEr1TIc5OBeziTi+1jcCvttts8e0UWZIxpasjnQk9MNk/3iS+kA==} - - '@types/d3-format@3.0.4': - resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} - - '@types/d3-geo@3.0.8': - resolution: {integrity: sha512-eDivwKKB4ELHw93QNxigleCxZczcyrFgtKyHK5HoCQoeUD0i5feT8i5tH6RxXvt5hPPhpruvKWDSwvfsW448zQ==} - - '@types/d3-hierarchy@3.1.6': - resolution: {integrity: sha512-qlmD/8aMk5xGorUvTUWHCiumvgaUXYldYjNVOWtYoTYY/L+WwIEAmJxUmTgr9LoGNG0PPAOmqMDJVDPc7DOpPw==} - - '@types/d3-interpolate@3.0.4': - resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} - - '@types/d3-path@3.0.2': - resolution: {integrity: sha512-WAIEVlOCdd/NKRYTsqCpOMHQHemKBEINf8YXMYOtXH0GA7SY0dqMB78P3Uhgfy+4X+/Mlw2wDtlETkN6kQUCMA==} - - '@types/d3-polygon@3.0.2': - resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} - - '@types/d3-quadtree@3.0.5': - resolution: {integrity: sha512-Cb1f3jyNBnvMMkf4KBZ7IgAQVWd9yzBwYcrxGqg3aPCUgWELAS+nyeB7r76aqu1e3+CGDjhk4BrWaFBekMwigg==} - - '@types/d3-random@3.0.3': - resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} - - '@types/d3-scale-chromatic@3.0.2': - resolution: {integrity: sha512-kpKNZMDT3OAX6b5ct5nS/mv6LULagnUy4DmS6yyNjclje1qVe7vbjPwY3q1TGz6+Wr2IUkgFatCzqYUl54fHag==} - - '@types/d3-scale@4.0.8': - resolution: {integrity: sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==} - - '@types/d3-selection@3.0.10': - resolution: {integrity: sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==} - - '@types/d3-shape@3.1.5': - resolution: {integrity: sha512-dfEWpZJ1Pdg8meLlICX1M3WBIpxnaH2eQV2eY43Y5ysRJOTAV9f3/R++lgJKFstfrEOE2zdJ0sv5qwr2Bkic6Q==} - - '@types/d3-time-format@4.0.3': - resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} - - '@types/d3-time@3.0.3': - resolution: {integrity: sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==} - - '@types/d3-timer@3.0.2': - resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} - - '@types/d3-transition@3.0.8': - resolution: {integrity: sha512-ew63aJfQ/ms7QQ4X7pk5NxQ9fZH/z+i24ZfJ6tJSfqxJMrYLiK01EAs2/Rtw/JreGUsS3pLPNV644qXFGnoZNQ==} - - '@types/d3-zoom@3.0.8': - resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} - - '@types/d3@7.4.3': - resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} - - '@types/debug@4.1.12': - resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} - - '@types/diff-match-patch@1.0.36': - resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} - - '@types/doctrine@0.0.9': - resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==} - - '@types/emscripten@1.39.13': - resolution: {integrity: sha512-cFq+fO/isvhvmuP/+Sl4K4jtU6E23DoivtbO4r50e3odaxAiVdbfSYRDdJ4gCdxx+3aRjhphS5ZMwIH4hFy/Cw==} - - '@types/eslint-scope@3.7.7': - resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} - - '@types/eslint@9.6.1': - resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} - - '@types/estree-jsx@1.0.5': - resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} - - '@types/estree@1.0.5': - resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} - - '@types/estree@1.0.6': - resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} - - '@types/fast-json-stable-stringify@2.1.0': - resolution: {integrity: sha512-IyNhGHu71jH1jCXTHmafuoAAdsbBON3kDh7u/UUhLmjYgN5TYB54e1R8ckTCiIevl2UuZaCsi9XRxineY5yUjw==} - deprecated: This is a stub types definition. fast-json-stable-stringify provides its own type definitions, so you do not need this installed. - - '@types/filesystem@0.0.35': - resolution: {integrity: sha512-1eKvCaIBdrD2mmMgy5dwh564rVvfEhZTWVQQGRNn0Nt4ZEnJ0C8oSUCzvMKRA4lGde5oEVo+q2MrTTbV/GHDCQ==} - - '@types/filewriter@0.0.33': - resolution: {integrity: sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==} - - '@types/geojson-vt@3.2.5': - resolution: {integrity: sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==} - - '@types/geojson@7946.0.14': - resolution: {integrity: sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==} - - '@types/geojson@7946.0.4': - resolution: {integrity: sha512-MHmwBtCb7OCv1DSivz2UNJXPGU/1btAWRKlqJ2saEhVJkpkvqHMMaOpKg0v4sAbDWSQekHGvPVMM8nQ+Jen03Q==} - - '@types/har-format@1.2.15': - resolution: {integrity: sha512-RpQH4rXLuvTXKR0zqHq3go0RVXYv/YVqv4TnPH95VbwUxZdQlK1EtcMvQvMpDngHbt13Csh9Z4qT9AbkiQH5BA==} - - '@types/hast@3.0.4': - resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - - '@types/humanize-duration@3.27.4': - resolution: {integrity: sha512-yaf7kan2Sq0goxpbcwTQ+8E9RP6HutFBPv74T/IA/ojcHKhuKVlk2YFYyHhWZeLvZPzzLE3aatuQB4h0iqyyUA==} - - '@types/istanbul-lib-coverage@2.0.6': - resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} - - '@types/istanbul-lib-report@3.0.3': - resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} - - '@types/istanbul-reports@1.1.2': - resolution: {integrity: sha512-P/W9yOX/3oPZSpaYOCQzGqgCQRXn0FFO/V8bWrCQs+wLmvVVxk6CRBXALEvNs9OHIatlnlFokfhuDo2ug01ciw==} - - '@types/js-cookie@3.0.6': - resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} - - '@types/jsdom@21.1.7': - resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/katex@0.16.7': - resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==} - - '@types/lodash-es@4.17.12': - resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} - - '@types/lodash@4.14.194': - resolution: {integrity: sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==} - - '@types/mapbox__point-geometry@0.1.4': - resolution: {integrity: sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==} - - '@types/mapbox__vector-tile@1.3.4': - resolution: {integrity: sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==} - - '@types/mdast@4.0.4': - resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} - - '@types/mdx@2.0.5': - resolution: {integrity: sha512-76CqzuD6Q7LC+AtbPqrvD9AqsN0k8bsYo2bM2J8pmNldP1aIPAbzUQ7QbobyXL4eLr1wK5x8FZFe8eF/ubRuBg==} - - '@types/ms@0.7.34': - resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} - - '@types/node@20.17.6': - resolution: {integrity: sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==} - - '@types/normalize-package-data@2.4.1': - resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} - - '@types/parse-json@4.0.0': - resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} - - '@types/pbf@3.0.5': - resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==} - - '@types/plotly.js@2.12.29': - resolution: {integrity: sha512-eaZGh/DFrjBKE6FnWT/CU2Hx1CQ9hT01LvVpiHHB20iVOLmgM5qPV74y08JrM4VjyF7Gx7hAx91dxusqQzE+nQ==} - - '@types/prop-types@15.7.13': - resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==} - - '@types/react-dom@18.3.0': - resolution: {integrity: sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==} - - '@types/react-grid-layout@1.3.5': - resolution: {integrity: sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==} - - '@types/react-plotly.js@2.6.3': - resolution: {integrity: sha512-HBQwyGuu/dGXDsWhnQrhH+xcJSsHvjkwfSRjP+YpOsCCWryIuXF78ZCBjpfgO3sCc0Jo8sYp4NOGtqT7Cn3epQ==} - - '@types/react-transition-group@4.4.11': - resolution: {integrity: sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==} - - '@types/react@18.3.12': - resolution: {integrity: sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==} - - '@types/resolve@1.20.4': - resolution: {integrity: sha512-BKGK0T1VgB1zD+PwQR4RRf0ais3NyvH1qjLUrHI5SEiccYaJrhLstLuoXFWJ+2Op9whGizSPUMGPJY/Qtb/A2w==} - - '@types/supercluster@7.1.3': - resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} - - '@types/timestring@6.0.5': - resolution: {integrity: sha512-u+PDTI30Ezg81ENLYTIezc2HtBW7n2/Vgt3vJ2LBzpn/VZzVj33MqxXeVti7g16gPzn3i1hSZCkNCveWp5iowQ==} - - '@types/tough-cookie@4.0.4': - resolution: {integrity: sha512-95Sfz4nvMAb0Nl9DTxN3j64adfwfbBPEYq14VN7zT5J5O2M9V6iZMIIQU1U+pJyl9agHYHNCqhCXgyEtIRRa5A==} - - '@types/unist@2.0.11': - resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} - - '@types/unist@3.0.3': - resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - - '@types/uuid@9.0.7': - resolution: {integrity: sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==} - - '@types/webextension-polyfill@0.10.7': - resolution: {integrity: sha512-10ql7A0qzBmFB+F+qAke/nP1PIonS0TXZAOMVOxEUsm+lGSW6uwVcISFNa0I4Oyj0884TZVWGGMIWeXOVSNFHw==} - - '@types/yargs-parser@21.0.3': - resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - - '@types/yargs@13.0.12': - resolution: {integrity: sha512-qCxJE1qgz2y0hA4pIxjBR+PelCH0U5CK1XJXFwCNqfmliatKp47UCXXE9Dyk1OXBDLvsCF57TqQEJaeLfDYEOQ==} - - '@typescript-eslint/eslint-plugin@7.15.0': - resolution: {integrity: sha512-uiNHpyjZtFrLwLDpHnzaDlP3Tt6sGMqTCiqmxaN4n4RP0EfYZDODJyddiFDF44Hjwxr5xAcaYxVKm9QKQFJFLA==} - engines: {node: ^18.18.0 || >=20.0.0} - peerDependencies: - '@typescript-eslint/parser': ^7.0.0 - eslint: ^8.56.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - '@typescript-eslint/parser@7.15.0': - resolution: {integrity: sha512-k9fYuQNnypLFcqORNClRykkGOMOj+pV6V91R4GO/l1FDGwpqmSwoOQrOHo3cGaH63e+D3ZiCAOsuS/D2c99j/A==} - engines: {node: ^18.18.0 || >=20.0.0} - peerDependencies: - eslint: ^8.56.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - '@typescript-eslint/scope-manager@7.15.0': - resolution: {integrity: sha512-Q/1yrF/XbxOTvttNVPihxh1b9fxamjEoz2Os/Pe38OHwxC24CyCqXxGTOdpb4lt6HYtqw9HetA/Rf6gDGaMPlw==} - engines: {node: ^18.18.0 || >=20.0.0} - - '@typescript-eslint/scope-manager@8.11.0': - resolution: {integrity: sha512-Uholz7tWhXmA4r6epo+vaeV7yjdKy5QFCERMjs1kMVsLRKIrSdM6o21W2He9ftp5PP6aWOVpD5zvrvuHZC0bMQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/type-utils@7.15.0': - resolution: {integrity: sha512-SkgriaeV6PDvpA6253PDVep0qCqgbO1IOBiycjnXsszNTVQe5flN5wR5jiczoEoDEnAqYFSFFc9al9BSGVltkg==} - engines: {node: ^18.18.0 || >=20.0.0} - peerDependencies: - eslint: ^8.56.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - '@typescript-eslint/types@7.15.0': - resolution: {integrity: sha512-aV1+B1+ySXbQH0pLK0rx66I3IkiZNidYobyfn0WFsdGhSXw+P3YOqeTq5GED458SfB24tg+ux3S+9g118hjlTw==} - engines: {node: ^18.18.0 || >=20.0.0} - - '@typescript-eslint/types@8.11.0': - resolution: {integrity: sha512-tn6sNMHf6EBAYMvmPUaKaVeYvhUsrE6x+bXQTxjQRp360h1giATU0WvgeEys1spbvb5R+VpNOZ+XJmjD8wOUHw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@7.15.0': - resolution: {integrity: sha512-gjyB/rHAopL/XxfmYThQbXbzRMGhZzGw6KpcMbfe8Q3nNQKStpxnUKeXb0KiN/fFDR42Z43szs6rY7eHk0zdGQ==} - engines: {node: ^18.18.0 || >=20.0.0} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - '@typescript-eslint/typescript-estree@8.11.0': - resolution: {integrity: sha512-yHC3s1z1RCHoCz5t06gf7jH24rr3vns08XXhfEqzYpd6Hll3z/3g23JRi0jM8A47UFKNc3u/y5KIMx8Ynbjohg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - '@typescript-eslint/utils@7.15.0': - resolution: {integrity: sha512-hfDMDqaqOqsUVGiEPSMLR/AjTSCsmJwjpKkYQRo1FNbmW4tBwBspYDwO9eh7sKSTwMQgBw9/T4DHudPaqshRWA==} - engines: {node: ^18.18.0 || >=20.0.0} - peerDependencies: - eslint: ^8.56.0 - - '@typescript-eslint/utils@8.11.0': - resolution: {integrity: sha512-CYiX6WZcbXNJV7UNB4PLDIBtSdRmRI/nb0FMyqHPTQD1rMjA0foPLaPUV39C/MxkTd/QKSeX+Gb34PPsDVC35g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - - '@typescript-eslint/visitor-keys@7.15.0': - resolution: {integrity: sha512-Hqgy/ETgpt2L5xueA/zHHIl4fJI2O4XUE9l4+OIfbJIRSnTJb/QscncdqqZzofQegIJugRIF57OJea1khw2SDw==} - engines: {node: ^18.18.0 || >=20.0.0} - - '@typescript-eslint/visitor-keys@8.11.0': - resolution: {integrity: sha512-EaewX6lxSjRJnc+99+dqzTeoDZUfyrA52d2/HRrkI830kgovWsmIiTfmr0NZorzqic7ga+1bS60lRBUgR3n/Bw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@uidotdev/usehooks@2.4.1': - resolution: {integrity: sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==} - engines: {node: '>=16'} - peerDependencies: - react: '>=18.0.0' - react-dom: '>=18.0.0' - - '@uiw/codemirror-extensions-basic-setup@4.23.5': - resolution: {integrity: sha512-eTMfT8TejVN/D5vvuz9Lab+MIoRYdtqa2ftZZmU3JpcDIXf9KaExPo+G2Rl9HqySzaasgGXOOG164MAnj3MSIw==} - peerDependencies: - '@codemirror/autocomplete': '>=6.0.0' - '@codemirror/commands': '>=6.0.0' - '@codemirror/language': '>=6.0.0' - '@codemirror/lint': '>=6.0.0' - '@codemirror/search': '>=6.0.0' - '@codemirror/state': '>=6.0.0' - '@codemirror/view': '>=6.0.0' - - '@uiw/codemirror-extensions-langs@4.23.5': - resolution: {integrity: sha512-6BPtBj2Ff9KTXP1DfURtUcKBLH7ozor48dprJ5b6u7NLzRuSRQkGisAcueMLHX98tedY+QWp6V5YtOsqeguTNg==} - peerDependencies: - '@codemirror/language-data': '>=6.0.0' - '@codemirror/legacy-modes': '>=6.0.0' - - '@uiw/codemirror-extensions-mentions@4.23.5': - resolution: {integrity: sha512-ZxX/KXY9qmgb32u4Py/+MYqKL9ln13zJBjr/G/GFDjyUBGA4I5XWv4yQczBdXg+kyAyiP2tqWURoQfvSGtV12g==} - peerDependencies: - '@codemirror/state': '>=6.0.0' - '@codemirror/view': '>=6.0.0' - - '@uiw/react-codemirror@4.23.5': - resolution: {integrity: sha512-2zzGpx61L4mq9zDG/hfsO4wAH209TBE8VVsoj/qrccRe6KfcneCwKgRxtQjxBCCnO0Q5S+IP+uwCx5bXRzgQFQ==} - peerDependencies: - '@babel/runtime': '>=7.11.0' - '@codemirror/state': '>=6.0.0' - '@codemirror/theme-one-dark': '>=6.0.0' - '@codemirror/view': '>=6.0.0' - codemirror: '>=6.0.0' - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@ungap/structured-clone@1.2.0': - resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} - - '@valtown/codemirror-codeium@1.1.1': - resolution: {integrity: sha512-J/LcCiHAhqcqTFjxAFYtUIxSPXY1cdhANsJ8HWqb4wIRengcigBC0r8psZOaVdFHrky4p56q+sJL1Wnsd0Xsug==} - peerDependencies: - '@codemirror/autocomplete': ^6 - '@codemirror/state': ^6 - '@codemirror/view': ^6 - - '@vitejs/plugin-react-swc@3.7.1': - resolution: {integrity: sha512-vgWOY0i1EROUK0Ctg1hwhtC3SdcDjZcdit4Ups4aPkDcB1jYhmo+RMYWY87cmXMhvtD5uf8lV89j2w16vkdSVg==} - peerDependencies: - vite: ^4 || ^5 - - '@vitejs/plugin-react@4.3.3': - resolution: {integrity: sha512-NooDe9GpHGqNns1i8XDERg0Vsg5SSYRhRxxyTGogUdkdNt47jal+fbuYi+Yfq6pzRCKXyoPcWisfxE6RIM3GKA==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 - - '@vitest/expect@1.6.0': - resolution: {integrity: sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==} - - '@vitest/expect@2.0.5': - resolution: {integrity: sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==} - - '@vitest/pretty-format@2.0.5': - resolution: {integrity: sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==} - - '@vitest/pretty-format@2.1.3': - resolution: {integrity: sha512-XH1XdtoLZCpqV59KRbPrIhFCOO0hErxrQCMcvnQete3Vibb9UeIOX02uFPfVn3Z9ZXsq78etlfyhnkmIZSzIwQ==} - - '@vitest/runner@1.6.0': - resolution: {integrity: sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==} - - '@vitest/snapshot@1.6.0': - resolution: {integrity: sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==} - - '@vitest/spy@1.6.0': - resolution: {integrity: sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==} - - '@vitest/spy@2.0.5': - resolution: {integrity: sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==} - - '@vitest/utils@1.6.0': - resolution: {integrity: sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==} - - '@vitest/utils@2.0.5': - resolution: {integrity: sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==} - - '@vitest/utils@2.1.3': - resolution: {integrity: sha512-xpiVfDSg1RrYT0tX6czgerkpcKFmFOF/gCr30+Mve5V2kewCy4Prn1/NDMSRwaSmT7PRaOF83wu+bEtsY1wrvA==} - - '@webassemblyjs/ast@1.14.1': - resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} - - '@webassemblyjs/floating-point-hex-parser@1.13.2': - resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} - - '@webassemblyjs/helper-api-error@1.13.2': - resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} - - '@webassemblyjs/helper-buffer@1.14.1': - resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} - - '@webassemblyjs/helper-numbers@1.13.2': - resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} - - '@webassemblyjs/helper-wasm-bytecode@1.13.2': - resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} - - '@webassemblyjs/helper-wasm-section@1.14.1': - resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} - - '@webassemblyjs/ieee754@1.13.2': - resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} - - '@webassemblyjs/leb128@1.13.2': - resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} - - '@webassemblyjs/utf8@1.13.2': - resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} - - '@webassemblyjs/wasm-edit@1.14.1': - resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} - - '@webassemblyjs/wasm-gen@1.14.1': - resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} - - '@webassemblyjs/wasm-opt@1.14.1': - resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} - - '@webassemblyjs/wasm-parser@1.14.1': - resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} - - '@webassemblyjs/wast-printer@1.14.1': - resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} - - '@xterm/addon-attach@0.11.0': - resolution: {integrity: sha512-JboCN0QAY6ZLY/SSB/Zl2cQ5zW1Eh4X3fH7BnuR1NB7xGRhzbqU2Npmpiw/3zFlxDaU88vtKzok44JKi2L2V2Q==} - peerDependencies: - '@xterm/xterm': ^5.0.0 - - '@xterm/addon-fit@0.10.0': - resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} - peerDependencies: - '@xterm/xterm': ^5.0.0 - - '@xterm/xterm@5.5.0': - resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} - - '@xtuc/ieee754@1.2.0': - resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} - - '@xtuc/long@4.2.2': - resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} - - abs-svg-path@0.1.1: - resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==} - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn-walk@8.3.2: - resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} - engines: {node: '>=0.4.0'} - - acorn@7.4.1: - resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} - engines: {node: '>=0.4.0'} - hasBin: true - - acorn@8.14.0: - resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} - engines: {node: '>=0.4.0'} - hasBin: true - - ag-charts-types@10.2.0: - resolution: {integrity: sha512-PUqH1QtugpYLnlbMdeSZVf5PpT1XZVsP69qN1JXhetLtQpVC28zaj7ikwu9CMA9N9b+dBboA9QcjUQUJZVUokQ==} - - ag-grid-community@32.2.2: - resolution: {integrity: sha512-RQluoEXbTCkYHHwmOUzG4wGBX3yQffFH+52aWJUAFqFKNNHYKYGhjvH2iuAa2xw3CWva1hupUaDpP+Rol32Arg==} - - ag-grid-react@32.2.2: - resolution: {integrity: sha512-YNj6ssjcaE5R2xnxH21YvseKatsTN59Tb8EoafjT/zWQvOAvSWofdbKwYVz8YfPOwNzw+FB+qiQfP1j8rJtVdA==} - peerDependencies: - react: ^16.3.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.3.0 || ^17.0.0 || ^18.0.0 - - agent-base@7.1.0: - resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==} - engines: {node: '>= 14'} - - ai@3.3.12: - resolution: {integrity: sha512-vmum83qRAWNPWFiaxhQNMAksZhjcSFdGlUdQubifCvYS1wptDFxz9WyLgh4hwUPiJNX5sNuUqra6U1VndLZ6Aw==} - engines: {node: '>=18'} - peerDependencies: - openai: ^4.42.0 - react: ^18 || ^19 - sswr: ^2.1.0 - svelte: '*' - zod: ^3.0.0 - peerDependenciesMeta: - openai: - optional: true - react: - optional: true - sswr: - optional: true - svelte: - optional: true - zod: - optional: true - - ajv-keywords@3.5.2: - resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} - peerDependencies: - ajv: ^6.9.1 - - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - - ajv@8.12.0: - resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} - - almost-equal@1.1.0: - resolution: {integrity: sha512-0V/PkoculFl5+0Lp47JoxUcO0xSxhIBvm+BxHdD/OgXNmdRpRHCFnKVuUoWyS9EzQP+otSGv0m9Lb4yVkQBn2A==} - - ansi-regex@2.1.1: - resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==} - engines: {node: '>=0.10.0'} - - ansi-regex@4.1.1: - resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} - engines: {node: '>=6'} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.0.1: - resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} - engines: {node: '>=12'} - - ansi-styles@3.2.1: - resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} - engines: {node: '>=4'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - - ansi-styles@6.2.1: - resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} - engines: {node: '>=12'} - - ansi_up@6.0.2: - resolution: {integrity: sha512-3G3vKvl1ilEp7J1u6BmULpMA0xVoW/f4Ekqhl8RTrJrhEBkonKn5k3bUc5Xt+qDayA6iDX0jyUh3AbZjB/l0tw==} - - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - arg@5.0.2: - resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - aria-hidden@1.1.3: - resolution: {integrity: sha512-RhVWFtKH5BiGMycI72q2RAFMLQi8JP9bLuQXgR5a8Znp7P5KOIADSJeyfI8PCVxLEp067B2HbP5JIiI/PXIZeA==} - engines: {node: '>=8.5.0'} - - aria-query@5.1.3: - resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} - - aria-query@5.3.0: - resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - - aria-query@5.3.1: - resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} - engines: {node: '>= 0.4'} - - array-bounds@1.0.1: - resolution: {integrity: sha512-8wdW3ZGk6UjMPJx/glyEt0sLzzwAE1bhToPsO1W2pbpR2gULyxe3BjSiuJFheP50T/GgODVPz2fuMUmIywt8cQ==} - - array-buffer-byte-length@1.0.1: - resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} - engines: {node: '>= 0.4'} - - array-find-index@1.0.2: - resolution: {integrity: sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==} - engines: {node: '>=0.10.0'} - - array-includes@3.1.8: - resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} - engines: {node: '>= 0.4'} - - array-normalize@1.1.4: - resolution: {integrity: sha512-fCp0wKFLjvSPmCn4F5Tiw4M3lpMZoHlCjfcs7nNzuj3vqQQ1/a8cgB9DXcpDSn18c+coLnaW7rqfcYCvKbyJXg==} - - array-range@1.0.1: - resolution: {integrity: sha512-shdaI1zT3CVNL2hnx9c0JMc0ZogGaxDs5e85akgHWKYa0yVbIyp06Ind3dVkTj/uuFrzaHBOyqFzo+VV6aXgtA==} - - array-rearrange@2.2.2: - resolution: {integrity: sha512-UfobP5N12Qm4Qu4fwLDIi2v6+wZsSf6snYSxAMeKhrh37YGnNWZPRmVEKc/2wfms53TLQnzfpG8wCx2Y/6NG1w==} - - array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - - array.prototype.findlast@1.2.5: - resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} - engines: {node: '>= 0.4'} - - array.prototype.flat@1.3.2: - resolution: {integrity: sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==} - engines: {node: '>= 0.4'} - - array.prototype.flatmap@1.3.2: - resolution: {integrity: sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==} - engines: {node: '>= 0.4'} - - array.prototype.toreversed@1.1.2: - resolution: {integrity: sha512-wwDCoT4Ck4Cz7sLtgUmzR5UV3YF5mFHUlbChCzZBQZ+0m2cl/DH3tKgvphv1nKgFsJ48oCSg6p91q2Vm0I/ZMA==} - - array.prototype.tosorted@1.1.4: - resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} - engines: {node: '>= 0.4'} - - arraybuffer.prototype.slice@1.0.3: - resolution: {integrity: sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==} - engines: {node: '>= 0.4'} - - asap@2.0.6: - resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - - asn1@0.2.6: - resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} - - assert-plus@1.0.0: - resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} - engines: {node: '>=0.8'} - - assertion-error@1.1.0: - resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} - - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - ast-types-flow@0.0.8: - resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - - ast-types@0.16.1: - resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} - engines: {node: '>=4'} - - astral-regex@2.0.0: - resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} - engines: {node: '>=8'} - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - attr-accept@2.2.2: - resolution: {integrity: sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==} - engines: {node: '>=4'} - - autoprefixer@10.4.20: - resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} - - aws-sign2@0.7.0: - resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} - - aws4@1.12.0: - resolution: {integrity: sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==} - - axe-core@4.9.1: - resolution: {integrity: sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw==} - engines: {node: '>=4'} - - axobject-query@3.1.1: - resolution: {integrity: sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==} - - babel-plugin-macros@3.1.0: - resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} - engines: {node: '>=10', npm: '>=6'} - - babel-plugin-react-compiler@19.0.0-beta-8a03594-20241020: - resolution: {integrity: sha512-Wk0748DZzQEmjkEN4SbBujM5al4q5TfRBapA32ax0AID/Yek3emS+eyCvPvb4zPddYJTAF4LaJNLt8uHYfdKAQ==} - - bail@2.0.2: - resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - balanced-match@2.0.0: - resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} - - base64-arraybuffer@1.0.2: - resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} - engines: {node: '>= 0.6.0'} - - bcrypt-pbkdf@1.0.2: - resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} - - better-opn@3.0.2: - resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} - engines: {node: '>=12.0.0'} - - binary-extensions@2.2.0: - resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} - engines: {node: '>=8'} - - binary-search-bounds@2.0.5: - resolution: {integrity: sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==} - - bit-twiddle@1.0.2: - resolution: {integrity: sha512-B9UhK0DKFZhoTFcfvAzhqsjStvGJp9vYWf3+6SNTtdSQnvIgfkHbgHrg/e4+TH71N2GDu8tpmCVoyfrL1d7ntA==} - - bitmap-sdf@1.0.4: - resolution: {integrity: sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==} - - bl@2.2.1: - resolution: {integrity: sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==} - - blob-polyfill@7.0.20220408: - resolution: {integrity: sha512-oD8Ydw+5lNoqq+en24iuPt1QixdPpe/nUF8azTHnviCZYu9zUC+TwdzIp5orpblJosNlgNbVmmAb//c6d6ImUQ==} - - boolbase@1.0.0: - resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - - brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} - - brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - brfs@1.6.1: - resolution: {integrity: sha512-OfZpABRQQf+Xsmju8XE9bDjs+uU4vLREGolP7bDgcpsI17QREyZ4Bl+2KLxxx1kCgA0fAIhKQBaBYh+PEcCqYQ==} - hasBin: true - - browser-assert@1.2.1: - resolution: {integrity: sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==} - - browser-namespace@1.4.0: - resolution: {integrity: sha512-9b4yNTNs+8HVPssSq8RSZMRunf+G4cVQ2PMtOTn+uEVFOW5C0Uo+eGXuJ5LfxS1UDph5oAdWj92thPyxVhpqXg==} - - browserslist@4.24.2: - resolution: {integrity: sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - buffer-equal@0.0.1: - resolution: {integrity: sha512-RgSV6InVQ9ODPdLWJ5UAqBqJBOg370Nz6ZQtRzpt6nUjc8v0St97uJ4PYC6NztqIScrAXafKM3mZPMygSe1ggA==} - engines: {node: '>=0.4.0'} - - buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - - builtin-modules@3.3.0: - resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} - engines: {node: '>=6'} - - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - - call-bind@1.0.7: - resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} - engines: {node: '>= 0.4'} - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - camelcase-css@2.0.1: - resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} - engines: {node: '>= 6'} - - camelcase@3.0.0: - resolution: {integrity: sha512-4nhGqUkc4BqbBBB4Q6zLuD7lzzrHYrjKGeYaEji/3tFR5VdJu9v+LilhGIVe8wxEJPPOeWo7eg8dwY13TZ1BNg==} - engines: {node: '>=0.10.0'} - - camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} - engines: {node: '>=6'} - - caniuse-api@3.0.0: - resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} - - caniuse-lite@1.0.30001669: - resolution: {integrity: sha512-DlWzFDJqstqtIVx1zeSpIMLjunf5SmwOw0N2Ck/QSQdS8PLS4+9HrLaYei4w8BIAL7IB/UEDu889d8vhCTPA0w==} - - canvas-fit@1.5.0: - resolution: {integrity: sha512-onIcjRpz69/Hx5bB5HGbYKUF2uC6QT6Gp+pfpGm3A7mPfcluSLV5v4Zu+oflDUwLdUw0rLIBhUbi0v8hM4FJQQ==} - - caseless@0.11.0: - resolution: {integrity: sha512-ODLXH644w9C2fMPAm7bMDQ3GRvipZWZfKc+8As6hIadRIelE0n0xZuN38NS6kiK3KPEVrpymmQD8bvncAHWQkQ==} - - caseless@0.12.0: - resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} - - ccount@2.0.1: - resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - - chai@4.4.1: - resolution: {integrity: sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==} - engines: {node: '>=4'} - - chai@5.1.1: - resolution: {integrity: sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==} - engines: {node: '>=12'} - - chalk@2.4.2: - resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} - engines: {node: '>=4'} - - chalk@3.0.0: - resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} - engines: {node: '>=8'} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chalk@5.3.0: - resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - - character-entities-html4@2.1.0: - resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} - - character-entities-legacy@3.0.0: - resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} - - character-entities@2.0.2: - resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} - - character-reference-invalid@2.0.1: - resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} - - check-error@1.0.3: - resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} - - check-error@2.1.1: - resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} - engines: {node: '>= 16'} - - chevrotain-allstar@0.3.1: - resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} - peerDependencies: - chevrotain: ^11.0.0 - - chevrotain@11.0.3: - resolution: {integrity: sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==} - - chokidar@3.5.3: - resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} - engines: {node: '>= 8.10.0'} - - chrome-trace-event@1.0.4: - resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} - engines: {node: '>=6.0'} - - ci-info@4.0.0: - resolution: {integrity: sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==} - engines: {node: '>=8'} - - clamp@1.0.1: - resolution: {integrity: sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==} - - class-variance-authority@0.7.0: - resolution: {integrity: sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==} - - classcat@5.0.4: - resolution: {integrity: sha512-sbpkOw6z413p+HDGcBENe498WM9woqWHiJxCq7nvmxe9WmrUmqfAcxpIwAiMtM5Q3AhYkzXcNQHqsWq0mND51g==} - - clean-regexp@1.0.0: - resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} - engines: {node: '>=4'} - - client-only@0.0.1: - resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} - - cliui@3.2.0: - resolution: {integrity: sha512-0yayqDxWQbqk3ojkYqUKqaAQ6AfNKeKWRNA8kR0WXzAsdHpP4BIaOmMAG87JGuO6qcobyW4GjxHd9PmhEd+T9w==} - - cliui@5.0.0: - resolution: {integrity: sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - clone@2.1.2: - resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} - engines: {node: '>=0.8'} - - clsx@1.2.1: - resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} - engines: {node: '>=6'} - - clsx@2.0.0: - resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} - engines: {node: '>=6'} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - cmdk@1.0.0: - resolution: {integrity: sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==} - peerDependencies: - react: ^18.0.0 - react-dom: ^18.0.0 - - code-point-at@1.1.0: - resolution: {integrity: sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==} - engines: {node: '>=0.10.0'} - - codemirror-extension-inline-suggestion@0.0.3: - resolution: {integrity: sha512-IgNoyK7d4HJicqZMYMC1RgHlpR1pnSEgDHATKIuQrqW68o5QmD8IFZ26M/8UXNI+Usb00Vu4q0EtzMdm92pOAg==} - peerDependencies: - '@codemirror/state': ^6.2.0 - '@codemirror/view': ^6.7.2 - - codemirror-lang-mermaid@0.5.0: - resolution: {integrity: sha512-Taw/2gPCyNArQJCxIP/HSUif+3zrvD+6Ugt7KJZ2dUKou/8r3ZhcfG8krNTZfV2iu8AuGnymKuo7bLPFyqsh/A==} - - codemirror-languageserver@1.11.0: - resolution: {integrity: sha512-QtFEMc1yhi8wzKasE+OseMeikHpHdFt1wQdt5F4O3sKl7B1m56OcVuqmCAJipklbIPDkLecaXhd8oFvA72lfnw==} - - codemirror@6.0.1: - resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==} - - color-alpha@1.0.4: - resolution: {integrity: sha512-lr8/t5NPozTSqli+duAN+x+no/2WaKTeWvxhHGN+aXT6AJ8vPlzLa7UriyjWak0pSC2jHol9JgjBYnnHsGha9A==} - - color-convert@1.9.3: - resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-id@1.1.0: - resolution: {integrity: sha512-2iRtAn6dC/6/G7bBIo0uupVrIne1NsQJvJxZOBCzQOfk7jRq97feaDZ3RdzuHakRXXnHGNwglto3pqtRx1sX0g==} - - color-name@1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - color-normalize@1.5.0: - resolution: {integrity: sha512-rUT/HDXMr6RFffrR53oX3HGWkDOP9goSAQGBkUaAYKjOE2JxozccdGyufageWDlInRAjm/jYPrf/Y38oa+7obw==} - - color-parse@1.3.8: - resolution: {integrity: sha512-1Y79qFv0n1xair3lNMTNeoFvmc3nirMVBij24zbs1f13+7fPpQClMg5b4AuKXLt3szj7BRlHMCXHplkce6XlmA==} - - color-parse@2.0.0: - resolution: {integrity: sha512-g2Z+QnWsdHLppAbrpcFWo629kLOnOPtpxYV69GCqm92gqSgyXbzlfyN3MXs0412fPBkFmiuS+rXposgBgBa6Kg==} - - color-rgba@2.1.1: - resolution: {integrity: sha512-VaX97wsqrMwLSOR6H7rU1Doa2zyVdmShabKrPEIFywLlHoibgD3QW9Dw6fSqM4+H/LfjprDNAUUW31qEQcGzNw==} - - color-space@1.16.0: - resolution: {integrity: sha512-A6WMiFzunQ8KEPFmj02OnnoUnqhmSaHaZ/0LVFcPTdlvm8+3aMJ5x1HRHy3bDHPkovkf4sS0f4wsVvwk71fKkg==} - - colord@2.9.3: - resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - comma-separated-tokens@2.0.3: - resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} - - commander@2.20.3: - resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} - - commander@7.2.0: - resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} - engines: {node: '>= 10'} - - commander@8.3.0: - resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} - engines: {node: '>= 12'} - - compassql@0.21.2: - resolution: {integrity: sha512-r6+xj+QlP/rmdSgowYmjzDQLAIxKpRCTP+1as/KtfGpISucn+JPY+f7TniNPaBnpckQPOo9yb8mXOkNKzREcyg==} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - concat-stream@1.6.2: - resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} - engines: {'0': node >= 0.8} - - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - - convert-source-map@1.9.0: - resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - copy-anything@2.0.6: - resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==} - - copy-to-clipboard@3.3.3: - resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} - - core-js-compat@3.37.1: - resolution: {integrity: sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==} - - core-util-is@1.0.2: - resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} - - core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - - cose-base@1.0.3: - resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} - - cose-base@2.2.0: - resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} - - cosmiconfig@7.0.1: - resolution: {integrity: sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==} - engines: {node: '>=10'} - - cosmiconfig@9.0.0: - resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} - engines: {node: '>=14'} - peerDependencies: - typescript: '>=4.9.5' - peerDependenciesMeta: - typescript: - optional: true - - country-regex@1.1.0: - resolution: {integrity: sha512-iSPlClZP8vX7MC3/u6s3lrDuoQyhQukh5LyABJ3hvfzbQ3Yyayd4fp04zjLnfi267B/B2FkumcWWgrbban7sSA==} - - crelt@1.0.6: - resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} - - cross-spawn@7.0.3: - resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} - engines: {node: '>= 8'} - - css-declaration-sorter@7.2.0: - resolution: {integrity: sha512-h70rUM+3PNFuaBDTLe8wF/cdWu+dOZmb7pJt8Z2sedYbAcQVQV/tEchueg3GWxwqS0cxtbxmaHEdkNACqcvsow==} - engines: {node: ^14 || ^16 || >=18} - peerDependencies: - postcss: ^8.0.9 - - css-font-size-keywords@1.0.0: - resolution: {integrity: sha512-Q+svMDbMlelgCfH/RVDKtTDaf5021O486ZThQPIpahnIjUkMUslC+WuOQSWTgGSrNCH08Y7tYNEmmy0hkfMI8Q==} - - css-font-stretch-keywords@1.0.1: - resolution: {integrity: sha512-KmugPO2BNqoyp9zmBIUGwt58UQSfyk1X5DbOlkb2pckDXFSAfjsD5wenb88fNrD6fvS+vu90a/tsPpb9vb0SLg==} - - css-font-style-keywords@1.0.1: - resolution: {integrity: sha512-0Fn0aTpcDktnR1RzaBYorIxQily85M2KXRpzmxQPgh8pxUN9Fcn00I8u9I3grNr1QXVgCl9T5Imx0ZwKU973Vg==} - - css-font-weight-keywords@1.0.0: - resolution: {integrity: sha512-5So8/NH+oDD+EzsnF4iaG4ZFHQ3vaViePkL1ZbZ5iC/KrsCY+WHq/lvOgrtmuOQ9pBBZ1ADGpaf+A4lj1Z9eYA==} - - css-font@1.2.0: - resolution: {integrity: sha512-V4U4Wps4dPDACJ4WpgofJ2RT5Yqwe1lEH6wlOOaIxMi0gTjdIijsc5FmxQlZ7ZZyKQkkutqqvULOp07l9c7ssA==} - - css-functions-list@3.2.2: - resolution: {integrity: sha512-c+N0v6wbKVxTu5gOBBFkr9BEdBWaqqjQeiJ8QvSRIJOf+UxlJh930m8e6/WNeODIK0mYLFkoONrnj16i2EcvfQ==} - engines: {node: '>=12 || >=16'} - - css-global-keywords@1.0.1: - resolution: {integrity: sha512-X1xgQhkZ9n94WDwntqst5D/FKkmiU0GlJSFZSV3kLvyJ1WC5VeyoXDOuleUD+SIuH9C7W05is++0Woh0CGfKjQ==} - - css-loader@7.1.2: - resolution: {integrity: sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==} - engines: {node: '>= 18.12.0'} - peerDependencies: - '@rspack/core': 0.x || 1.x - webpack: ^5.27.0 - peerDependenciesMeta: - '@rspack/core': - optional: true - webpack: - optional: true - - css-select@5.1.0: - resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} - - css-system-font-keywords@1.0.0: - resolution: {integrity: sha512-1umTtVd/fXS25ftfjB71eASCrYhilmEsvDEI6wG/QplnmlfmVM5HkZ/ZX46DT5K3eblFPgLUHt5BRCb0YXkSFA==} - - css-tree@2.2.1: - resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} - - css-tree@2.3.1: - resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - - css-what@6.1.0: - resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} - engines: {node: '>= 6'} - - css.escape@1.5.1: - resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - - csscolorparser@1.0.3: - resolution: {integrity: sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==} - - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - - cssnano-preset-default@7.0.5: - resolution: {integrity: sha512-Jbzja0xaKwc5JzxPQoc+fotKpYtWEu4wQLMQe29CM0FjjdRjA4omvbGHl2DTGgARKxSTpPssBsok+ixv8uTBqw==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - cssnano-utils@5.0.0: - resolution: {integrity: sha512-Uij0Xdxc24L6SirFr25MlwC2rCFX6scyUmuKpzI+JQ7cyqDEwD42fJ0xfB3yLfOnRDU5LKGgjQ9FA6LYh76GWQ==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - cssnano@7.0.5: - resolution: {integrity: sha512-Aq0vqBLtpTT5Yxj+hLlLfNPFuRQCDIjx5JQAhhaedQKLNDvDGeVziF24PS+S1f0Z5KCxWvw0QVI3VNHNBITxVQ==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - csso@5.0.5: - resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} - - cssstyle@4.0.1: - resolution: {integrity: sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ==} - engines: {node: '>=18'} - - csstype@3.1.3: - resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - - cytoscape-cose-bilkent@4.1.0: - resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} - peerDependencies: - cytoscape: ^3.2.0 - - cytoscape-fcose@2.2.0: - resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} - peerDependencies: - cytoscape: ^3.2.0 - - cytoscape@3.30.2: - resolution: {integrity: sha512-oICxQsjW8uSaRmn4UK/jkczKOqTrVqt5/1WL0POiJUT2EKNc9STM4hYFHv917yu55aTBMFNRzymlJhVAiWPCxw==} - engines: {node: '>=0.10'} - - d3-array@1.2.4: - resolution: {integrity: sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==} - - d3-array@3.2.4: - resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} - engines: {node: '>=12'} - - d3-axis@3.0.0: - resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} - engines: {node: '>=12'} - - d3-brush@3.0.0: - resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} - engines: {node: '>=12'} - - d3-chord@3.0.1: - resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} - engines: {node: '>=12'} - - d3-collection@1.0.7: - resolution: {integrity: sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==} - - d3-color@3.1.0: - resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} - engines: {node: '>=12'} - - d3-contour@4.0.2: - resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} - engines: {node: '>=12'} - - d3-delaunay@6.0.4: - resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} - engines: {node: '>=12'} - - d3-dispatch@1.0.6: - resolution: {integrity: sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==} - - d3-dispatch@3.0.1: - resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} - engines: {node: '>=12'} - - d3-drag@3.0.0: - resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} - engines: {node: '>=12'} - - d3-dsv@0.1.14: - resolution: {integrity: sha512-GtPXlXg9uXYhizNtLf9TVQDmrs4/p728DtbEG+rnrjupgDFeKODqtd4OepPCthhIjsH96xajcpFxor3d0Mt2Tg==} - hasBin: true - - d3-dsv@3.0.1: - resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} - engines: {node: '>=12'} - hasBin: true - - d3-ease@3.0.1: - resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} - engines: {node: '>=12'} - - d3-fetch@3.0.1: - resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} - engines: {node: '>=12'} - - d3-force@1.2.1: - resolution: {integrity: sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==} - - d3-force@3.0.0: - resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} - engines: {node: '>=12'} - - d3-format@0.4.2: - resolution: {integrity: sha512-lkWt9H70RX/o95aD3nc8crYALo2x069vyGSb8JGAF9A/g9W3UNf512yOTb5f+LF/sv9NWlkIameXVuL4adPYkQ==} - - d3-format@1.4.5: - resolution: {integrity: sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==} - - d3-format@3.1.0: - resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} - engines: {node: '>=12'} - - d3-geo-projection@0.2.16: - resolution: {integrity: sha512-NB4/NRMnfJnpodvRbNY/nOzuoU17P229ASYf2l1GwjZyfD7l5aIuMylDMbIBF4y42BGZZvGdUwFW8iFM/5UBzg==} - - d3-geo-projection@2.9.0: - resolution: {integrity: sha512-ZULvK/zBn87of5rWAfFMc9mJOipeSo57O+BBitsKIXmU4rTVAnX1kSsJkE0R+TxY8pGNoM1nbyRRE7GYHhdOEQ==} - hasBin: true - - d3-geo-projection@4.0.0: - resolution: {integrity: sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==} - engines: {node: '>=12'} - hasBin: true - - d3-geo@1.12.1: - resolution: {integrity: sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==} - - d3-geo@3.1.1: - resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} - engines: {node: '>=12'} - - d3-hierarchy@1.1.9: - resolution: {integrity: sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==} - - d3-hierarchy@3.1.2: - resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} - engines: {node: '>=12'} - - d3-interpolate@3.0.1: - resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} - engines: {node: '>=12'} - - d3-path@1.0.9: - resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} - - d3-path@3.1.0: - resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} - engines: {node: '>=12'} - - d3-polygon@3.0.1: - resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} - engines: {node: '>=12'} - - d3-quadtree@1.0.7: - resolution: {integrity: sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==} - - d3-quadtree@3.0.1: - resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} - engines: {node: '>=12'} - - d3-queue@1.2.3: - resolution: {integrity: sha512-m6KtxX4V5pmVf1PqhH4SkQVMshSJfyCLM2vf2oFPi9FWFVT3+rtbCGerk766b/JXymHQDU3oqXHaZoiQ/e8yUQ==} - - d3-queue@2.0.3: - resolution: {integrity: sha512-ejbdHqZYEmk9ns/ljSbEcD6VRiuNwAkZMdFf6rsUb3vHROK5iMFd8xewDQnUVr6m/ba2BG63KmR/LySfsluxbg==} - - d3-random@3.0.1: - resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} - engines: {node: '>=12'} - - d3-sankey@0.12.3: - resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} - - d3-scale-chromatic@3.1.0: - resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} - engines: {node: '>=12'} - - d3-scale@4.0.2: - resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} - engines: {node: '>=12'} - - d3-selection@3.0.0: - resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} - engines: {node: '>=12'} - - d3-shape@1.3.7: - resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} - - d3-shape@3.2.0: - resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} - engines: {node: '>=12'} - - d3-time-format@0.2.1: - resolution: {integrity: sha512-o9JubPrATPxZ9PqMwerQel4X47q2Ts0dirYNQtkSe0uZbgRWbEJBcP3IIRbdv6BM5gxAucz9Ph4bJTa485nszw==} - - d3-time-format@2.3.0: - resolution: {integrity: sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==} - - d3-time-format@4.1.0: - resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} - engines: {node: '>=12'} - - d3-time@0.1.1: - resolution: {integrity: sha512-aRZzw+adVtkM28p47fRe9hzUvLYtvb9EVtmCB2nu2wOgOxCraqmKvBrBkfeMvQoC7sVkChXbKmxYJL4dhb9/xA==} - - d3-time@1.1.0: - resolution: {integrity: sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==} - - d3-time@3.1.0: - resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} - engines: {node: '>=12'} - - d3-timer@1.0.10: - resolution: {integrity: sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==} - - d3-timer@3.0.1: - resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} - engines: {node: '>=12'} - - d3-transition@3.0.1: - resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} - engines: {node: '>=12'} - peerDependencies: - d3-selection: 2 - 3 - - d3-zoom@3.0.0: - resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} - engines: {node: '>=12'} - - d3@3.5.17: - resolution: {integrity: sha512-yFk/2idb8OHPKkbAL8QaOaqENNoMhIaSHZerk3oQsECwkObkCpJyjYwCe+OHiq6UEdhe1m8ZGARRRO3ljFjlKg==} - - d3@7.9.0: - resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} - engines: {node: '>=12'} - - d@1.0.1: - resolution: {integrity: sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==} - - dagre-d3-es@7.0.10: - resolution: {integrity: sha512-qTCQmEhcynucuaZgY5/+ti3X/rnszKZhEQH/ZdWdtP1tA/y3VoHJzcVrO9pjjJCNpigfscAtoUB5ONcd2wNn0A==} - - damerau-levenshtein@1.0.8: - resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} - - dashdash@1.14.1: - resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} - engines: {node: '>=0.10'} - - data-urls@5.0.0: - resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} - engines: {node: '>=18'} - - data-view-buffer@1.0.1: - resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} - engines: {node: '>= 0.4'} - - data-view-byte-length@1.0.1: - resolution: {integrity: sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==} - engines: {node: '>= 0.4'} - - data-view-byte-offset@1.0.0: - resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} - engines: {node: '>= 0.4'} - - datalib@1.7.3: - resolution: {integrity: sha512-nA2t1SrVwZMfVFSdpqTRtymIaoadd7nWUNBcS499sUHlQXI0fP1g9zl/eYWM4dhJw4idUyjCt9+ZbLHbtbfviw==} - - date-fns@3.6.0: - resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} - - dayjs@1.11.10: - resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==} - - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - debug@4.3.6: - resolution: {integrity: sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decamelize@1.2.0: - resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} - engines: {node: '>=0.10.0'} - - decimal.js@10.4.3: - resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} - - decode-named-character-reference@1.0.2: - resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} - - deep-eql@4.1.3: - resolution: {integrity: sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==} - engines: {node: '>=6'} - - deep-eql@5.0.2: - resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} - engines: {node: '>=6'} - - deep-equal@2.2.3: - resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} - engines: {node: '>= 0.4'} - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - - define-lazy-prop@2.0.0: - resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} - engines: {node: '>=8'} - - define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} - - defined@1.0.1: - resolution: {integrity: sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==} - - delaunator@5.0.0: - resolution: {integrity: sha512-AyLvtyJdbv/U1GkiS6gUUzclRoAY4Gs75qkMygJJhU75LW4DNuSF2RMzpxs9jw9Oz1BobHjTdkG3zdP55VxAqw==} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - - detect-kerning@2.1.2: - resolution: {integrity: sha512-I3JIbrnKPAntNLl1I6TpSQQdQ4AutYzv/sKMFKbepawV/hlH0GmYKhUoOEMd4xqaUHT+Bm0f4127lh5qs1m1tw==} - - detect-node-es@1.1.0: - resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} - - devlop@1.1.0: - resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - - didyoumean@1.2.2: - resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - - diff-match-patch@1.0.5: - resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} - - diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - - dlv@1.1.3: - resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - - dnd-core@14.0.1: - resolution: {integrity: sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==} - - doctrine@2.1.0: - resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} - engines: {node: '>=0.10.0'} - - doctrine@3.0.0: - resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} - engines: {node: '>=6.0.0'} - - dom-accessibility-api@0.5.16: - resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - - dom-accessibility-api@0.6.3: - resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - - dom-helpers@5.2.1: - resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} - - dom-serializer@2.0.0: - resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} - - domelementtype@2.3.0: - resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - - domhandler@5.0.3: - resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} - engines: {node: '>= 4'} - - dompurify@3.1.6: - resolution: {integrity: sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==} - - domutils@3.1.0: - resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} - - draw-svg-path@1.0.0: - resolution: {integrity: sha512-P8j3IHxcgRMcY6sDzr0QvJDLzBnJJqpTG33UZ2Pvp8rw0apCHhJCWqYprqrXjrgHnJ6tuhP1iTJSAodPDHxwkg==} - - dtype@2.0.0: - resolution: {integrity: sha512-s2YVcLKdFGS0hpFqJaTwscsyt0E8nNFdmo73Ocd81xNPj4URI4rj6D60A+vFMIw7BXWlb4yRkEwfBqcZzPGiZg==} - engines: {node: '>= 0.8.0'} - - dup@1.0.0: - resolution: {integrity: sha512-Bz5jxMMC0wgp23Zm15ip1x8IhYRqJvF3nFC0UInJUDkN1z4uNPk9jTnfCUJXbOGiQ1JbXLQsiV41Fb+HXcj5BA==} - - duplexer2@0.1.4: - resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} - - duplexify@3.7.1: - resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} - - earcut@2.2.4: - resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} - - earcut@3.0.0: - resolution: {integrity: sha512-41Fs7Q/PLq1SDbqjsgcY7GA42T0jvaCNGXgGtsNdvg+Yv8eIu06bxv4/PoREkZ9nMDNwnUSG9OFB9+yv8eKhDg==} - - ecc-jsbn@0.1.2: - resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} - - electron-to-chromium@1.5.43: - resolution: {integrity: sha512-NxnmFBHDl5Sachd2P46O7UJiMaMHMLSofoIWVJq3mj8NJgG0umiSeljAVP9lGzjI0UDLJJ5jjoGjcrB8RSbjLQ==} - - element-size@1.1.1: - resolution: {integrity: sha512-eaN+GMOq/Q+BIWy0ybsgpcYImjGIdNLyjLFJU4XsLHXYQao5jCNb36GyN6C2qwmDDYSfIBmKpPpr4VnBdLCsPQ==} - - elementary-circuits-directed-graph@1.3.1: - resolution: {integrity: sha512-ZEiB5qkn2adYmpXGnJKkxT8uJHlW/mxmBpmeqawEHzPxh9HkLD4/1mFYX5l0On+f6rcPIt8/EWlRU2Vo3fX6dQ==} - - emoji-regex@7.0.3: - resolution: {integrity: sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - - end-of-stream@1.4.4: - resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} - - enhanced-resolve@5.17.1: - resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==} - engines: {node: '>=10.13.0'} - - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} - engines: {node: '>=0.12'} - - env-paths@2.2.1: - resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} - engines: {node: '>=6'} - - errno@0.1.8: - resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} - hasBin: true - - error-ex@1.3.2: - resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} - - es-abstract@1.23.3: - resolution: {integrity: sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==} - engines: {node: '>= 0.4'} - - es-define-property@1.0.0: - resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-get-iterator@1.1.3: - resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} - - es-iterator-helpers@1.0.19: - resolution: {integrity: sha512-zoMwbCcH5hwUkKJkT8kDIBZSz9I6mVG//+lDCinLCGov4+r7NIy0ld8o03M0cJxl2spVf6ESYVS6/gpIfq1FFw==} - engines: {node: '>= 0.4'} - - es-module-lexer@1.5.4: - resolution: {integrity: sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==} - - es-object-atoms@1.0.0: - resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.0.3: - resolution: {integrity: sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==} - engines: {node: '>= 0.4'} - - es-shim-unscopables@1.0.2: - resolution: {integrity: sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==} - - es-to-primitive@1.2.1: - resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} - engines: {node: '>= 0.4'} - - es5-ext@0.10.62: - resolution: {integrity: sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==} - engines: {node: '>=0.10'} - - es6-iterator@2.0.3: - resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==} - - es6-symbol@3.1.3: - resolution: {integrity: sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==} - - es6-weak-map@2.0.3: - resolution: {integrity: sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==} - - esbuild-register@3.5.0: - resolution: {integrity: sha512-+4G/XmakeBAsvJuDugJvtyF1x+XJT4FMocynNpxrvEBViirpfUn2PgNpCHedfWhF4WokNsO/OvMKrmJOIJsI5A==} - peerDependencies: - esbuild: '>=0.12 <1' - - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} - engines: {node: '>=0.8.0'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - escodegen@1.14.3: - resolution: {integrity: sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==} - engines: {node: '>=4.0'} - hasBin: true - - escodegen@1.9.1: - resolution: {integrity: sha512-6hTjO1NAWkHnDk3OqQ4YrCuwwmGHL9S3nPlzBOUG/R44rda3wLNrfvQ5fkSGjyhHFKM7ALPKcKGrwvCLe0lC7Q==} - engines: {node: '>=4.0'} - hasBin: true - - eslint-config-prettier@9.1.0: - resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} - hasBin: true - peerDependencies: - eslint: '>=7.0.0' - - eslint-plugin-header@3.1.1: - resolution: {integrity: sha512-9vlKxuJ4qf793CmeeSrZUvVClw6amtpghq3CuWcB5cUNnWHQhgcqy5eF8oVKFk1G3Y/CbchGfEaw3wiIJaNmVg==} - peerDependencies: - eslint: '>=7.7.0' - - eslint-plugin-jsx-a11y@6.9.0: - resolution: {integrity: sha512-nOFOCaJG2pYqORjK19lqPqxMO/JpvdCZdPtNdxY3kvom3jTvkAbOvQvD8wuD0G8BYR0IGAGYDlzqWJOh/ybn2g==} - engines: {node: '>=4.0'} - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 - - eslint-plugin-react-compiler@19.0.0-beta-8a03594-20241020: - resolution: {integrity: sha512-bYg1COih1s3r14IV/AKdQs/SN7CQmNI0ZaMtPdgZ6gp1S1Q/KGP9P43w7R6dHJ4wYpuMBvekNJHQdVu+x6UM+A==} - engines: {node: ^14.17.0 || ^16.0.0 || >= 18.0.0} - peerDependencies: - eslint: '>=7' - - eslint-plugin-react-hooks@4.6.2: - resolution: {integrity: sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==} - engines: {node: '>=10'} - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 - - eslint-plugin-react@7.34.3: - resolution: {integrity: sha512-aoW4MV891jkUulwDApQbPYTVZmeuSyFrudpbTAQuj5Fv8VL+o6df2xIGpw8B0hPjAaih1/Fb0om9grCdyFYemA==} - engines: {node: '>=4'} - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 - - eslint-plugin-ssr-friendly@1.3.0: - resolution: {integrity: sha512-VOYl9OgK9mSVWxwl3pSTzNmBUMhPYjDGmxgyjSM9Agdve4GHjn0gAcCG/seg1taaW/aBWTkb7Aw4GIBsxVhL9Q==} - peerDependencies: - eslint: '>=0.8.0' - - eslint-plugin-storybook@0.10.1: - resolution: {integrity: sha512-YpxkdqyiKpMIrRquuvBaCinsqmZJ86JvXRX/gtRa4Qctpk0ipFt2cWqEjkB1HHWWG0DVRXlUBKHjRogC2Ig1fg==} - engines: {node: '>= 18'} - peerDependencies: - eslint: '>=6' - - eslint-plugin-unicorn@54.0.0: - resolution: {integrity: sha512-XxYLRiYtAWiAjPv6z4JREby1TAE2byBC7wlh0V4vWDCpccOSU1KovWV//jqPXF6bq3WKxqX9rdjoRQ1EhdmNdQ==} - engines: {node: '>=18.18'} - peerDependencies: - eslint: '>=8.56.0' - - eslint-plugin-vitest@0.4.1: - resolution: {integrity: sha512-+PnZ2u/BS+f5FiuHXz4zKsHPcMKHie+K+1Uvu/x91ovkCMEOJqEI8E9Tw1Wzx2QRz4MHOBHYf1ypO8N1K0aNAA==} - engines: {node: ^18.0.0 || >= 20.0.0} - peerDependencies: - '@typescript-eslint/eslint-plugin': '*' - eslint: '>=8.0.0' - vitest: '*' - peerDependenciesMeta: - '@typescript-eslint/eslint-plugin': - optional: true - vitest: - optional: true - - eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - - eslint-scope@7.2.2: - resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@4.0.0: - resolution: {integrity: sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint@8.57.0: - resolution: {integrity: sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. - hasBin: true - - espree@10.1.0: - resolution: {integrity: sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - espree@9.6.1: - resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - esprima@3.1.3: - resolution: {integrity: sha512-AWwVMNxwhN8+NIPQzAQZCm7RkLC4RbM3B1OobMuyp3i+w73X57KCKaVIxaRZb+DYCojq7rspo+fmuQfAboyhFg==} - engines: {node: '>=4'} - hasBin: true - - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - - esquery@1.5.0: - resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - estree-util-is-identifier-name@3.0.0: - resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} - - estree-walker@2.0.2: - resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - event-target-shim@6.0.2: - resolution: {integrity: sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==} - engines: {node: '>=10.13.0'} - - events@3.3.0: - resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} - engines: {node: '>=0.8.x'} - - eventsource-parser@1.1.2: - resolution: {integrity: sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==} - engines: {node: '>=14.18'} - - execa@8.0.1: - resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} - engines: {node: '>=16.17'} - - ext@1.7.0: - resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} - - extend@3.0.2: - resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - - extsprintf@1.3.0: - resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} - engines: {'0': node >=0.6.0} - - falafel@2.2.5: - resolution: {integrity: sha512-HuC1qF9iTnHDnML9YZAdCDQwT0yKl/U55K4XSUXqGAA2GLoafFgWRqdAbhWJxXaYD4pyoVxAJ8wH670jMpI9DQ==} - engines: {node: '>=0.4.0'} - - fast-deep-equal@2.0.1: - resolution: {integrity: sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-equals@4.0.3: - resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==} - - fast-glob@3.3.2: - resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} - engines: {node: '>=8.6.0'} - - fast-isnumeric@1.1.4: - resolution: {integrity: sha512-1mM8qOr2LYz8zGaUdmiqRDiuue00Dxjgcb1NQR7TnhLVh6sQyngP9xvLo7Sl7LZpP/sk5eb+bcyWXw530NTBZw==} - - fast-json-patch@3.1.1: - resolution: {integrity: sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==} - - fast-json-stable-stringify@2.0.0: - resolution: {integrity: sha512-eIgZvM9C3P05kg0qxfqaVU6Tma4QedCPIByQOcemV0vju8ot3cS2DpHi4m2G2JvbSMI152rjfLX0p1pkSdyPlQ==} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fastest-levenshtein@1.0.16: - resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} - engines: {node: '>= 4.9.1'} - - fastq@1.13.0: - resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==} - - file-entry-cache@6.0.1: - resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} - engines: {node: ^10.12.0 || >=12.0.0} - - file-entry-cache@9.0.0: - resolution: {integrity: sha512-6MgEugi8p2tiUhqO7GnPsmbCCzj0YRCwwaTbpGRyKZesjRSzkqkAE9fPp7V2yMs5hwfgbQLgdvSSkGNg1s5Uvw==} - engines: {node: '>=18'} - - file-selector@0.6.0: - resolution: {integrity: sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==} - engines: {node: '>= 12'} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - find-root@1.1.0: - resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} - - find-up@1.1.2: - resolution: {integrity: sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA==} - engines: {node: '>=0.10.0'} - - find-up@3.0.0: - resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} - engines: {node: '>=6'} - - find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@3.2.0: - resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} - engines: {node: ^10.12.0 || >=12.0.0} - - flat-cache@5.0.0: - resolution: {integrity: sha512-JrqFmyUl2PnPi1OvLyTVHnQvwQ0S+e6lGSwu8OkAZlSaNIZciTY2H/cOOROxsBA1m/LZNHDsqAgDZt6akWcjsQ==} - engines: {node: '>=18'} - - flatted@3.3.1: - resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} - - flatten-vertex-data@1.0.2: - resolution: {integrity: sha512-BvCBFK2NZqerFTdMDgqfHBwxYWnxeCkwONsw6PvBMcUXqo8U/KDWwmXhqx1x2kLIg7DqIsJfOaJFOmlua3Lxuw==} - - font-atlas@2.1.0: - resolution: {integrity: sha512-kP3AmvX+HJpW4w3d+PiPR2X6E1yvsBXt2yhuCw+yReO9F1WYhvZwx3c95DGZGwg9xYzDGrgJYa885xmVA+28Cg==} - - font-measure@1.2.2: - resolution: {integrity: sha512-mRLEpdrWzKe9hbfaF3Qpr06TAjquuBVP5cHy4b3hyeNdjc9i0PO6HniGsX5vjL5OWv7+Bd++NiooNpT/s8BvIA==} - - for-each@0.3.3: - resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} - - forever-agent@0.6.1: - resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} - - form-data@2.3.3: - resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} - engines: {node: '>= 0.12'} - - form-data@4.0.0: - resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} - engines: {node: '>= 6'} - - fraction.js@4.3.7: - resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} - - from2@2.3.0: - resolution: {integrity: sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==} - - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - function.prototype.name@1.1.6: - resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==} - engines: {node: '>= 0.4'} - - functions-have-names@1.2.3: - resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - geojson-vt@3.2.1: - resolution: {integrity: sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==} - - geojson-vt@4.0.2: - resolution: {integrity: sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==} - - get-caller-file@1.0.3: - resolution: {integrity: sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-canvas-context@1.0.2: - resolution: {integrity: sha512-LnpfLf/TNzr9zVOGiIY6aKCz8EKuXmlYNV7CM2pUjBa/B+c2I15tS7KLySep75+FuerJdmArvJLcsAXWEy2H0A==} - - get-func-name@2.0.2: - resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} - - get-intrinsic@1.2.4: - resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} - engines: {node: '>= 0.4'} - - get-nonce@1.0.1: - resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} - engines: {node: '>=6'} - - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - - get-stream@8.0.1: - resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} - engines: {node: '>=16'} - - get-symbol-description@1.0.2: - resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} - engines: {node: '>= 0.4'} - - getpass@0.1.7: - resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} - - gl-mat4@1.2.0: - resolution: {integrity: sha512-sT5C0pwB1/e9G9AvAoLsoaJtbMGjfd/jfxo8jMCKqYYEnjZuFvqV5rehqar0538EmssjdDeiEWnKyBSTw7quoA==} - - gl-matrix@3.4.3: - resolution: {integrity: sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA==} - - gl-text@1.4.0: - resolution: {integrity: sha512-o47+XBqLCj1efmuNyCHt7/UEJmB9l66ql7pnobD6p+sgmBUdzfMZXIF0zD2+KRfpd99DJN+QXdvTFAGCKCVSmQ==} - - gl-util@3.1.3: - resolution: {integrity: sha512-dvRTggw5MSkJnCbh74jZzSoTOGnVYK+Bt+Ckqm39CVcl6+zSsxqWk4lr5NKhkqXHL6qvZAU9h17ZF8mIskY9mA==} - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - glob-to-regexp@0.4.1: - resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - - glob@7.1.6: - resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} - deprecated: Glob versions prior to v9 are no longer supported - - glob@7.2.0: - resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} - deprecated: Glob versions prior to v9 are no longer supported - - global-modules@2.0.0: - resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} - engines: {node: '>=6'} - - global-prefix@3.0.0: - resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} - engines: {node: '>=6'} - - global-prefix@4.0.0: - resolution: {integrity: sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==} - engines: {node: '>=16'} - - globals@11.12.0: - resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} - engines: {node: '>=4'} - - globals@13.20.0: - resolution: {integrity: sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==} - engines: {node: '>=8'} - - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - - globalthis@1.0.3: - resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==} - engines: {node: '>= 0.4'} - - globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} - - globjoin@0.1.4: - resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} - - globrex@0.1.2: - resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} - - glsl-inject-defines@1.0.3: - resolution: {integrity: sha512-W49jIhuDtF6w+7wCMcClk27a2hq8znvHtlGnrYkSWEr8tHe9eA2dcnohlcAmxLYBSpSSdzOkRdyPTrx9fw49+A==} - - glsl-resolve@0.0.1: - resolution: {integrity: sha512-xxFNsfnhZTK9NBhzJjSBGX6IOqYpvBHxxmo+4vapiljyGNCY0Bekzn0firQkQrazK59c1hYxMDxYS8MDlhw4gA==} - - glsl-token-assignments@2.0.2: - resolution: {integrity: sha512-OwXrxixCyHzzA0U2g4btSNAyB2Dx8XrztY5aVUCjRSh4/D0WoJn8Qdps7Xub3sz6zE73W3szLrmWtQ7QMpeHEQ==} - - glsl-token-defines@1.0.0: - resolution: {integrity: sha512-Vb5QMVeLjmOwvvOJuPNg3vnRlffscq2/qvIuTpMzuO/7s5kT+63iL6Dfo2FYLWbzuiycWpbC0/KV0biqFwHxaQ==} - - glsl-token-depth@1.1.2: - resolution: {integrity: sha512-eQnIBLc7vFf8axF9aoi/xW37LSWd2hCQr/3sZui8aBJnksq9C7zMeUYHVJWMhFzXrBU7fgIqni4EhXVW4/krpg==} - - glsl-token-descope@1.0.2: - resolution: {integrity: sha512-kS2PTWkvi/YOeicVjXGgX5j7+8N7e56srNDEHDTVZ1dcESmbmpmgrnpjPcjxJjMxh56mSXYoFdZqb90gXkGjQw==} - - glsl-token-inject-block@1.1.0: - resolution: {integrity: sha512-q/m+ukdUBuHCOtLhSr0uFb/qYQr4/oKrPSdIK2C4TD+qLaJvqM9wfXIF/OOBjuSA3pUoYHurVRNao6LTVVUPWA==} - - glsl-token-properties@1.0.1: - resolution: {integrity: sha512-dSeW1cOIzbuUoYH0y+nxzwK9S9O3wsjttkq5ij9ZGw0OS41BirKJzzH48VLm8qLg+au6b0sINxGC0IrGwtQUcA==} - - glsl-token-scope@1.1.2: - resolution: {integrity: sha512-YKyOMk1B/tz9BwYUdfDoHvMIYTGtVv2vbDSLh94PT4+f87z21FVdou1KNKgF+nECBTo0fJ20dpm0B1vZB1Q03A==} - - glsl-token-string@1.0.1: - resolution: {integrity: sha512-1mtQ47Uxd47wrovl+T6RshKGkRRCYWhnELmkEcUAPALWGTFe2XZpH3r45XAwL2B6v+l0KNsCnoaZCSnhzKEksg==} - - glsl-token-whitespace-trim@1.0.0: - resolution: {integrity: sha512-ZJtsPut/aDaUdLUNtmBYhaCmhIjpKNg7IgZSfX5wFReMc2vnj8zok+gB/3Quqs0TsBSX/fGnqUUYZDqyuc2xLQ==} - - glsl-tokenizer@2.1.5: - resolution: {integrity: sha512-XSZEJ/i4dmz3Pmbnpsy3cKh7cotvFlBiZnDOwnj/05EwNp2XrhQ4XKJxT7/pDt4kp4YcpRSKz8eTV7S+mwV6MA==} - - glslify-bundle@5.1.1: - resolution: {integrity: sha512-plaAOQPv62M1r3OsWf2UbjN0hUYAB7Aph5bfH58VxJZJhloRNbxOL9tl/7H71K7OLJoSJ2ZqWOKk3ttQ6wy24A==} - - glslify-deps@1.3.2: - resolution: {integrity: sha512-7S7IkHWygJRjcawveXQjRXLO2FTjijPDYC7QfZyAQanY+yGLCFHYnPtsGT9bdyHiwPTw/5a1m1M9hamT2aBpag==} - - glslify@7.1.1: - resolution: {integrity: sha512-bud98CJ6kGZcP9Yxcsi7Iz647wuDz3oN+IZsjCRi5X1PI7t/xPKeL0mOwXJjo+CRZMqvq0CkSJiywCcY7kVYog==} - hasBin: true - - gopd@1.0.1: - resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - - grid-index@1.1.0: - resolution: {integrity: sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==} - - hachure-fill@0.5.2: - resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} - - har-schema@2.0.0: - resolution: {integrity: sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==} - engines: {node: '>=4'} - - har-validator@5.1.5: - resolution: {integrity: sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==} - engines: {node: '>=6'} - deprecated: this library is no longer supported - - has-bigints@1.0.2: - resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} - - has-flag@3.0.0: - resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} - engines: {node: '>=4'} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - has-hover@1.0.1: - resolution: {integrity: sha512-0G6w7LnlcpyDzpeGUTuT0CEw05+QlMuGVk1IHNAlHrGJITGodjZu3x8BNDUMfKJSZXNB2ZAclqc1bvrd+uUpfg==} - - has-passive-events@1.0.0: - resolution: {integrity: sha512-2vSj6IeIsgvsRMyeQ0JaCX5Q3lX4zMn5HpoVc7MEhQ6pv8Iq9rsXjsp+E5ZwaT7T0xhMT0KmU8gtt1EFVdbJiw==} - - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - - has-proto@1.0.3: - resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} - engines: {node: '>= 0.4'} - - has-symbols@1.0.3: - resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - has@1.0.3: - resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} - engines: {node: '>= 0.4.0'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - hast-util-to-jsx-runtime@2.3.2: - resolution: {integrity: sha512-1ngXYb+V9UT5h+PxNRa1O1FYguZK/XL+gkeqvp7EdHlB9oHUG0eYRo/vY5inBdcqo3RkPMC58/H94HvkbfGdyg==} - - hast-util-whitespace@3.0.0: - resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} - - hermes-estree@0.20.1: - resolution: {integrity: sha512-SQpZK4BzR48kuOg0v4pb3EAGNclzIlqMj3Opu/mu7bbAoFw6oig6cEt/RAi0zTFW/iW6Iz9X9ggGuZTAZ/yZHg==} - - hermes-parser@0.20.1: - resolution: {integrity: sha512-BL5P83cwCogI8D7rrDCgsFY0tdYUtmFP9XaXtl2IQjC+2Xo+4okjfXintlTxcIwl4qeGddEl28Z11kbVIw0aNA==} - - hoist-non-react-statics@3.3.2: - resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} - - hosted-git-info@2.8.9: - resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} - - hsluv@0.0.3: - resolution: {integrity: sha512-08iL2VyCRbkQKBySkSh6m8zMUa3sADAxGVWs3Z1aPcUkTJeK0ETG4Fc27tEmQBGUAXZjIsXOZqBvacuVNSC/fQ==} - - htm@3.1.1: - resolution: {integrity: sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==} - - html-dom-parser@5.0.9: - resolution: {integrity: sha512-QGeoFYwgQ582EDvrBx0+ejIz76/LuQcwwkmSR4ueKncjl2yWbciA45Kfz/LrHvWR3CgtKnxKFkr4Mpq2Sh1QNg==} - - html-encoding-sniffer@4.0.0: - resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} - engines: {node: '>=18'} - - html-react-parser@5.1.12: - resolution: {integrity: sha512-OPv8fsIvxxv/+pLj9mYvyNu8PE5dPMowTRdd5VHpcoZpXlstp8eYCxQ5rzqAE5Tb75rhdiWUXnPltfb62zCVjg==} - peerDependencies: - '@types/react': 0.14 || 15 || 16 || 17 || 18 - react: 0.14 || 15 || 16 || 17 || 18 - peerDependenciesMeta: - '@types/react': - optional: true - - html-tags@3.3.1: - resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} - engines: {node: '>=8'} - - html-to-image@1.11.11: - resolution: {integrity: sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA==} - - html-url-attributes@3.0.1: - resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} - - htmlparser2@9.1.0: - resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} - - http-basic@2.5.1: - resolution: {integrity: sha512-q/qOkgjcnZ90v0wSaMwamhfAhIf6lhOsH0ehHFnQHAt1lA9MedSnmqEEnh8bq0njTBAK3IsmS2gEuXryfWCDkw==} - - http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} - - http-response-object@1.1.0: - resolution: {integrity: sha512-adERueQxEMtIfGk4ee/9CG7AGUjS09OyHeKrubTjmHUsEVXesrGlZLWYnCL8fajPZIX9H4NDnXyyzBPrF078sA==} - - http-signature@1.2.0: - resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==} - engines: {node: '>=0.8', npm: '>=1.3.7'} - - https-proxy-agent@7.0.5: - resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==} - engines: {node: '>= 14'} - - human-signals@5.0.0: - resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} - engines: {node: '>=16.17.0'} - - humanize-duration@3.32.1: - resolution: {integrity: sha512-inh5wue5XdfObhu/IGEMiA1nUXigSGcaKNemcbLRKa7jXYGDZXr3LoT9pTIzq2hPEbld7w/qv9h+ikWGz8fL1g==} - - iconify-icon@2.1.0: - resolution: {integrity: sha512-lto4XU3bwTQnb+D/CsJ4dWAo0aDe+uPMxEtxyOodw9l7R9QnJUUab3GCehlw2M8mDHdeUu/ufx8PvRQiJphhXg==} - - iconv-lite@0.2.11: - resolution: {integrity: sha512-KhmFWgaQZY83Cbhi+ADInoUQ8Etn6BG5fikM9syeOjQltvR45h7cRKJ/9uvQEuD61I3Uju77yYce0/LhKVClQw==} - engines: {node: '>=0.4.0'} - - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - - icss-utils@5.1.0: - resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} - engines: {node: ^10 || ^12 || >= 14} - peerDependencies: - postcss: ^8.1.0 - - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - image-size@0.5.5: - resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==} - engines: {node: '>=0.10.0'} - hasBin: true - - import-fresh@3.3.0: - resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} - engines: {node: '>=6'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - indent-string@4.0.0: - resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} - engines: {node: '>=8'} - - inflight@1.0.6: - resolution: {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. - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - - ini@4.1.3: - resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - inline-style-parser@0.2.3: - resolution: {integrity: sha512-qlD8YNDqyTKTyuITrDOffsl6Tdhv+UC4hcdAVuQsK4IMQ99nSgd1MIA/Q+jQYoh9r3hVUXhYh7urSRmXPkW04g==} - - internal-slot@1.0.7: - resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} - engines: {node: '>= 0.4'} - - internmap@2.0.3: - resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} - engines: {node: '>=12'} - - intl-messageformat@10.5.11: - resolution: {integrity: sha512-eYq5fkFBVxc7GIFDzpFQkDOZgNayNTQn4Oufe8jw6YY6OHVw70/4pA3FyCsQ0Gb2DnvEJEMmN2tOaXUGByM+kg==} - - invariant@2.2.4: - resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - - invert-kv@1.0.0: - resolution: {integrity: sha512-xgs2NH9AE66ucSq4cNG1nhSFghr5l6tdL15Pk+jl46bmmBapgoaY/AacXyaDznAqmGL99TiLSQgO/XazFSKYeQ==} - engines: {node: '>=0.10.0'} - - is-alphabetical@2.0.1: - resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} - - is-alphanumerical@2.0.1: - resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} - - is-arguments@1.1.1: - resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==} - engines: {node: '>= 0.4'} - - is-array-buffer@3.0.4: - resolution: {integrity: sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==} - engines: {node: '>= 0.4'} - - is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - - is-async-function@2.0.0: - resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==} - engines: {node: '>= 0.4'} - - is-bigint@1.0.4: - resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==} - - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - - is-boolean-object@1.1.2: - resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} - engines: {node: '>= 0.4'} - - is-browser@2.1.0: - resolution: {integrity: sha512-F5rTJxDQ2sW81fcfOR1GnCXT6sVJC104fCyfj+mjpwNEwaPYSn5fte5jiHmBg3DHsIoL/l8Kvw5VN5SsTRcRFQ==} - - is-builtin-module@3.2.1: - resolution: {integrity: sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==} - engines: {node: '>=6'} - - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - - is-core-module@2.13.1: - resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==} - - is-data-view@1.0.1: - resolution: {integrity: sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==} - engines: {node: '>= 0.4'} - - is-date-object@1.0.5: - resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} - engines: {node: '>= 0.4'} - - is-decimal@2.0.1: - resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} - - is-docker@2.2.1: - resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} - engines: {node: '>=8'} - hasBin: true - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-finalizationregistry@1.0.2: - resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==} - - is-finite@1.1.0: - resolution: {integrity: sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==} - engines: {node: '>=0.10.0'} - - is-firefox@1.0.3: - resolution: {integrity: sha512-6Q9ITjvWIm0Xdqv+5U12wgOKEM2KoBw4Y926m0OFkvlCxnbG94HKAsVz8w3fWcfAS5YA2fJORXX1dLrkprCCxA==} - engines: {node: '>=0.10.0'} - - is-fullwidth-code-point@1.0.0: - resolution: {integrity: sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==} - engines: {node: '>=0.10.0'} - - is-fullwidth-code-point@2.0.0: - resolution: {integrity: sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==} - engines: {node: '>=4'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-generator-function@1.0.10: - resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==} - engines: {node: '>= 0.4'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-hexadecimal@2.0.1: - resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} - - is-iexplorer@1.0.0: - resolution: {integrity: sha512-YeLzceuwg3K6O0MLM3UyUUjKAlyULetwryFp1mHy1I5PfArK0AEqlfa+MR4gkJjcbuJXoDJCvXbyqZVf5CR2Sg==} - engines: {node: '>=0.10.0'} - - is-map@2.0.2: - resolution: {integrity: sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==} - - is-mobile@4.0.0: - resolution: {integrity: sha512-mlcHZA84t1qLSuWkt2v0I2l61PYdyQDt4aG1mLIXF5FDMm4+haBCxCPYSr/uwqQNRk1MiTizn0ypEuRAOLRAew==} - - is-negative-zero@2.0.3: - resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} - engines: {node: '>= 0.4'} - - is-number-object@1.0.7: - resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} - engines: {node: '>= 0.4'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-obj@1.0.1: - resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} - engines: {node: '>=0.10.0'} - - is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} - - is-plain-obj@1.1.0: - resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} - engines: {node: '>=0.10.0'} - - is-plain-obj@4.1.0: - resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} - engines: {node: '>=12'} - - is-plain-object@5.0.0: - resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} - engines: {node: '>=0.10.0'} - - is-potential-custom-element-name@1.0.1: - resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - - is-regex@1.1.4: - resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} - engines: {node: '>= 0.4'} - - is-set@2.0.2: - resolution: {integrity: sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==} - - is-shared-array-buffer@1.0.3: - resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} - engines: {node: '>= 0.4'} - - is-stream@3.0.0: - resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - is-string-blank@1.0.1: - resolution: {integrity: sha512-9H+ZBCVs3L9OYqv8nuUAzpcT9OTgMD1yAWrG7ihlnibdkbtB850heAmYWxHuXc4CHy4lKeK69tN+ny1K7gBIrw==} - - is-string@1.0.7: - resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} - engines: {node: '>= 0.4'} - - is-svg-path@1.0.2: - resolution: {integrity: sha512-Lj4vePmqpPR1ZnRctHv8ltSh1OrSxHkhUkd7wi+VQdcdP15/KvQFyk7LhNuM7ZW0EVbJz8kZLVmL9quLrfq4Kg==} - - is-symbol@1.0.4: - resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} - engines: {node: '>= 0.4'} - - is-typed-array@1.1.13: - resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==} - engines: {node: '>= 0.4'} - - is-typedarray@1.0.0: - resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} - - is-utf8@0.2.1: - resolution: {integrity: sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==} - - is-weakmap@2.0.1: - resolution: {integrity: sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==} - - is-weakref@1.0.2: - resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} - - is-weakset@2.0.2: - resolution: {integrity: sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==} - - is-what@3.14.1: - resolution: {integrity: sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==} - - is-wsl@2.2.0: - resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} - engines: {node: '>=8'} - - isarray@0.0.1: - resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} - - isarray@1.0.0: - resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - isexe@3.1.1: - resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} - engines: {node: '>=16'} - - isomorphic-fetch@3.0.0: - resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==} - - isomorphic-ws@5.0.0: - resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} - peerDependencies: - ws: '*' - - isstream@0.1.2: - resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} - - iterator.prototype@1.1.2: - resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} - - jest-worker@27.5.1: - resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} - engines: {node: '>= 10.13.0'} - - jiti@1.21.0: - resolution: {integrity: sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==} - hasBin: true - - jotai@2.9.3: - resolution: {integrity: sha512-IqMWKoXuEzWSShjd9UhalNsRGbdju5G2FrqNLQJT+Ih6p41VNYe2sav5hnwQx4HJr25jq9wRqvGSWGviGG6Gjw==} - engines: {node: '>=12.20.0'} - peerDependencies: - '@types/react': '>=17.0.0' - react: '>=17.0.0' - peerDependenciesMeta: - '@types/react': - optional: true - react: - optional: true - - js-cookie@3.0.5: - resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} - engines: {node: '>=14'} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - js-tokens@8.0.3: - resolution: {integrity: sha512-UfJMcSJc+SEXEl9lH/VLHSZbThQyLpw1vLO1Lb+j4RWDvG3N2f7yj3PVQA3cmkTBNldJ9eFnM+xEXxHIXrYiJw==} - - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} - hasBin: true - - jsbn@0.1.1: - resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} - - jsdoc-type-pratt-parser@4.1.0: - resolution: {integrity: sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==} - engines: {node: '>=12.0.0'} - - jsdom@24.1.1: - resolution: {integrity: sha512-5O1wWV99Jhq4DV7rCLIoZ/UIhyQeDR7wHVyZAHAshbrvZsLs+Xzz7gtwnlJTJDjleiTKh54F4dXrX70vJQTyJQ==} - engines: {node: '>=18'} - peerDependencies: - canvas: ^2.11.2 - peerDependenciesMeta: - canvas: - optional: true - - jsesc@0.5.0: - resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==} - hasBin: true - - jsesc@2.5.2: - resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} - engines: {node: '>=4'} - hasBin: true - - jsesc@3.0.2: - resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} - engines: {node: '>=6'} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - - json-parse-even-better-errors@3.0.1: - resolution: {integrity: sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-schema@0.4.0: - resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - json-stable-stringify@1.1.1: - resolution: {integrity: sha512-SU/971Kt5qVQfJpyDveVhQ/vya+5hvrjClFOcr8c0Fq5aODJjMwutrOfCU+eCnVD5gpx1Q3fEqkyom77zH1iIg==} - engines: {node: '>= 0.4'} - - json-stringify-pretty-compact@2.0.0: - resolution: {integrity: sha512-WRitRfs6BGq4q8gTgOy4ek7iPFXjbra0H3PmDLKm2xnZ+Gh1HUhiKGgCZkSPNULlP7mvfu6FV/mOLhCarspADQ==} - - json-stringify-pretty-compact@3.0.0: - resolution: {integrity: sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==} - - json-stringify-pretty-compact@4.0.0: - resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==} - - json-stringify-safe@5.0.1: - resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - jsondiffpatch@0.6.0: - resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - - jsonify@0.0.1: - resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==} - - jsprim@1.4.2: - resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} - engines: {node: '>=0.6.0'} - - jsx-ast-utils@3.3.5: - resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} - engines: {node: '>=4.0'} - - katex@0.16.11: - resolution: {integrity: sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==} - hasBin: true - - kdbush@3.0.0: - resolution: {integrity: sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==} - - kdbush@4.0.2: - resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==} - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - khroma@2.1.0: - resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} - - kind-of@6.0.3: - resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} - engines: {node: '>=0.10.0'} - - known-css-properties@0.34.0: - resolution: {integrity: sha512-tBECoUqNFbyAY4RrbqsBQqDFpGXAEbdD5QKr8kACx3+rnArmuuR22nKQWKazvp07N9yjTyDZaw/20UIH8tL9DQ==} - - kolorist@1.8.0: - resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} - - langium@3.0.0: - resolution: {integrity: sha512-+Ez9EoiByeoTu/2BXmEaZ06iPNXM6thWJp02KfBO/raSMyCJ4jw7AkWWa+zBCTm0+Tw1Fj9FOxdqSskyN5nAwg==} - engines: {node: '>=16.0.0'} - - language-subtag-registry@0.3.22: - resolution: {integrity: sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==} - - language-tags@1.0.9: - resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} - engines: {node: '>=0.10'} - - layout-base@1.0.2: - resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} - - layout-base@2.0.1: - resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} - - lcid@1.0.0: - resolution: {integrity: sha512-YiGkH6EnGrDGqLMITnGjXtGmNtjoXw9SVUzcaos8RBi7Ps0VBylkq+vOcY9QE5poLasPCR849ucFUkl0UzUyOw==} - engines: {node: '>=0.10.0'} - - less@4.2.0: - resolution: {integrity: sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA==} - engines: {node: '>=6'} - hasBin: true - - levn@0.3.0: - resolution: {integrity: sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==} - engines: {node: '>= 0.8.0'} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - lilconfig@2.1.0: - resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} - engines: {node: '>=10'} - - lilconfig@3.1.2: - resolution: {integrity: sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==} - engines: {node: '>=14'} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - load-json-file@1.1.0: - resolution: {integrity: sha512-cy7ZdNRXdablkXYNI049pthVeXFurRyb9+hA/dZzerZ0pGTx42z+y+ssxBaVV2l70t1muq5IdKhn4UtcoGUY9A==} - engines: {node: '>=0.10.0'} - - loader-runner@4.3.0: - resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} - engines: {node: '>=6.11.5'} - - local-pkg@0.5.0: - resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==} - engines: {node: '>=14'} - - locate-path@3.0.0: - resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} - engines: {node: '>=6'} - - locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lodash-es@4.17.21: - resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} - - lodash.castarray@4.4.0: - resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==} - - lodash.isplainobject@4.0.6: - resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - - lodash.memoize@4.1.2: - resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} - - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - - lodash.truncate@4.4.2: - resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} - - lodash.uniq@4.5.0: - resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} - - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - - longest-streak@3.1.0: - resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} - - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - - loupe@2.3.7: - resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} - - loupe@3.1.2: - resolution: {integrity: sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - lucide-react@0.428.0: - resolution: {integrity: sha512-rGrzslfEcgqwh+TLBC5qJ8wvVIXhLvAIXVFKNHndYyb1utSxxn9rXOC+1CNJLi6yNOooyPqIs6+3YCp6uSiEvg==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc - - lz-string@1.5.0: - resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} - hasBin: true - - magic-string@0.22.5: - resolution: {integrity: sha512-oreip9rJZkzvA8Qzk9HFs8fZGF/u7H/gtrE8EN6RjKJ9kh2HlC+yQ2QezifqTZfGyiuAV0dRv5a+y/8gBb1m9w==} - - magic-string@0.27.0: - resolution: {integrity: sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==} - engines: {node: '>=12'} - - magic-string@0.30.11: - resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} - - make-dir@2.1.0: - resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} - engines: {node: '>=6'} - - map-limit@0.0.1: - resolution: {integrity: sha512-pJpcfLPnIF/Sk3taPW21G/RQsEEirGaFpCW3oXRwH9dnFHPHNGjNyvh++rdmC2fNqEaTw2MhYJraoJWAHx8kEg==} - - map-or-similar@1.5.0: - resolution: {integrity: sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==} - - mapbox-gl@1.13.3: - resolution: {integrity: sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==} - engines: {node: '>=6.4.0'} - - maplibre-gl@4.7.1: - resolution: {integrity: sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==} - engines: {node: '>=16.14.0', npm: '>=8.1.0'} - - marked@13.0.3: - resolution: {integrity: sha512-rqRix3/TWzE9rIoFGIn8JmsVfhiuC8VIQ8IdX5TfzmeBucdY05/0UlzKaw0eVtpcN/OdVFpBk7CjKGo9iHJ/zA==} - engines: {node: '>= 18'} - hasBin: true - - math-log2@1.0.1: - resolution: {integrity: sha512-9W0yGtkaMAkf74XGYVy4Dqw3YUMnTNB2eeiw9aQbUl4A3KmuCEHTt2DgAB07ENzOYAjsYSAYufkAq0Zd+jU7zA==} - engines: {node: '>=0.10.0'} - - mathml-tag-names@2.1.3: - resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} - - mdast-util-from-markdown@2.0.2: - resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} - - mdast-util-mdx-expression@2.0.1: - resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} - - mdast-util-mdx-jsx@3.1.3: - resolution: {integrity: sha512-bfOjvNt+1AcbPLTFMFWY149nJz0OjmewJs3LQQ5pIyVGxP4CdOqNVJL6kTaM5c68p8q82Xv3nCyFfUnuEcH3UQ==} - - mdast-util-mdxjs-esm@2.0.1: - resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} - - mdast-util-phrasing@4.1.0: - resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} - - mdast-util-to-hast@13.2.0: - resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} - - mdast-util-to-markdown@2.1.2: - resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} - - mdast-util-to-string@4.0.0: - resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} - - mdn-data@2.0.28: - resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} - - mdn-data@2.0.30: - resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} - - memoize-one@5.2.1: - resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} - - memoizerific@1.11.3: - resolution: {integrity: sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==} - - memorystream@0.3.1: - resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} - engines: {node: '>= 0.10.0'} - - meow@13.2.0: - resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} - engines: {node: '>=18'} - - merge-source-map@1.0.4: - resolution: {integrity: sha512-PGSmS0kfnTnMJCzJ16BLLCEe6oeYCamKFFdQKshi4BmM6FUwipjVOcBFGxqtQtirtAG4iZvHlqST9CpZKqlRjA==} - - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - mermaid@11.3.0: - resolution: {integrity: sha512-fFmf2gRXLtlGzug4wpIGN+rQdZ30M8IZEB1D3eZkXNqC7puhqeURBcD/9tbwXsqBO+A6Nzzo3MSSepmnw5xSeg==} - - micromark-core-commonmark@2.0.2: - resolution: {integrity: sha512-FKjQKbxd1cibWMM1P9N+H8TwlgGgSkWZMmfuVucLCHaYqeSvJ0hFeHsIa65pA2nYbes0f8LDHPMrd9X7Ujxg9w==} - - micromark-factory-destination@2.0.1: - resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} - - micromark-factory-label@2.0.1: - resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} - - micromark-factory-space@2.0.1: - resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} - - micromark-factory-title@2.0.1: - resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} - - micromark-factory-whitespace@2.0.1: - resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} - - micromark-util-character@2.1.1: - resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} - - micromark-util-chunked@2.0.1: - resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} - - micromark-util-classify-character@2.0.1: - resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} - - micromark-util-combine-extensions@2.0.1: - resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} - - micromark-util-decode-numeric-character-reference@2.0.2: - resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} - - micromark-util-decode-string@2.0.1: - resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} - - micromark-util-encode@2.0.1: - resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} - - micromark-util-html-tag-name@2.0.1: - resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} - - micromark-util-normalize-identifier@2.0.1: - resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} - - micromark-util-resolve-all@2.0.1: - resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} - - micromark-util-sanitize-uri@2.0.1: - resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} - - micromark-util-subtokenize@2.0.3: - resolution: {integrity: sha512-VXJJuNxYWSoYL6AJ6OQECCFGhIU2GGHMw8tahogePBrjkG8aCCas3ibkp7RnVOSTClg2is05/R7maAhF1XyQMg==} - - micromark-util-symbol@2.0.1: - resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} - - micromark-util-types@2.0.1: - resolution: {integrity: sha512-534m2WhVTddrcKVepwmVEVnUAmtrx9bfIjNoQHRqfnvdaHQiFytEhJoTgpWJvDEXCO5gLTQh3wYC1PgOJA4NSQ==} - - micromark@4.0.1: - resolution: {integrity: sha512-eBPdkcoCNvYcxQOAKAlceo5SNdzZWfF+FcSupREAzdAh9rRmE239CEQAiTwIgblwnoM8zzj35sZ5ZwvSEOF6Kw==} - - micromatch@4.0.7: - resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} - engines: {node: '>=8.6'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true - - mimic-fn@4.0.0: - resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} - engines: {node: '>=12'} - - min-indent@1.0.1: - resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} - engines: {node: '>=4'} - - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - minimatch@9.0.4: - resolution: {integrity: sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==} - engines: {node: '>=16 || 14 >=14.17'} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - mitt@3.0.1: - resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} - - mlly@1.7.2: - resolution: {integrity: sha512-tN3dvVHYVz4DhSXinXIk7u9syPYaJvio118uomkovAtWBT+RdbP6Lfh/5Lvo519YMmwBafwlh20IPTXIStscpA==} - - mouse-change@1.4.0: - resolution: {integrity: sha512-vpN0s+zLL2ykyyUDh+fayu9Xkor5v/zRD9jhSqjRS1cJTGS0+oakVZzNm5n19JvvEj0you+MXlYTpNxUDQUjkQ==} - - mouse-event-offset@3.0.2: - resolution: {integrity: sha512-s9sqOs5B1Ykox3Xo8b3Ss2IQju4UwlW6LSR+Q5FXWpprJ5fzMLefIIItr3PH8RwzfGy6gxs/4GAmiNuZScE25w==} - - mouse-event@1.0.5: - resolution: {integrity: sha512-ItUxtL2IkeSKSp9cyaX2JLUuKk2uMoxBg4bbOWVd29+CskYJR9BGsUqtXenNzKbnDshvupjUewDIYVrOB6NmGw==} - - mouse-wheel@1.2.0: - resolution: {integrity: sha512-+OfYBiUOCTWcTECES49neZwL5AoGkXE+lFjIvzwNCnYRlso+EnfvovcBxGoyQ0yQt806eSPjS675K0EwWknXmw==} - - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - - ms@2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - mumath@3.3.4: - resolution: {integrity: sha512-VAFIOG6rsxoc7q/IaY3jdjmrsuX9f15KlRLYTHmixASBZkZEKC1IFqE2BC5CdhXmK6WLM1Re33z//AGmeRI6FA==} - deprecated: Redundant dependency in your project. - - murmurhash-js@1.0.0: - resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==} - - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - - nanoid@3.3.6: - resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - nanoid@3.3.7: - resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - native-promise-only@0.8.1: - resolution: {integrity: sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==} - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - needle@2.9.1: - resolution: {integrity: sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==} - engines: {node: '>= 4.4.x'} - hasBin: true - - needle@3.3.1: - resolution: {integrity: sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==} - engines: {node: '>= 4.4.x'} - hasBin: true - - neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - - next-tick@1.1.0: - resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} - - node-fetch@2.6.7: - resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - - node-releases@2.0.18: - resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} - - normalize-package-data@2.5.0: - resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} - - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - normalize-range@0.1.2: - resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} - engines: {node: '>=0.10.0'} - - normalize-svg-path@0.1.0: - resolution: {integrity: sha512-1/kmYej2iedi5+ROxkRESL/pI02pkg0OBnaR4hJkSIX6+ORzepwbuUXfrdZaPjysTsJInj0Rj5NuX027+dMBvA==} - - normalize-svg-path@1.1.0: - resolution: {integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==} - - npm-normalize-package-bin@3.0.1: - resolution: {integrity: sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - npm-run-all2@6.2.2: - resolution: {integrity: sha512-Q+alQAGIW7ZhKcxLt8GcSi3h3ryheD6xnmXahkMRVM5LYmajcUrSITm8h+OPC9RYWMV2GR0Q1ntTUCfxaNoOJw==} - engines: {node: ^14.18.0 || ^16.13.0 || >=18.0.0, npm: '>= 8'} - hasBin: true - - npm-run-path@5.1.0: - resolution: {integrity: sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - nth-check@2.1.1: - resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - - number-is-integer@1.0.1: - resolution: {integrity: sha512-Dq3iuiFBkrbmuQjGFFF3zckXNCQoSD37/SdSbgcBailUx6knDvDwb5CympBgcoWHy36sfS12u74MHYkXyHq6bg==} - engines: {node: '>=0.10.0'} - - number-is-nan@1.0.1: - resolution: {integrity: sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==} - engines: {node: '>=0.10.0'} - - nwsapi@2.2.12: - resolution: {integrity: sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==} - - oauth-sign@0.9.0: - resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - object-hash@3.0.0: - resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} - engines: {node: '>= 6'} - - object-inspect@1.13.1: - resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} - - object-inspect@1.4.1: - resolution: {integrity: sha512-wqdhLpfCUbEsoEwl3FXwGyv8ief1k/1aUdIPCqVnupM6e8l63BEJdiF/0swtn04/8p05tG/T0FrpTlfwvljOdw==} - - object-is@1.1.5: - resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==} - engines: {node: '>= 0.4'} - - object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} - - object.assign@4.1.5: - resolution: {integrity: sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==} - engines: {node: '>= 0.4'} - - object.entries@1.1.8: - resolution: {integrity: sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==} - engines: {node: '>= 0.4'} - - object.fromentries@2.0.8: - resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} - engines: {node: '>= 0.4'} - - object.hasown@1.1.4: - resolution: {integrity: sha512-FZ9LZt9/RHzGySlBARE3VF+gE26TxR38SdmqOqliuTnl9wrKulaQs+4dee1V+Io8VfxqzAfHu6YuRgUy8OHoTg==} - engines: {node: '>= 0.4'} - - object.values@1.2.0: - resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} - engines: {node: '>= 0.4'} - - once@1.3.3: - resolution: {integrity: sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w==} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - onetime@6.0.0: - resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} - engines: {node: '>=12'} - - open@8.4.2: - resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} - engines: {node: '>=12'} - - openapi-fetch@0.9.7: - resolution: {integrity: sha512-NMp/GEmWSGO0b2d731IdGXcMP2PF85Rk1q+oy2Mx/DYMdP3pgTZTRamKxgZpkHhM4iOVsyD1iP5HKL9Fr6CH2Q==} - - openapi-typescript-helpers@0.0.8: - resolution: {integrity: sha512-1eNjQtbfNi5Z/kFhagDIaIRj6qqDzhjNJKz8cmMW0CVdGwT6e1GLbAfgI0d28VTJa1A8jz82jm/4dG8qNoNS8g==} - - optimist@0.3.7: - resolution: {integrity: sha512-TCx0dXQzVtSCg2OgY/bO9hjM9cV4XYx09TVK+s3+FhkjT6LovsLe+pPMzpWf+6yXK/hUizs2gUoTw3jHM0VaTQ==} - - optionator@0.8.3: - resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==} - engines: {node: '>= 0.8.0'} - - optionator@0.9.3: - resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} - engines: {node: '>= 0.8.0'} - - os-locale@1.4.0: - resolution: {integrity: sha512-PRT7ZORmwu2MEFt4/fv3Q+mEfN4zetKxufQrkShY2oGvUms9r8otu5HfdyIFHkYXjO7laNsoVGmM2MANfuTA8g==} - engines: {node: '>=0.10.0'} - - os-shim@0.1.3: - resolution: {integrity: sha512-jd0cvB8qQ5uVt0lvCIexBaROw1KyKm5sbulg2fWOHjETisuCzWyt+eTZKEMs8v6HwzoGs8xik26jg7eCM6pS+A==} - engines: {node: '>= 0.4.0'} - - p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-limit@5.0.0: - resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} - engines: {node: '>=18'} - - p-locate@3.0.0: - resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} - engines: {node: '>=6'} - - p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} - - package-manager-detector@0.2.2: - resolution: {integrity: sha512-VgXbyrSNsml4eHWIvxxG/nTL4wgybMTXCV2Un/+yEc3aDKKU6nQBZjbeP3Pl3qm9Qg92X/1ng4ffvCeD/zwHgg==} - - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - - parenthesis@3.1.8: - resolution: {integrity: sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==} - - parse-entities@4.0.1: - resolution: {integrity: sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w==} - - parse-json@2.2.0: - resolution: {integrity: sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==} - engines: {node: '>=0.10.0'} - - parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} - - parse-node-version@1.0.1: - resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} - engines: {node: '>= 0.10'} - - parse-rect@1.2.0: - resolution: {integrity: sha512-4QZ6KYbnE6RTwg9E0HpLchUM9EZt6DnDxajFZZDSV4p/12ZJEvPO702DZpGvRYEPo00yKDys7jASi+/w7aO8LA==} - - parse-svg-path@0.1.2: - resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==} - - parse-unit@1.0.1: - resolution: {integrity: sha512-hrqldJHokR3Qj88EIlV/kAyAi/G5R2+R56TBANxNMy0uPlYcttx0jnMW6Yx5KsKPSbC3KddM/7qQm3+0wEXKxg==} - - parse5@7.1.2: - resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} - - partysocket@1.0.2: - resolution: {integrity: sha512-rAFOUKImaq+VBk2B+2RTBsWEvlnarEP53nchoUHzpVs8V6fG2/estihOTslTQUWHVuHEKDL5k8htG8K3TngyFA==} - - path-data-parser@0.1.0: - resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} - - path-exists@2.1.0: - resolution: {integrity: sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ==} - engines: {node: '>=0.10.0'} - - path-exists@3.0.0: - resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} - engines: {node: '>=4'} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-key@4.0.0: - resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} - engines: {node: '>=12'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - path-to-regexp@8.1.0: - resolution: {integrity: sha512-Bqn3vc8CMHty6zuD+tG23s6v2kwxslHEhTj4eYaVKGIEB+YX/2wd0/rgXLFD9G9id9KCtbVy/3ZgmvZjpa0UdQ==} - engines: {node: '>=16'} - - path-type@1.1.0: - resolution: {integrity: sha512-S4eENJz1pkiQn9Znv33Q+deTOKmbl+jj1Fl+qiP/vYezj+S8x+J3Uo0ISrx/QoEvIlOaDWJhPaRd1flJ9HXZqg==} - engines: {node: '>=0.10.0'} - - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - - pathe@1.1.2: - resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - - pathval@1.1.1: - resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} - - pathval@2.0.0: - resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} - engines: {node: '>= 14.16'} - - pbf@3.3.0: - resolution: {integrity: sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==} - hasBin: true - - performance-now@2.1.0: - resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} - - pick-by-alias@1.2.0: - resolution: {integrity: sha512-ESj2+eBxhGrcA1azgHs7lARG5+5iLakc/6nlfbpjcLl00HuuUOIuORhYXN4D1HfvMSKuVtFQjAlnwi1JHEeDIw==} - - picocolors@0.2.1: - resolution: {integrity: sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - - pidtree@0.6.0: - resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} - engines: {node: '>=0.10'} - hasBin: true - - pify@2.3.0: - resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} - engines: {node: '>=0.10.0'} - - pify@4.0.1: - resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} - engines: {node: '>=6'} - - pinkie-promise@2.0.1: - resolution: {integrity: sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==} - engines: {node: '>=0.10.0'} - - pinkie@2.0.4: - resolution: {integrity: sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==} - engines: {node: '>=0.10.0'} - - pirates@4.0.5: - resolution: {integrity: sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==} - engines: {node: '>= 6'} - - pkg-types@1.2.1: - resolution: {integrity: sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==} - - playwright-core@1.46.1: - resolution: {integrity: sha512-h9LqIQaAv+CYvWzsZ+h3RsrqCStkBHlgo6/TJlFst3cOTlLghBQlJwPOZKQJTKNaD3QIB7aAVQ+gfWbN3NXB7A==} - engines: {node: '>=18'} - hasBin: true - - playwright@1.46.1: - resolution: {integrity: sha512-oPcr1yqoXLCkgKtD5eNUPLiN40rYEM39odNpIb6VE6S7/15gJmA1NzVv6zJYusV0e7tzvkU/utBFNa/Kpxmwng==} - engines: {node: '>=18'} - hasBin: true - - plotly.js@2.35.2: - resolution: {integrity: sha512-s0knlWzRvLQXxzf3JQ6qbm8FpwKuMjkr+6r04f8/yCEByAQ+I0jkUzY/hSGRGb+u7iljTh9hgpEiiJP90vjyeQ==} - - pluralize@8.0.0: - resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} - engines: {node: '>=4'} - - point-in-polygon@1.1.0: - resolution: {integrity: sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==} - - points-on-curve@0.2.0: - resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} - - points-on-path@0.2.1: - resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} - - polished@4.2.2: - resolution: {integrity: sha512-Sz2Lkdxz6F2Pgnpi9U5Ng/WdWAUZxmHrNPoVlm3aAemxoy2Qy7LGjQg4uf8qKelDAUW94F4np3iH2YPf2qefcQ==} - engines: {node: '>=10'} - - polybooljs@1.2.2: - resolution: {integrity: sha512-ziHW/02J0XuNuUtmidBc6GXE8YohYydp3DWPWXYsd7O721TjcmN+k6ezjdwkDqep+gnWnFY+yqZHvzElra2oCg==} - - possible-typed-array-names@1.0.0: - resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} - engines: {node: '>= 0.4'} - - postcss-calc@10.0.2: - resolution: {integrity: sha512-DT/Wwm6fCKgpYVI7ZEWuPJ4az8hiEHtCUeYjZXqU7Ou4QqYh1Df2yCQ7Ca6N7xqKPFkxN3fhf+u9KSoOCJNAjg==} - engines: {node: ^18.12 || ^20.9 || >=22.0} - peerDependencies: - postcss: ^8.4.38 - - postcss-colormin@7.0.2: - resolution: {integrity: sha512-YntRXNngcvEvDbEjTdRWGU606eZvB5prmHG4BF0yLmVpamXbpsRJzevyy6MZVyuecgzI2AWAlvFi8DAeCqwpvA==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-convert-values@7.0.3: - resolution: {integrity: sha512-yJhocjCs2SQer0uZ9lXTMOwDowbxvhwFVrZeS6NPEij/XXthl73ggUmfwVvJM+Vaj5gtCKJV1jiUu4IhAUkX/Q==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-discard-comments@7.0.2: - resolution: {integrity: sha512-/Hje9Ls1IYcB9duELO/AyDUJI6aQVY3h5Rj1ziXgaLYCTi1iVBLnjg/TS0D6NszR/kDG6I86OwLmAYe+bvJjiQ==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-discard-duplicates@7.0.1: - resolution: {integrity: sha512-oZA+v8Jkpu1ct/xbbrntHRsfLGuzoP+cpt0nJe5ED2FQF8n8bJtn7Bo28jSmBYwqgqnqkuSXJfSUEE7if4nClQ==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-discard-empty@7.0.0: - resolution: {integrity: sha512-e+QzoReTZ8IAwhnSdp/++7gBZ/F+nBq9y6PomfwORfP7q9nBpK5AMP64kOt0bA+lShBFbBDcgpJ3X4etHg4lzA==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-discard-overridden@7.0.0: - resolution: {integrity: sha512-GmNAzx88u3k2+sBTZrJSDauR0ccpE24omTQCVmaTTZFz1du6AasspjaUPMJ2ud4RslZpoFKyf+6MSPETLojc6w==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-import@15.1.0: - resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} - engines: {node: '>=14.0.0'} - peerDependencies: - postcss: ^8.0.0 - - postcss-js@4.0.1: - resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} - engines: {node: ^12 || ^14 || >= 16} - peerDependencies: - postcss: ^8.4.21 - - postcss-load-config@4.0.1: - resolution: {integrity: sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==} - engines: {node: '>= 14'} - peerDependencies: - postcss: '>=8.0.9' - ts-node: '>=9.0.0' - peerDependenciesMeta: - postcss: - optional: true - ts-node: - optional: true - - postcss-merge-longhand@7.0.3: - resolution: {integrity: sha512-8waYomFxshdv6M9Em3QRM9MettRLDRcH2JQi2l0Z1KlYD/vhal3gbkeSES0NuACXOlZBB0V/B0AseHZaklzWOA==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-merge-rules@7.0.3: - resolution: {integrity: sha512-2eSas2p3voPxNfdI5sQrvIkMaeUHpVc3EezgVs18hz/wRTQAC9U99tp9j3W5Jx9/L3qHkEDvizEx/LdnmumIvQ==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-minify-font-values@7.0.0: - resolution: {integrity: sha512-2ckkZtgT0zG8SMc5aoNwtm5234eUx1GGFJKf2b1bSp8UflqaeFzR50lid4PfqVI9NtGqJ2J4Y7fwvnP/u1cQog==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-minify-gradients@7.0.0: - resolution: {integrity: sha512-pdUIIdj/C93ryCHew0UgBnL2DtUS3hfFa5XtERrs4x+hmpMYGhbzo6l/Ir5de41O0GaKVpK1ZbDNXSY6GkXvtg==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-minify-params@7.0.2: - resolution: {integrity: sha512-nyqVLu4MFl9df32zTsdcLqCFfE/z2+f8GE1KHPxWOAmegSo6lpV2GNy5XQvrzwbLmiU7d+fYay4cwto1oNdAaQ==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-minify-selectors@7.0.3: - resolution: {integrity: sha512-SxTgUQSgBk6wEqzQZKEv1xQYIp9UBju6no9q+npohzSdhuSICQdkqmD1UMKkZWItS3olJSJMDDEY9WOJ5oGJew==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-modules-extract-imports@3.1.0: - resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==} - engines: {node: ^10 || ^12 || >= 14} - peerDependencies: - postcss: ^8.1.0 - - postcss-modules-local-by-default@4.1.0: - resolution: {integrity: sha512-rm0bdSv4jC3BDma3s9H19ZddW0aHX6EoqwDYU2IfZhRN+53QrufTRo2IdkAbRqLx4R2IYbZnbjKKxg4VN5oU9Q==} - engines: {node: ^10 || ^12 || >= 14} - peerDependencies: - postcss: ^8.1.0 - - postcss-modules-scope@3.2.1: - resolution: {integrity: sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==} - engines: {node: ^10 || ^12 || >= 14} - peerDependencies: - postcss: ^8.1.0 - - postcss-modules-values@4.0.0: - resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} - engines: {node: ^10 || ^12 || >= 14} - peerDependencies: - postcss: ^8.1.0 - - postcss-nested@6.0.1: - resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.2.14 - - postcss-normalize-charset@7.0.0: - resolution: {integrity: sha512-ABisNUXMeZeDNzCQxPxBCkXexvBrUHV+p7/BXOY+ulxkcjUZO0cp8ekGBwvIh2LbCwnWbyMPNJVtBSdyhM2zYQ==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-normalize-display-values@7.0.0: - resolution: {integrity: sha512-lnFZzNPeDf5uGMPYgGOw7v0BfB45+irSRz9gHQStdkkhiM0gTfvWkWB5BMxpn0OqgOQuZG/mRlZyJxp0EImr2Q==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-normalize-positions@7.0.0: - resolution: {integrity: sha512-I0yt8wX529UKIGs2y/9Ybs2CelSvItfmvg/DBIjTnoUSrPxSV7Z0yZ8ShSVtKNaV/wAY+m7bgtyVQLhB00A1NQ==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-normalize-repeat-style@7.0.0: - resolution: {integrity: sha512-o3uSGYH+2q30ieM3ppu9GTjSXIzOrRdCUn8UOMGNw7Af61bmurHTWI87hRybrP6xDHvOe5WlAj3XzN6vEO8jLw==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-normalize-string@7.0.0: - resolution: {integrity: sha512-w/qzL212DFVOpMy3UGyxrND+Kb0fvCiBBujiaONIihq7VvtC7bswjWgKQU/w4VcRyDD8gpfqUiBQ4DUOwEJ6Qg==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-normalize-timing-functions@7.0.0: - resolution: {integrity: sha512-tNgw3YV0LYoRwg43N3lTe3AEWZ66W7Dh7lVEpJbHoKOuHc1sLrzMLMFjP8SNULHaykzsonUEDbKedv8C+7ej6g==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-normalize-unicode@7.0.2: - resolution: {integrity: sha512-ztisabK5C/+ZWBdYC+Y9JCkp3M9qBv/XFvDtSw0d/XwfT3UaKeW/YTm/MD/QrPNxuecia46vkfEhewjwcYFjkg==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-normalize-url@7.0.0: - resolution: {integrity: sha512-+d7+PpE+jyPX1hDQZYG+NaFD+Nd2ris6r8fPTBAjE8z/U41n/bib3vze8x7rKs5H1uEw5ppe9IojewouHk0klQ==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-normalize-whitespace@7.0.0: - resolution: {integrity: sha512-37/toN4wwZErqohedXYqWgvcHUGlT8O/m2jVkAfAe9Bd4MzRqlBmXrJRePH0e9Wgnz2X7KymTgTOaaFizQe3AQ==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-ordered-values@7.0.1: - resolution: {integrity: sha512-irWScWRL6nRzYmBOXReIKch75RRhNS86UPUAxXdmW/l0FcAsg0lvAXQCby/1lymxn/o0gVa6Rv/0f03eJOwHxw==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-plugin-namespace@0.0.3: - resolution: {integrity: sha512-tDPJ7pF2ekL5OvdQXJgRIiAwV7148PEVQfSymIahO0DzyRruDCpgeJXrQXHdDVnNm/Qkip5FxNJUkUvUadrEew==} - - postcss-reduce-initial@7.0.2: - resolution: {integrity: sha512-pOnu9zqQww7dEKf62Nuju6JgsW2V0KRNBHxeKohU+JkHd/GAH5uvoObqFLqkeB2n20mr6yrlWDvo5UBU5GnkfA==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-reduce-transforms@7.0.0: - resolution: {integrity: sha512-pnt1HKKZ07/idH8cpATX/ujMbtOGhUfE+m8gbqwJE05aTaNw8gbo34a2e3if0xc0dlu75sUOiqvwCGY3fzOHew==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-resolve-nested-selector@0.1.6: - resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==} - - postcss-safe-parser@7.0.0: - resolution: {integrity: sha512-ovehqRNVCpuFzbXoTb4qLtyzK3xn3t/CUBxOs8LsnQjQrShaB4lKiHoVqY8ANaC0hBMHq5QVWk77rwGklFUDrg==} - engines: {node: '>=18.0'} - peerDependencies: - postcss: ^8.4.31 - - postcss-selector-parser@6.0.10: - resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} - engines: {node: '>=4'} - - postcss-selector-parser@6.1.2: - resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} - engines: {node: '>=4'} - - postcss-selector-parser@7.0.0: - resolution: {integrity: sha512-9RbEr1Y7FFfptd/1eEdntyjMwLeghW1bHX9GWjXo19vx4ytPQhANltvVxDggzJl7mnWM+dX28kb6cyS/4iQjlQ==} - engines: {node: '>=4'} - - postcss-svgo@7.0.1: - resolution: {integrity: sha512-0WBUlSL4lhD9rA5k1e5D8EN5wCEyZD6HJk0jIvRxl+FDVOMlJ7DePHYWGGVc5QRqrJ3/06FTXM0bxjmJpmTPSA==} - engines: {node: ^18.12.0 || ^20.9.0 || >= 18} - peerDependencies: - postcss: ^8.4.31 - - postcss-unique-selectors@7.0.2: - resolution: {integrity: sha512-CjSam+7Vf8cflJQsHrMS0P2hmy9u0+n/P001kb5eAszLmhjMqrt/i5AqQuNFihhViwDvEAezqTmXqaYXL2ugMw==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - postcss-value-parser@4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - - postcss@7.0.39: - resolution: {integrity: sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==} - engines: {node: '>=6.0.0'} - - postcss@8.4.49: - resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} - engines: {node: ^10 || ^12 || >=14} - - potpack@1.0.2: - resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} - - potpack@2.0.0: - resolution: {integrity: sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==} - - prelude-ls@1.1.2: - resolution: {integrity: sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==} - engines: {node: '>= 0.8.0'} - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - pretty-format@24.9.0: - resolution: {integrity: sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA==} - engines: {node: '>= 6'} - - pretty-format@27.5.1: - resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} - engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - - pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - probe-image-size@7.2.3: - resolution: {integrity: sha512-HubhG4Rb2UH8YtV4ba0Vp5bQ7L78RTONYu/ujmCu5nBI8wGv24s4E9xSKBi0N1MowRpxk76pFCpJtW0KPzOK0w==} - - process-nextick-args@2.0.1: - resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - - process@0.11.10: - resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} - engines: {node: '>= 0.6.0'} - - promise@7.3.1: - resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} - - prop-types@15.8.1: - resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - - property-information@6.5.0: - resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} - - protocol-buffers-schema@3.6.0: - resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==} - - prr@1.0.1: - resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} - - psl@1.9.0: - resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - pyodide@0.26.2: - resolution: {integrity: sha512-8VCRdFX83gBsWs6XP2rhG8HMaB+JaVyyav4q/EMzoV8fXH8HN6T5IISC92SNma6i1DRA3SVXA61S1rJcB8efgA==} - engines: {node: '>=18.0.0'} - - qs@6.11.2: - resolution: {integrity: sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==} - engines: {node: '>=0.6'} - - qs@6.5.3: - resolution: {integrity: sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==} - engines: {node: '>=0.6'} - - querystringify@2.2.0: - resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - quickselect@2.0.0: - resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==} - - quickselect@3.0.0: - resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} - - quote-stream@1.0.2: - resolution: {integrity: sha512-kKr2uQ2AokadPjvTyKJQad9xELbZwYzWlNfI3Uz2j/ib5u6H9lDP7fUUR//rMycd0gv4Z5P1qXMfXR8YpIxrjQ==} - hasBin: true - - raf@3.4.1: - resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} - - randombytes@2.1.0: - resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} - - react-arborist@3.4.0: - resolution: {integrity: sha512-QI46oRGXJr0oaQfqqVobIiIoqPp5Y5gM69D2A2P7uHVif+X75XWnScR5drC7YDKgJ4CXVaDeFwnYKOWRRfncMg==} - peerDependencies: - react: '>= 16.14' - react-dom: '>= 16.14' - - react-aria-components@1.3.2: - resolution: {integrity: sha512-jAHR18ubLi4R6mUgSVpPfPGNNfwrveJ7IZ9ns8prUkXpY/A8IkR5x8y1SbXJKq9uwlGC2cEiWjG6OMAsmAAXdQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - react-aria@3.34.2: - resolution: {integrity: sha512-V5xvIw/nW6ED8mEdNIao7+z8gKMSff0HAiU7mshh1ZDI0LGyOM6JR/qQmux0mUOxbBARtNiBbBV6pwmQGkfQDw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - react-codemirror-merge@4.23.5: - resolution: {integrity: sha512-+FxEpnF8V9nycGRVlvOhOSIbhL7TZUJLmpuKsJdMpKXUCAwVEo2Uc+RaYox12Y//0upfwWUqmCbjJ1IwOLLFQg==} - peerDependencies: - '@babel/runtime': '>=7.11.0' - '@codemirror/state': '>=6.0.0' - '@codemirror/theme-one-dark': '>=6.0.0' - '@codemirror/view': '>=6.0.0' - codemirror: '>=6.0.0' - react: '>=16.8.0' - react-dom: '>=16.8.0' - - react-compiler-runtime@19.0.0-beta-8a03594-20241020: - resolution: {integrity: sha512-YWl8SjxsWGU1dpxHvWS0vxTkpeLXTZ/Y7IVzwZGj6yAfXOEie1MduuAR0TFiGeV0RxFLp5jKUIWl+ZglN4dMQw==} - peerDependencies: - react: ^18.2.0 || ^19.0.0 - - react-dnd-html5-backend@14.1.0: - resolution: {integrity: sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==} - - react-dnd@14.0.5: - resolution: {integrity: sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==} - peerDependencies: - '@types/hoist-non-react-statics': '>= 3.3.1' - '@types/node': '>= 12' - '@types/react': '>= 16' - react: '>= 16.14' - peerDependenciesMeta: - '@types/hoist-non-react-statics': - optional: true - '@types/node': - optional: true - '@types/react': - optional: true - - react-docgen-typescript@2.2.2: - resolution: {integrity: sha512-tvg2ZtOpOi6QDwsb3GZhOjDkkX0h8Z2gipvTg6OVMUyoYoURhEiRNePT8NZItTVCDh39JJHnLdfCOkzoLbFnTg==} - peerDependencies: - typescript: '>= 4.3.x' - - react-docgen@7.0.1: - resolution: {integrity: sha512-rCz0HBIT0LWbIM+///LfRrJoTKftIzzwsYDf0ns5KwaEjejMHQRtphcns+IXFHDNY9pnz6G8l/JbbI6pD4EAIA==} - engines: {node: '>=16.14.0'} - - react-dom@18.3.1: - resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} - peerDependencies: - react: ^18.3.1 - - react-draggable@4.4.5: - resolution: {integrity: sha512-OMHzJdyJbYTZo4uQE393fHcqqPYsEtkjfMgvCHr6rejT+Ezn4OZbNyGH50vv+SunC1RMvwOTSWkEODQLzw1M9g==} - peerDependencies: - react: '>= 16.3.0' - react-dom: '>= 16.3.0' - - react-dropzone@14.2.3: - resolution: {integrity: sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==} - engines: {node: '>= 10.13'} - peerDependencies: - react: '>= 16.8 || 18.0.0' - - react-error-boundary@3.1.4: - resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==} - engines: {node: '>=10', npm: '>=6'} - peerDependencies: - react: '>=16.13.1' - - react-error-boundary@4.0.13: - resolution: {integrity: sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==} - peerDependencies: - react: '>=16.13.1' - - react-grid-layout@1.4.4: - resolution: {integrity: sha512-7+Lg8E8O8HfOH5FrY80GCIR1SHTn2QnAYKh27/5spoz+OHhMmEhU/14gIkRzJOtympDPaXcVRX/nT1FjmeOUmQ==} - peerDependencies: - react: '>= 16.3.0' - react-dom: '>= 16.3.0' - - react-hook-form@7.52.2: - resolution: {integrity: sha512-pqfPEbERnxxiNMPd0bzmt1tuaPcVccywFDpyk2uV5xCIBphHV5T8SVnX9/o3kplPE1zzKt77+YIoq+EMwJp56A==} - engines: {node: '>=18.0.0'} - peerDependencies: - react: ^16.8.0 || ^17 || ^18 || ^19 - - react-is@16.13.1: - resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - - react-is@17.0.2: - resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - - react-markdown@9.0.1: - resolution: {integrity: sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==} - peerDependencies: - '@types/react': '>=18' - react: '>=18' - - react-plotly.js@2.6.0: - resolution: {integrity: sha512-g93xcyhAVCSt9kV1svqG1clAEdL6k3U+jjuSzfTV7owaSU9Go6Ph8bl25J+jKfKvIGAEYpe4qj++WHJuc9IaeA==} - peerDependencies: - plotly.js: '>1.34.0' - react: '>0.13.0' - - react-property@2.0.2: - resolution: {integrity: sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==} - - react-refresh@0.14.2: - resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} - engines: {node: '>=0.10.0'} - - react-remove-scroll-bar@2.3.4: - resolution: {integrity: sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - react-remove-scroll@2.5.5: - resolution: {integrity: sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - react-resizable-panels@2.0.19: - resolution: {integrity: sha512-v3E41kfKSuCPIvJVb4nL4mIZjjKIn/gh6YqZF/gDfQDolv/8XnhJBek4EiV2gOr3hhc5A3kOGOayk3DhanpaQw==} - peerDependencies: - react: ^16.14.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 - - react-resizable@3.0.5: - resolution: {integrity: sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==} - peerDependencies: - react: '>= 16.3' - - react-shallow-renderer@16.15.0: - resolution: {integrity: sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==} - peerDependencies: - react: ^16.0.0 || ^17.0.0 || ^18.0.0 - - react-stately@3.32.2: - resolution: {integrity: sha512-pDSrbCIJtir4HeSa//PTqLSR7Tl7pFC9usmkkBObNKktObQq3Vdgkf46cxeTD1ov7J7GDdR3meIyjXGnZoEzUg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - react-style-singleton@2.2.1: - resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - react-test-renderer@18.3.1: - resolution: {integrity: sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA==} - peerDependencies: - react: ^18.3.1 - - react-transition-group@4.4.5: - resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} - peerDependencies: - react: '>=16.6.0' - react-dom: '>=16.6.0' - - react-use-event-hook@0.9.6: - resolution: {integrity: sha512-wNchkFrrlz64QQOJOf5+dFjy+27bJEwtS1SgliRXVsU60wo3pSntNRlwDws2qPHwrAP/wlm5IyQHBlPCya0JIQ==} - peerDependencies: - react: '>=16.8.0' - - react-vega@7.6.0: - resolution: {integrity: sha512-2oMML4wH9qWLnZPRxJm06ozwrVN/K+nkjqdI5/ofWWsrBnnH4iB9rRKrsV8px0nlWgZrwfdCH4g5RUiyyJHWSA==} - peerDependencies: - react: ^16 || ^17 || ^18 - vega: '*' - vega-lite: '*' - - react-virtuoso@4.10.1: - resolution: {integrity: sha512-vDBt9AarmCjPNshw3VxPXW355ZQKSO0p9vrAJ0pi04TB6aXk+qHWTu8NuaQ3ppcd/Ub1r5ryRA4fJ2QGuH0H0g==} - engines: {node: '>=10'} - peerDependencies: - react: '>=16 || >=17 || >= 18' - react-dom: '>=16 || >=17 || >= 18' - - react-window@1.8.10: - resolution: {integrity: sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==} - engines: {node: '>8.0.0'} - peerDependencies: - react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 - react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 - - react@18.3.1: - resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} - engines: {node: '>=0.10.0'} - - reactflow@11.11.4: - resolution: {integrity: sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==} - peerDependencies: - react: '>=17' - react-dom: '>=17' - - read-cache@1.0.0: - resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} - - read-package-json-fast@3.0.2: - resolution: {integrity: sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - read-pkg-up@1.0.1: - resolution: {integrity: sha512-WD9MTlNtI55IwYUS27iHh9tK3YoIVhxis8yKhLpTqWtml739uXc9NWTpxoHkfZf3+DkCCsXox94/VWZniuZm6A==} - engines: {node: '>=0.10.0'} - - read-pkg-up@7.0.1: - resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} - engines: {node: '>=8'} - - read-pkg@1.1.0: - resolution: {integrity: sha512-7BGwRHqt4s/uVbuyoeejRn4YmFnYZiFl4AuaeXHlgZf3sONF0SOGlxs2Pw8g6hCKupo08RafIO5YXFNOKTfwsQ==} - engines: {node: '>=0.10.0'} - - read-pkg@5.2.0: - resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==} - engines: {node: '>=8'} - - readable-stream@1.0.34: - resolution: {integrity: sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==} - - readable-stream@2.3.8: - resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - - recast@0.23.6: - resolution: {integrity: sha512-9FHoNjX1yjuesMwuthAmPKabxYQdOgihFYmT5ebXfYGBcnqXZf3WOVz+5foEZ8Y83P4ZY6yQD5GMmtV+pgCCAQ==} - engines: {node: '>= 4'} - - redent@3.0.0: - resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} - engines: {node: '>=8'} - - redux@4.2.1: - resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} - - redux@5.0.1: - resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} - - reflect.getprototypeof@1.0.4: - resolution: {integrity: sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==} - engines: {node: '>= 0.4'} - - regenerator-runtime@0.14.1: - resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} - - regexp-tree@0.1.27: - resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} - hasBin: true - - regexp.prototype.flags@1.5.2: - resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==} - engines: {node: '>= 0.4'} - - regjsparser@0.10.0: - resolution: {integrity: sha512-qx+xQGZVsy55CH0a1hiVwHmqjLryfh7wQyF5HO07XJ9f7dQMY/gPQHhlyDkIzJKC+x2fUCpCcUODUUUFrm7SHA==} - hasBin: true - - regl-error2d@2.0.12: - resolution: {integrity: sha512-r7BUprZoPO9AbyqM5qlJesrSRkl+hZnVKWKsVp7YhOl/3RIpi4UDGASGJY0puQ96u5fBYw/OlqV24IGcgJ0McA==} - - regl-line2d@3.1.3: - resolution: {integrity: sha512-fkgzW+tTn4QUQLpFKsUIE0sgWdCmXAM3ctXcCgoGBZTSX5FE2A0M7aynz7nrZT5baaftLrk9te54B+MEq4QcSA==} - - regl-scatter2d@3.3.1: - resolution: {integrity: sha512-seOmMIVwaCwemSYz/y4WE0dbSO9svNFSqtTh5RE57I7PjGo3tcUYKtH0MTSoshcAsreoqN8HoCtnn8wfHXXfKQ==} - - regl-splom@1.0.14: - resolution: {integrity: sha512-OiLqjmPRYbd7kDlHC6/zDf6L8lxgDC65BhC8JirhP4ykrK4x22ZyS+BnY8EUinXKDeMgmpRwCvUmk7BK4Nweuw==} - - regl@2.1.1: - resolution: {integrity: sha512-+IOGrxl3FZ8ZM9ixCWQZzFRiRn7Rzn9bu3iFHwg/yz4tlOUQgbO4PHLgG+1ZT60zcIV8tief6Qrmyl8qcoJP0g==} - - remark-parse@11.0.0: - resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} - - remark-rehype@11.1.1: - resolution: {integrity: sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ==} - - request@2.88.2: - resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} - engines: {node: '>= 6'} - deprecated: request has been deprecated, see https://github.com/request/request/issues/3142 - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - require-main-filename@1.0.1: - resolution: {integrity: sha512-IqSUtOVP4ksd1C/ej5zeEh/BIP2ajqpn8c5x+q99gvcIG/Qf0cud5raVnE/Dwd0ua9TXYDoDc0RE5hBSdz22Ug==} - - require-main-filename@2.0.0: - resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} - - requires-port@1.0.0: - resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} - - resize-observer-polyfill@1.5.1: - resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} - - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - - resolve-protobuf-schema@2.1.0: - resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} - - resolve@0.6.3: - resolution: {integrity: sha512-UHBY3viPlJKf85YijDUcikKX6tmF4SokIDp518ZDVT92JNDcG5uKIthaT/owt3Sar0lwtOafsQuwrg22/v2Dwg==} - - resolve@1.22.8: - resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} - hasBin: true - - resolve@2.0.0-next.5: - resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} - hasBin: true - - reusify@1.0.4: - resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - right-now@1.0.0: - resolution: {integrity: sha512-DA8+YS+sMIVpbsuKgy+Z67L9Lxb1p05mNxRpDPNksPDEFir4vmBlUtuN9jkTGn9YMMdlBuK7XQgFiz6ws+yhSg==} - - rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true - - robust-predicates@3.0.2: - resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} - - rollup@4.21.3: - resolution: {integrity: sha512-7sqRtBNnEbcBtMeRVc6VRsJMmpI+JU1z9VTvW8D4gXIYQFz0aLcsE6rRkyghZkLfEgUZgVvOG7A5CVz/VW5GIA==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - roughjs@4.6.6: - resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} - - rpc-anywhere@1.7.0: - resolution: {integrity: sha512-/MgRTK/5iOh7EmYz5uZAmHX0DNg9PdbyjVgDtLX4WwoWHmpI8uh9We877U6KizxPDlmeDslq/2MnS7kZXWR8Cw==} - - rrweb-cssom@0.6.0: - resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==} - - rrweb-cssom@0.7.1: - resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - rw@1.3.3: - resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} - - safe-array-concat@1.1.2: - resolution: {integrity: sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==} - engines: {node: '>=0.4'} - - safe-buffer@5.1.2: - resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safe-regex-test@1.0.3: - resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} - engines: {node: '>= 0.4'} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - sax@1.4.1: - resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} - - saxes@6.0.0: - resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} - engines: {node: '>=v12.22.7'} - - scheduler@0.23.2: - resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} - - schema-utils@3.3.0: - resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} - engines: {node: '>= 10.13.0'} - - secure-json-parse@2.7.0: - resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} - - semver@5.7.2: - resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} - hasBin: true - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.6.3: - resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} - engines: {node: '>=10'} - hasBin: true - - serialize-javascript@6.0.2: - resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} - - set-blocking@2.0.0: - resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - - set-function-name@2.0.2: - resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} - engines: {node: '>= 0.4'} - - shallow-copy@0.0.1: - resolution: {integrity: sha512-b6i4ZpVuUxB9h5gfCxPiusKYkqTMOjEbBs4wMaFbkfia4yFv92UKZ6Df8WXcKbn08JNL/abvg3FnMAOfakDvUw==} - - shapefile@0.3.1: - resolution: {integrity: sha512-BZoPvnq4ULce0pyKiZUU4D8CdPl0Z1fpE73AeCkwyMbD2hpUeVA0s7jIE/wX8uWNruVeJV6e+rznPHBwuH5J6g==} - hasBin: true - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - shell-quote@1.7.4: - resolution: {integrity: sha512-8o/QEhSSRb1a5i7TFR0iM4G16Z0vYB2OQVs4G3aAFXjn3T6yEx8AZxy1PgDF7I00LZHYA3WxaSYIf5e5sAX8Rw==} - - side-channel@1.0.6: - resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} - engines: {node: '>= 0.4'} - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - signum@1.0.0: - resolution: {integrity: sha512-yodFGwcyt59XRh7w5W3jPcIQb3Bwi21suEfT7MAWnBX3iCdklJpgDgvGT9o04UonglZN5SNMfJFkHIR/jO8GHw==} - - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - - slice-ansi@4.0.0: - resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} - engines: {node: '>=10'} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - source-map-support@0.5.21: - resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} - - source-map@0.5.7: - resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} - engines: {node: '>=0.10.0'} - - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - - space-separated-tokens@2.0.2: - resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - - spawn-sync@1.0.15: - resolution: {integrity: sha512-9DWBgrgYZzNghseho0JOuh+5fg9u6QWhAWa51QC7+U5rCheZ/j1DrEZnyE0RBBRqZ9uEXGPgSSM0nky6burpVw==} - - spdx-correct@3.1.1: - resolution: {integrity: sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==} - - spdx-exceptions@2.3.0: - resolution: {integrity: sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==} - - spdx-expression-parse@3.0.1: - resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} - - spdx-license-ids@3.0.12: - resolution: {integrity: sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==} - - sshpk@1.18.0: - resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} - engines: {node: '>=0.10.0'} - hasBin: true - - sswr@2.1.0: - resolution: {integrity: sha512-Cqc355SYlTAaUt8iDPaC/4DPPXK925PePLMxyBKuWd5kKc5mwsG3nT9+Mq2tyguL5s7b4Jg+IRMpTRsNTAfpSQ==} - - stack-trace@0.0.9: - resolution: {integrity: sha512-vjUc6sfgtgY0dxCdnc40mK6Oftjo9+2K8H/NG81TMhgL392FtiPA9tn9RLyTxXmTLPJPjF3VyzFp6bsWFLisMQ==} - - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - static-eval@2.1.0: - resolution: {integrity: sha512-agtxZ/kWSsCkI5E4QifRwsaPs0P0JmZV6dkLz6ILYfFYQGn+5plctanRN+IC8dJRiFkyXHrwEE3W9Wmx67uDbw==} - - static-module@2.2.5: - resolution: {integrity: sha512-D8vv82E/Kpmz3TXHKG8PPsCPg+RAX6cbCOyvjM6x04qZtQ47EtJFVwRsdov3n5d6/6ynrOY9XB4JkaZwB2xoRQ==} - - std-env@3.6.0: - resolution: {integrity: sha512-aFZ19IgVmhdB2uX599ve2kE6BIE3YMnQ6Gp6BURhW/oIzpXGKr878TQfAQZn1+i0Flcc/UKUy1gOlcfaUBCryg==} - - stop-iteration-iterator@1.0.0: - resolution: {integrity: sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==} - engines: {node: '>= 0.4'} - - storybook@8.4.6: - resolution: {integrity: sha512-J6juZSZT2u3PUW0QZYZZYxBq6zU5O0OrkSgkMXGMg/QrS9to9IHmt4FjEMEyACRbXo8POcB/fSXa3VpGe7bv3g==} - hasBin: true - peerDependencies: - prettier: ^2 || ^3 - peerDependenciesMeta: - prettier: - optional: true - - stream-parser@0.3.1: - resolution: {integrity: sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==} - - stream-shift@1.0.1: - resolution: {integrity: sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==} - - strict-event-emitter-types@2.0.0: - resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==} - - string-dedent@3.0.1: - resolution: {integrity: sha512-A2zCXSgpPrpFi1lDJlDwIPYakBWeDtQZ8ZBKssB8M/WbtNEKTzsl1yCDRmHx55jSB27xZDQ6NOtRYekESWx6fw==} - engines: {node: '>=0.12.0'} - - string-split-by@1.0.0: - resolution: {integrity: sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==} - - string-width@1.0.2: - resolution: {integrity: sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==} - engines: {node: '>=0.10.0'} - - string-width@3.1.0: - resolution: {integrity: sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==} - engines: {node: '>=6'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string.prototype.includes@2.0.0: - resolution: {integrity: sha512-E34CkBgyeqNDcrbU76cDjL5JLcVrtSdYq0MEh/B10r17pRP4ciHLwTgnuLV8Ay6cgEMLkcBkFCKyFZ43YldYzg==} - - string.prototype.matchall@4.0.11: - resolution: {integrity: sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==} - engines: {node: '>= 0.4'} - - string.prototype.trim@1.2.9: - resolution: {integrity: sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==} - engines: {node: '>= 0.4'} - - string.prototype.trimend@1.0.8: - resolution: {integrity: sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==} - - string.prototype.trimstart@1.0.8: - resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} - engines: {node: '>= 0.4'} - - string_decoder@0.10.31: - resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} - - string_decoder@1.1.1: - resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} - - stringify-entities@4.0.4: - resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} - - strip-ansi@3.0.1: - resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==} - engines: {node: '>=0.10.0'} - - strip-ansi@5.2.0: - resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} - engines: {node: '>=6'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-ansi@7.1.0: - resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} - engines: {node: '>=12'} - - strip-bom@2.0.0: - resolution: {integrity: sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==} - engines: {node: '>=0.10.0'} - - strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} - - strip-final-newline@3.0.0: - resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} - engines: {node: '>=12'} - - strip-indent@3.0.0: - resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} - engines: {node: '>=8'} - - strip-indent@4.0.0: - resolution: {integrity: sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA==} - engines: {node: '>=12'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - strip-literal@2.0.0: - resolution: {integrity: sha512-f9vHgsCWBq2ugHAkGMiiYY+AYG0D/cbloKKg0nhaaaSNsujdGIpVXCNsrJpCKr5M0f4aI31mr13UjY6GAuXCKA==} - - strongly-connected-components@1.0.1: - resolution: {integrity: sha512-i0TFx4wPcO0FwX+4RkLJi1MxmcTv90jNZgxMu9XRnMXMeFUY1VJlIoXpZunPUvUUqbCT1pg5PEkFqqpcaElNaA==} - - style-loader@4.0.0: - resolution: {integrity: sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==} - engines: {node: '>= 18.12.0'} - peerDependencies: - webpack: ^5.27.0 - - style-mod@4.1.2: - resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==} - - style-to-js@1.1.12: - resolution: {integrity: sha512-tv+/FkgNYHI2fvCoBMsqPHh5xovwiw+C3X0Gfnss/Syau0Nr3IqGOJ9XiOYXoPnToHVbllKFf5qCNFJGwFg5mg==} - - style-to-object@1.0.6: - resolution: {integrity: sha512-khxq+Qm3xEyZfKd/y9L3oIWQimxuc4STrQKtQn8aSDRHb8mFgpukgX1hdzfrMEW6JCjyJ8p89x+IUMVnCBI1PA==} - - stylehacks@7.0.3: - resolution: {integrity: sha512-4DqtecvI/Nd+2BCvW9YEF6lhBN5UM50IJ1R3rnEAhBwbCKf4VehRf+uqvnVArnBayjYD/WtT3g0G/HSRxWfTRg==} - engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} - peerDependencies: - postcss: ^8.4.31 - - stylelint-config-recommended@14.0.1: - resolution: {integrity: sha512-bLvc1WOz/14aPImu/cufKAZYfXs/A/owZfSMZ4N+16WGXLoX5lOir53M6odBxvhgmgdxCVnNySJmZKx73T93cg==} - engines: {node: '>=18.12.0'} - peerDependencies: - stylelint: ^16.1.0 - - stylelint-config-standard@36.0.1: - resolution: {integrity: sha512-8aX8mTzJ6cuO8mmD5yon61CWuIM4UD8Q5aBcWKGSf6kg+EC3uhB+iOywpTK4ca6ZL7B49en8yanOFtUW0qNzyw==} - engines: {node: '>=18.12.0'} - peerDependencies: - stylelint: ^16.1.0 - - stylelint@16.8.2: - resolution: {integrity: sha512-fInKATippQhcSm7AB+T32GpI+626yohrg33GkFT/5jzliUw5qhlwZq2UQQwgl3HsHrf09oeARi0ZwgY/UWEv9A==} - engines: {node: '>=18.12.0'} - hasBin: true - - stylis@4.2.0: - resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} - - stylis@4.3.4: - resolution: {integrity: sha512-osIBl6BGUmSfDkyH2mB7EFvCJntXDrLhKjHTRj/rK6xLH0yuPrHULDRQzKokSOD4VoorhtKpfcfW1GAntu8now==} - - sucrase@3.32.0: - resolution: {integrity: sha512-ydQOU34rpSyj2TGyz4D2p8rbktIOZ8QY9s+DGLvFU1i5pWJE8vkpruCjGCMHsdXwnD7JDcS+noSwM/a7zyNFDQ==} - engines: {node: '>=8'} - hasBin: true - - supercluster@7.1.5: - resolution: {integrity: sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==} - - supercluster@8.0.1: - resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==} - - superscript-text@1.0.0: - resolution: {integrity: sha512-gwu8l5MtRZ6koO0icVTlmN5pm7Dhh1+Xpe9O4x6ObMAsW+3jPbW14d1DsBq1F4wiI+WOFjXF35pslgec/G8yCQ==} - - supports-color@5.5.0: - resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} - engines: {node: '>=4'} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - - supports-hyperlinks@3.0.0: - resolution: {integrity: sha512-QBDPHyPQDRTy9ku4URNGY5Lah8PAaXs6tAAwp55sL5WCsSW7GIfdf6W5ixfziW+t7wh3GVvHyHHyQ1ESsoRvaA==} - engines: {node: '>=14.18'} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - svg-arc-to-cubic-bezier@3.2.0: - resolution: {integrity: sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==} - - svg-path-bounds@1.0.2: - resolution: {integrity: sha512-H4/uAgLWrppIC0kHsb2/dWUYSmb4GE5UqH06uqWBcg6LBjX2fu0A8+JrO2/FJPZiSsNOKZAhyFFgsLTdYUvSqQ==} - - svg-path-sdf@1.1.3: - resolution: {integrity: sha512-vJJjVq/R5lSr2KLfVXVAStktfcfa1pNFjFOgyJnzZFXlO/fDZ5DmM8FpnSKKzLPfEYTVeXuVBTHF296TpxuJVg==} - - svg-tags@1.0.0: - resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} - - svgo@3.3.2: - resolution: {integrity: sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==} - engines: {node: '>=14.0.0'} - hasBin: true - - swiper@11.1.9: - resolution: {integrity: sha512-rflu8zvfGa3x1v/aeSufk4zRJffhOQowyvtJlp46sUBnOqAuk1Rdv5Ldj0AWWBV595iZ+ZMk7VB35ZRtRUomtA==} - engines: {node: '>= 4.7.0'} - - swr@2.2.5: - resolution: {integrity: sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==} - peerDependencies: - react: ^16.11.0 || ^17.0.0 || ^18.0.0 - - swrev@4.0.0: - resolution: {integrity: sha512-LqVcOHSB4cPGgitD1riJ1Hh4vdmITOp+BkmfmXRh4hSF/t7EnS4iD+SOTmq7w5pPm/SiPeto4ADbKS6dHUDWFA==} - - swrv@1.0.4: - resolution: {integrity: sha512-zjEkcP8Ywmj+xOJW3lIT65ciY/4AL4e/Or7Gj0MzU3zBJNMdJiT8geVZhINavnlHRMMCcJLHhraLTAiDOTmQ9g==} - - symbol-tree@3.2.4: - resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - - sync-request@2.2.0: - resolution: {integrity: sha512-2/XwYGb2GA31bcl4eVuyvzsMvOgubd7TqY9LVrp7LGCF1c7j54uG5rZLh0CasUkRf6D3uMIdRzFJ5PUVZAalDg==} - - table@6.8.2: - resolution: {integrity: sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA==} - engines: {node: '>=10.0.0'} - - tailwind-merge@2.5.2: - resolution: {integrity: sha512-kjEBm+pvD+6eAwzJL2Bi+02/9LFLal1Gs61+QB7HvTfQQ0aXwC5LGT8PEt1gS0CWKktKe6ysPTAy3cBC5MeiIg==} - - tailwindcss-animate@1.0.7: - resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} - peerDependencies: - tailwindcss: '>=3.0.0 || insiders' - - tailwindcss@3.4.10: - resolution: {integrity: sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==} - engines: {node: '>=14.0.0'} - hasBin: true - - tapable@2.2.1: - resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} - engines: {node: '>=6'} - - terser-webpack-plugin@5.3.10: - resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} - engines: {node: '>= 10.13.0'} - peerDependencies: - '@swc/core': '*' - esbuild: '*' - uglify-js: '*' - webpack: ^5.1.0 - peerDependenciesMeta: - '@swc/core': - optional: true - esbuild: - optional: true - uglify-js: - optional: true - - terser@5.36.0: - resolution: {integrity: sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==} - engines: {node: '>=10'} - hasBin: true - - text-table@0.2.0: - resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - - thememirror@2.0.1: - resolution: {integrity: sha512-d5i6FVvWWPkwrm4cHLI3t9AT1OrkAt7Ig8dtdYSofgF7C/eiyNuq6zQzSTusWTde3jpW9WLvA9J/fzNKMUsd0w==} - peerDependencies: - '@codemirror/language': ^6.0.0 - '@codemirror/state': ^6.0.0 - '@codemirror/view': ^6.0.0 - - then-request@2.2.0: - resolution: {integrity: sha512-YM/Fho1bQ3JFX9dgFQsBswc3aSTePXvtNHl3aXJTZNz/444yC86EVJR92aWMRNA0O9X0UfmojyCTUcT8Lbo5yA==} - - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - - through2@0.6.5: - resolution: {integrity: sha512-RkK/CCESdTKQZHdmKICijdKKsCRVHs5KsLZ6pACAmF/1GPUQhonHSXWNERctxEp7RmvjdNbZTL5z9V7nSCXKcg==} - - through2@2.0.5: - resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} - - timestring@7.0.0: - resolution: {integrity: sha512-U7ttxEdKWqHYJ96OGoJJR5gU8Nwkl3tlY0n7Jr4vcpLD2RkVZLE1Ph9k8ZRrZ7LYX9QCtd3M9OUaR9P8Z37QNg==} - engines: {node: '>=12'} - - tiny-invariant@1.3.3: - resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - - tinybench@2.5.1: - resolution: {integrity: sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==} - - tinycolor2@1.6.0: - resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} - - tinyexec@0.3.1: - resolution: {integrity: sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==} - - tinypool@0.8.4: - resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} - engines: {node: '>=14.0.0'} - - tinyqueue@2.0.3: - resolution: {integrity: sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==} - - tinyqueue@3.0.0: - resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} - - tinyrainbow@1.2.0: - resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} - engines: {node: '>=14.0.0'} - - tinyspy@2.2.0: - resolution: {integrity: sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg==} - engines: {node: '>=14.0.0'} - - tinyspy@3.0.2: - resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} - engines: {node: '>=14.0.0'} - - to-float32@1.1.0: - resolution: {integrity: sha512-keDnAusn/vc+R3iEiSDw8TOF7gPiTLdK1ArvWtYbJQiVfmRg6i/CAvbKq3uIS0vWroAC7ZecN3DjQKw3aSklUg==} - - to-px@1.0.1: - resolution: {integrity: sha512-2y3LjBeIZYL19e5gczp14/uRWFDtDUErJPVN3VU9a7SJO+RjGRtYR47aMN2bZgGlxvW4ZcEz2ddUPVHXcMfuXw==} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - toggle-selection@1.0.6: - resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} - - topojson-client@3.1.0: - resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==} - hasBin: true - - topojson@1.6.27: - resolution: {integrity: sha512-JLFtrhClUH/k/yvsiCXqcWcXaOfO3DgFvHnYb+gS2xlDbjbvkKh6YB1CPilmEV++tH33xw6wCxoYA5g6YLZw/Q==} - deprecated: Use topojson-client, topojson-server or topojson-simplify directly. - hasBin: true - - tough-cookie@2.5.0: - resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==} - engines: {node: '>=0.8'} - - tough-cookie@4.1.4: - resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} - engines: {node: '>=6'} - - tr46@0.0.3: - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - - tr46@5.0.0: - resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} - engines: {node: '>=18'} - - trim-lines@3.0.1: - resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} - - trim-right@1.0.1: - resolution: {integrity: sha512-WZGXGstmCWgeevgTL54hrCuw1dyMQIzWy7ZfqRJfSmJZBwklI15egmQytFP6bPidmw3M8d5yEowl1niq4vmqZw==} - engines: {node: '>=0.10.0'} - - trough@2.2.0: - resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - - ts-api-utils@1.3.0: - resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} - engines: {node: '>=16'} - peerDependencies: - typescript: '>=4.2.0' - - ts-dedent@2.2.0: - resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} - engines: {node: '>=6.10'} - - ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - - tsconfck@3.0.3: - resolution: {integrity: sha512-4t0noZX9t6GcPTfBAbIbbIU4pfpCwh0ueq3S4O/5qXI1VwK1outmxhe9dOiEWqMz3MW2LKgDTpqWV+37IWuVbA==} - engines: {node: ^18 || >=20} - hasBin: true - peerDependencies: - typescript: ^5.0.0 - peerDependenciesMeta: - typescript: - optional: true - - tsconfig-paths@4.2.0: - resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} - engines: {node: '>=6'} - - tslib@1.10.0: - resolution: {integrity: sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==} - - tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - - tslib@2.6.3: - resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - - turbo-darwin-64@2.0.14: - resolution: {integrity: sha512-kwfDmjNwlNfvtrvT29+ZBg5n1Wvxl891bFHchMJyzMoR0HOE9N1NSNdSZb9wG3e7sYNIu4uDkNk+VBEqJW0HzQ==} - cpu: [x64] - os: [darwin] - - turbo-darwin-arm64@2.0.14: - resolution: {integrity: sha512-m3LXYEshCx3wc4ZClM6gb01KYpFmtjQ9IBF3A7ofjb6ahux3xlYZJZ3uFCLAGHuvGLuJ3htfiPbwlDPTdknqqw==} - cpu: [arm64] - os: [darwin] - - turbo-linux-64@2.0.14: - resolution: {integrity: sha512-7vBzCPdoTtR92SNn2JMgj1FlMmyonGmpMaQdgAB1OVYtuQ6NVGoh7/lODfaILqXjpvmFSVbpBIDrKOT6EvcprQ==} - cpu: [x64] - os: [linux] - - turbo-linux-arm64@2.0.14: - resolution: {integrity: sha512-jwH+c0bfjpBf26K/tdEFatmnYyXwGROjbr6bZmNcL8R+IkGAc/cglL+OToqJnQZTgZvH7uDGbeSyUo7IsHyjuA==} - cpu: [arm64] - os: [linux] - - turbo-windows-64@2.0.14: - resolution: {integrity: sha512-w9/XwkHSzvLjmioo6cl3S1yRfI6swxsV1j1eJwtl66JM4/pn0H2rBa855R0n7hZnmI6H5ywLt/nLt6Ae8RTDmw==} - cpu: [x64] - os: [win32] - - turbo-windows-arm64@2.0.14: - resolution: {integrity: sha512-XaQlyYk+Rf4xS5XWCo8XCMIpssgGGy8blzLfolN6YBp4baElIWMlkLZHDbGyiFmCbNf9I9gJI64XGRG+LVyyjA==} - cpu: [arm64] - os: [win32] - - turbo@2.0.14: - resolution: {integrity: sha512-00JjdCMD/cpsjP0Izkjcm8Oaor5yUCfDwODtaLb+WyblyadkaDEisGhy3Dbd5az9n+5iLSPiUgf+WjPbns6MRg==} - hasBin: true - - tweetnacl@0.14.5: - resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} - - type-check@0.3.2: - resolution: {integrity: sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==} - engines: {node: '>= 0.8.0'} - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - type-detect@4.0.8: - resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} - engines: {node: '>=4'} - - type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} - engines: {node: '>=10'} - - type-fest@0.6.0: - resolution: {integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==} - engines: {node: '>=8'} - - type-fest@0.8.1: - resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} - engines: {node: '>=8'} - - type-fest@2.19.0: - resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} - engines: {node: '>=12.20'} - - type@1.2.0: - resolution: {integrity: sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==} - - type@2.7.2: - resolution: {integrity: sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==} - - typed-array-buffer@1.0.2: - resolution: {integrity: sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==} - engines: {node: '>= 0.4'} - - typed-array-byte-length@1.0.1: - resolution: {integrity: sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==} - engines: {node: '>= 0.4'} - - typed-array-byte-offset@1.0.2: - resolution: {integrity: sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==} - engines: {node: '>= 0.4'} - - typed-array-length@1.0.6: - resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} - engines: {node: '>= 0.4'} - - typedarray-pool@1.2.0: - resolution: {integrity: sha512-YTSQbzX43yvtpfRtIDAYygoYtgT+Rpjuxy9iOpczrjpXLgGoyG7aS5USJXV2d3nn8uHTeb9rXDvzS27zUg5KYQ==} - - typedarray@0.0.6: - resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - - typescript-json-schema@0.9.2: - resolution: {integrity: sha512-af4VWiWZL24w3LNaV9OFHzaWXVePfkHEHPZ224EEGUZTPphXsZ2RbzHv9KiDZTv73ZFSkCjJ9VkS07GKVF7OUQ==} - hasBin: true - - typescript-memoize@1.1.1: - resolution: {integrity: sha512-GQ90TcKpIH4XxYTI2F98yEQYZgjNMOGPpOgdjIBhaLaWji5HPWlRnZ4AeA1hfBxtY7bCGDJsqDDHk/KaHOl5bA==} - - typescript@2.1.6: - resolution: {integrity: sha512-K3UOLVZbmb0mXh2dNfvY20u5PSqiyyhDHcCboXVRsmLavjbuZocp9D3fPdtAWZmdQ1UERwxqLeGmXDSQsWcL8g==} - engines: {node: '>=4.2.0'} - hasBin: true - - typescript@5.5.4: - resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==} - engines: {node: '>=14.17'} - hasBin: true - - ufo@1.5.4: - resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} - - unbox-primitive@1.0.2: - resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} - - undici-types@6.19.8: - resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} - - unified@11.0.5: - resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} - - unist-util-is@6.0.0: - resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} - - unist-util-position@5.0.0: - resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} - - unist-util-stringify-position@4.0.0: - resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} - - unist-util-visit-parents@6.0.1: - resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} - - unist-util-visit@5.0.0: - resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} - - universalify@0.2.0: - resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} - engines: {node: '>= 4.0.0'} - - unplugin@1.4.0: - resolution: {integrity: sha512-5x4eIEL6WgbzqGtF9UV8VEC/ehKptPXDS6L2b0mv4FRMkJxRtjaJfOWDd6a8+kYbqsjklix7yWP0N3SUepjXcg==} - - unquote@1.1.1: - resolution: {integrity: sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==} - - update-browserslist-db@1.1.1: - resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - update-diff@1.1.0: - resolution: {integrity: sha512-rCiBPiHxZwT4+sBhEbChzpO5hYHjm91kScWgdHf4Qeafs6Ba7MBl+d9GlGv72bcTZQO0sLmtQS1pHSWoCLtN/A==} - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - url-parse@1.5.10: - resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} - - use-callback-ref@1.3.0: - resolution: {integrity: sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - use-resize-observer@9.1.0: - resolution: {integrity: sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==} - peerDependencies: - react: 16.8.0 - 18 - react-dom: 16.8.0 - 18 - - use-sidecar@1.1.2: - resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': ^16.9.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - use-sync-external-store@1.2.0: - resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - util@0.12.5: - resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} - - uuid@3.4.0: - resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} - deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. - hasBin: true - - uuid@9.0.1: - resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} - hasBin: true - - validate-npm-package-license@3.0.4: - resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} - - vega-canvas@1.2.7: - resolution: {integrity: sha512-OkJ9CACVcN9R5Pi9uF6MZBF06pO6qFpDYHWSKBJsdHP5o724KrsgR6UvbnXFH82FdsiTOff/HqjuaG8C7FL+9Q==} - - vega-crossfilter@4.1.2: - resolution: {integrity: sha512-J7KVEXkpfRJBfRvwLxn5vNCzQCNkrnzmDvkvwhuiwT4gPm5sk7MK5TuUP8GCl/iKYw+kWeVXEtrVHwWtug+bcQ==} - - vega-dataflow@5.7.6: - resolution: {integrity: sha512-9Md8+5iUC1MVKPKDyZ7pCEHk6I9am+DgaMzZqo/27O/KI4f23/WQXPyuI8jbNmc/mkm340P0TKREmzL5M7+2Dg==} - - vega-embed@6.23.0: - resolution: {integrity: sha512-8Iava57LdROsatqsOhMLErHYaBpZBB7yZJlSVU3/xOK3l8Ft5WFnj5fm3OOAVML97/0yULE7LRVjXW5hV3fSpg==} - peerDependencies: - vega: ^5.21.0 - vega-lite: '*' - bundledDependencies: - - yallist - - vega-encode@4.10.1: - resolution: {integrity: sha512-d25nVKZDrg109rC65M8uxE+7iUrTxktaqgK4fU3XZBgpWlh1K4UbU5nDag7kiHVVN4tKqwgd+synEotra9TiVQ==} - - vega-event-selector@2.0.6: - resolution: {integrity: sha512-UwCu50Sqd8kNZ1X/XgiAY+QAyQUmGFAwyDu7y0T5fs6/TPQnDo/Bo346NgSgINBEhEKOAMY1Nd/rPOk4UEm/ew==} - - vega-event-selector@3.0.1: - resolution: {integrity: sha512-K5zd7s5tjr1LiOOkjGpcVls8GsH/f2CWCrWcpKy74gTCp+llCdwz0Enqo013ZlGaRNjfgD/o1caJRt3GSaec4A==} - - vega-expression@2.6.6: - resolution: {integrity: sha512-zxPzXO33FawU3WQHRmHJaRreyJlyMaNMn1uuCFSouJttPkBBWB5gCrha2f5+pF3t4NMFWTnSrgCkR6mcaubnng==} - - vega-expression@5.1.1: - resolution: {integrity: sha512-zv9L1Hm0KHE9M7mldHyz8sXbGu3KmC0Cdk7qfHkcTNS75Jpsem6jkbu6ZAwx5cNUeW91AxUQOu77r4mygq2wUQ==} - - vega-force@4.2.1: - resolution: {integrity: sha512-2BcuuqFr77vcCyKfcpedNFeYMxi+XEFCrlgLWNx7YV0PI8pdP5y/yPkzyuE9Tb894+KkRAvfQHZRAshcnFNcMw==} - - vega-format@1.1.2: - resolution: {integrity: sha512-0kUfAj0dg0U6GcEY0Kp6LiSTCZ8l8jl1qVdQyToMyKmtZg/q56qsiJQZy3WWRr1MtWkTIZL71xSJXgjwjeUaAw==} - - vega-functions@5.15.0: - resolution: {integrity: sha512-pCqmm5efd+3M65jrJGxEy3UGuRksmK6DnWijoSNocnxdCBxez+yqUUVX9o2pN8VxMe3648vZnR9/Vk5CXqRvIQ==} - - vega-geo@4.4.2: - resolution: {integrity: sha512-unuV/UxUHf6UJu6GYxMZonC3SZlMfFXYLOkgEsRSvmsMPt3+CVv8FmG88dXNRUJUrdROrJepgecqx0jOwMSnGA==} - - vega-hierarchy@4.1.2: - resolution: {integrity: sha512-m+xDtT5092YPSnV0rdTLW+AWmoCb+A54JQ66MUJwiDBpKxvfKnTiQeuiWDU2YudjUoXZN9EBOcI6QHF8H2Lu2A==} - - vega-interpreter@1.0.5: - resolution: {integrity: sha512-po6oTOmeQqr1tzTCdD15tYxAQLeUnOVirAysgVEemzl+vfmvcEP7jQmlc51jz0jMA+WsbmE6oJywisQPu/H0Bg==} - - vega-label@1.3.0: - resolution: {integrity: sha512-EfSFSCWAwVPsklM5g0gUEuohALgryuGC/SKMmsOH7dYT/bywmLBZhLVbrE+IHJAUauoGrMhYw1mqnXL/0giJBg==} - - vega-lite@3.4.0: - resolution: {integrity: sha512-RJg9uBNh5g0hA8xTzAcALUfNx0cEq7E7xx+vxPEGSMgI8z+A5KlE9u4jUx6nKu7Mjg1qZO8WOyWCmBS1kdFWPg==} - hasBin: true - peerDependencies: - vega: ^5.4.0 - - vega-lite@5.21.0: - resolution: {integrity: sha512-hNxM9nuMqpI1vkUOhEx6ewEf23WWLmJxSFJ4TA86AW43ixJyqcLV+iSCO0NipuVTE0rlDcc2e8joSewWyOlEwA==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - vega: ^5.24.0 - - vega-loader@4.5.2: - resolution: {integrity: sha512-ktIdGz3DRIS3XfTP9lJ6oMT5cKwC86nQkjUbXZbOtwXQFVNE2xVWBuH13GP6FKUZxg5hJCMtb5v/e/fwTvhKsQ==} - - vega-parser@6.4.0: - resolution: {integrity: sha512-/hFIJs0yITxfvLIfhhcpUrcbKvu4UZYoMGmly5PSsbgo60oAsVQW8ZbX2Ji3iNFqZJh1ifoX/P0j+9wep1OISw==} - - vega-projection@1.6.1: - resolution: {integrity: sha512-sqfnAAHumU7MWU1tQN3b6HNgKGF3legek0uLHhjLKcDJQxEc7kwcD18txFz2ffQks6d5j+AUhBiq4GARWf0DEQ==} - - vega-regression@1.3.0: - resolution: {integrity: sha512-gxOQfmV7Ft/MYKpXDEo09WZyBuKOBqxqDRWay9KtfGq/E0Y4vbTPsWLv2cB1ToPJdKE6XSN6Re9tCIw5M/yMUg==} - - vega-runtime@6.2.0: - resolution: {integrity: sha512-30UXbujWjKNd5aeP+oeHuwFmzuyVYlBj4aDy9+AjfWLECu8wJt4K01vwegcaGPdCWcPLVIv4Oa9Lob4mcXn5KQ==} - - vega-scale@7.4.1: - resolution: {integrity: sha512-dArA28DbV/M92O2QvswnzCmQ4bq9WwLKUoyhqFYWCltmDwkmvX7yhqiFLFMWPItIm7mi4Qyoygby6r4DKd1X2A==} - - vega-scenegraph@4.13.0: - resolution: {integrity: sha512-nfl45XtuqB5CxyIZJ+bbJ+dofzosPCRlmF+eUQo+0J23NkNXsTzur+1krJDSdhcw0SOYs4sbYRoMz1cpuOM4+Q==} - - vega-schema-url-parser@2.2.0: - resolution: {integrity: sha512-yAtdBnfYOhECv9YC70H2gEiqfIbVkq09aaE4y/9V/ovEFmH9gPKaEgzIZqgT7PSPQjKhsNkb6jk6XvSoboxOBw==} - - vega-selections@5.4.2: - resolution: {integrity: sha512-99FUhYmg0jOJr2/K4TcEURmJRkuibrCDc8KBUX7qcQEITzrZ5R6a4QE+sarCvbb3hi8aA9GV2oyST6MQeA9mgQ==} - - vega-statistics@1.9.0: - resolution: {integrity: sha512-GAqS7mkatpXcMCQKWtFu1eMUKLUymjInU0O8kXshWaQrVWjPIO2lllZ1VNhdgE0qGj4oOIRRS11kzuijLshGXQ==} - - vega-themes@2.14.0: - resolution: {integrity: sha512-9dLmsUER7gJrDp8SEYKxBFmXmpyzLlToKIjxq3HCvYjz8cnNrRGyAhvIlKWOB3ZnGvfYV+vnv3ZRElSNL31nkA==} - peerDependencies: - vega: '*' - vega-lite: '*' - - vega-time@2.1.2: - resolution: {integrity: sha512-6rXc6JdDt8MnCRy6UzUCsa6EeFycPDmvioMddLfKw38OYCV8pRQC5nw44gyddOwXgUTJLiCtn/sp53P0iA542A==} - - vega-tooltip@0.33.0: - resolution: {integrity: sha512-jMcvH2lP20UfyvO2KAEdloiwRyasikaiLuNFhzwrrzf2RamGTxP4G7B2OZ2QENfrGUH05Z9ei5tn/eErdzOaZQ==} - - vega-transforms@4.12.0: - resolution: {integrity: sha512-bh/2Qbj85O70mjfLRgPKAsABArgSUP0k+GjmaY54zukIRxoGxKju+85nigeX/aR/INpEqNWif+5lL+NvmyWA5w==} - - vega-typings@0.7.2: - resolution: {integrity: sha512-BReB2qRERA/Ke+QoxKDQ7fES25A9Q3qKRm1CJxwvpLGhAl4k5cGDORx6yW+J3rFHMzpJlmdRM+kb489EuphxZQ==} - - vega-typings@1.3.1: - resolution: {integrity: sha512-j9Sdgmvowz09jkMgTFGVfiv7ycuRP/TQkdHRPXIYwt3RDgPQn7inyFcJ8C8ABFt4MiMWdjOwbneF6KWW8TRXIw==} - - vega-util@1.10.0: - resolution: {integrity: sha512-fTGnTG7FhtTG9tiYDL3k5s8YHqB71Ml5+aC9B7eaBygeB8GKXBrcbTXLOzoCRxT3Jr5cRhr99PMBu0AkqmhBog==} - - vega-util@1.17.2: - resolution: {integrity: sha512-omNmGiZBdjm/jnHjZlywyYqafscDdHaELHx1q96n5UOz/FlO9JO99P4B3jZg391EFG8dqhWjQilSf2JH6F1mIw==} - - vega-view-transforms@4.6.0: - resolution: {integrity: sha512-z3z66aJTA3ZRo4oBY4iBXnn+A4KqBGZT/UrlKDbm+7Ec+Ip+hK2tF8Kmhp/WNcMsDZoUWFqLJgR2VgOgvJk9RA==} - - vega-view@5.13.0: - resolution: {integrity: sha512-ZPAAQ3iYz6YrQjJoDT+0bcxJkXt9PKF5v4OO7Omw8PFhkIv++jFXeKlQTW1bBtyQ92dkdGGHv5lYY67Djqjf3A==} - - vega-voronoi@4.2.3: - resolution: {integrity: sha512-aYYYM+3UGqwsOx+TkVtF1IZfguy0H7AN79dR8H0nONRIc+vhk/lbnlkgwY2nSzEu0EZ4b5wZxeGoDBEVmdDEcg==} - - vega-wordcloud@4.1.5: - resolution: {integrity: sha512-p+qXU3cb9VeWzJ/HEdax0TX2mqDJcSbrCIfo2d/EalOXGkvfSLKobsmMQ8DxPbtVp0uhnpvfCGDyMJw+AzcI2A==} - - vega@5.30.0: - resolution: {integrity: sha512-ZGoC8LdfEUV0LlXIuz7hup9jxuQYhSaWek2M7r9dEHAPbPrzSQvKXZ0BbsJbrarM100TGRpTVN/l1AFxCwDkWw==} - - verror@1.10.0: - resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} - engines: {'0': node >=0.6.0} - - vfile-message@4.0.2: - resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} - - vfile@6.0.3: - resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - - vite-node@1.6.0: - resolution: {integrity: sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - - vite-tsconfig-paths@4.3.2: - resolution: {integrity: sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==} - peerDependencies: - vite: '*' - peerDependenciesMeta: - vite: - optional: true - - vite@5.4.6: - resolution: {integrity: sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - - vitest@1.6.0: - resolution: {integrity: sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 1.6.0 - '@vitest/ui': 1.6.0 - happy-dom: '*' - jsdom: '*' - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@types/node': - optional: true - '@vitest/browser': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - vlq@0.2.3: - resolution: {integrity: sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==} - - vscode-jsonrpc@8.2.0: - resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} - engines: {node: '>=14.0.0'} - - vscode-languageserver-protocol@3.17.5: - resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} - - vscode-languageserver-textdocument@1.0.12: - resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} - - vscode-languageserver-types@3.17.5: - resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} - - vscode-languageserver@9.0.1: - resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} - hasBin: true - - vscode-uri@3.0.8: - resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} - - vt-pbf@3.1.3: - resolution: {integrity: sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==} - - w3c-keyname@2.2.8: - resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} - - w3c-xmlserializer@5.0.0: - resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} - engines: {node: '>=18'} - - watchpack@2.4.2: - resolution: {integrity: sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==} - engines: {node: '>=10.13.0'} - - weak-map@1.0.8: - resolution: {integrity: sha512-lNR9aAefbGPpHO7AEnY0hCFjz1eTkWCXYvkTRrTHs9qv8zJp+SkVYpzfLIFXQQiG3tVvbNFQgVg2bQS8YGgxyw==} - - web-vitals@4.2.3: - resolution: {integrity: sha512-/CFAm1mNxSmOj6i0Co+iGFJ58OS4NRGVP+AWS/l509uIK5a1bSoIVaHz/ZumpHTfHSZBpgrJ+wjfpAOrTHok5Q==} - - webgl-context@2.2.0: - resolution: {integrity: sha512-q/fGIivtqTT7PEoF07axFIlHNk/XCPaYpq64btnepopSWvKNFkoORlQYgqDigBIuGA1ExnFd/GnSUnBNEPQY7Q==} - - webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - - webidl-conversions@7.0.0: - resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} - engines: {node: '>=12'} - - webpack-sources@3.2.3: - resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} - engines: {node: '>=10.13.0'} - - webpack-virtual-modules@0.5.0: - resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==} - - webpack@5.96.1: - resolution: {integrity: sha512-l2LlBSvVZGhL4ZrPwyr8+37AunkcYj5qh8o6u2/2rzoPc8gxFJkLj1WxNgooi9pnoc06jh0BjuXnamM4qlujZA==} - engines: {node: '>=10.13.0'} - hasBin: true - peerDependencies: - webpack-cli: '*' - peerDependenciesMeta: - webpack-cli: - optional: true - - whatwg-encoding@3.1.1: - resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} - engines: {node: '>=18'} - - whatwg-fetch@3.6.17: - resolution: {integrity: sha512-c4ghIvG6th0eudYwKZY5keb81wtFz9/WeAHAoy8+r18kcWlitUIrmGFQ2rWEl4UCKUilD3zCLHOIPheHx5ypRQ==} - - whatwg-mimetype@4.0.0: - resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} - engines: {node: '>=18'} - - whatwg-url@14.0.0: - resolution: {integrity: sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==} - engines: {node: '>=18'} - - whatwg-url@5.0.0: - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - - which-boxed-primitive@1.0.2: - resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} - - which-builtin-type@1.1.3: - resolution: {integrity: sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==} - engines: {node: '>= 0.4'} - - which-collection@1.0.1: - resolution: {integrity: sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==} - - which-module@1.0.0: - resolution: {integrity: sha512-F6+WgncZi/mJDrammbTuHe1q0R5hOXv/mBaiNA2TCNT/LTHusX0V+CJnj9XT8ki5ln2UZyyddDgHfCzyrOH7MQ==} - - which-module@2.0.1: - resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} - - which-typed-array@1.1.15: - resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==} - engines: {node: '>= 0.4'} - - which@1.3.1: - resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} - hasBin: true - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - which@4.0.0: - resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} - engines: {node: ^16.13.0 || >=18.0.0} - hasBin: true - - why-is-node-running@2.2.2: - resolution: {integrity: sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==} - engines: {node: '>=8'} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - wordwrap@0.0.3: - resolution: {integrity: sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw==} - engines: {node: '>=0.4.0'} - - world-calendars@1.0.3: - resolution: {integrity: sha512-sAjLZkBnsbHkHWVhrsCU5Sa/EVuf9QqgvrN8zyJ2L/F9FR9Oc6CvVK0674+PGAtmmmYQMH98tCUSO4QLQv3/TQ==} - - wrap-ansi@2.1.0: - resolution: {integrity: sha512-vAaEaDM946gbNpH5pLVNR+vX2ht6n0Bt3GXwVB1AuAqZosOvHNF3P7wDnh8KLkSqgUh0uh77le7Owgoz+Z9XBw==} - engines: {node: '>=0.10.0'} - - wrap-ansi@5.1.0: - resolution: {integrity: sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==} - engines: {node: '>=6'} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - write-file-atomic@5.0.1: - resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - ws@7.5.9: - resolution: {integrity: sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==} - engines: {node: '>=8.3.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ^5.0.2 - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - ws@8.18.0: - resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} - 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 - - xml-name-validator@5.0.0: - resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} - engines: {node: '>=18'} - - xmlchars@2.2.0: - resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - - xtend@2.2.0: - resolution: {integrity: sha512-SLt5uylT+4aoXxXuwtQp5ZnMMzhDb1Xkg4pEqc00WUJCQifPfV9Ub1VrNhp9kXkrjZD2I2Hl8WnjP37jzZLPZw==} - engines: {node: '>=0.4'} - - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - - y18n@3.2.2: - resolution: {integrity: sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ==} - - y18n@4.0.3: - resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yaml@1.10.2: - resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} - engines: {node: '>= 6'} - - yaml@2.2.2: - resolution: {integrity: sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==} - engines: {node: '>= 14'} - - yargs-parser@13.1.2: - resolution: {integrity: sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs-parser@4.2.1: - resolution: {integrity: sha512-+QQWqC2xeL0N5/TE+TY6OGEqyNRM+g2/r712PDNYgiCdXYCApXf1vzfmDSLBxfGRwV+moTq/V8FnMI24JCm2Yg==} - - yargs@13.3.2: - resolution: {integrity: sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - - yargs@6.6.0: - resolution: {integrity: sha512-6/QWTdisjnu5UHUzQGst+UOEuEVwIzFVGBjq3jMTFNs5WJQsH/X6nMURSaScIdF5txylr1Ao9bvbWiKi2yXbwA==} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - - yocto-queue@1.0.0: - resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} - engines: {node: '>=12.20'} - - zod-to-json-schema@3.22.5: - resolution: {integrity: sha512-+akaPo6a0zpVCCseDed504KBJUQpEW5QZw7RMneNmKw+fGaML1Z9tUNLnHHAC8x6dzVRO1eB2oEMyZRnuBZg7Q==} - peerDependencies: - zod: ^3.22.4 - - zod-validation-error@2.1.0: - resolution: {integrity: sha512-VJh93e2wb4c3tWtGgTa0OF/dTt/zoPCPzXq4V11ZjxmEAFaPi/Zss1xIZdEB5RD8GD00U0/iVXgqkF77RV7pdQ==} - engines: {node: '>=18.0.0'} - peerDependencies: - zod: ^3.18.0 - - zod-validation-error@3.4.0: - resolution: {integrity: sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==} - engines: {node: '>=18.0.0'} - peerDependencies: - zod: ^3.18.0 - - zod@3.23.8: - resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} - - zustand@4.5.2: - resolution: {integrity: sha512-2cN1tPkDVkwCy5ickKrI7vijSjPksFRfqS6237NzT0vqSsztTNnQdHw9mmN7uBdk3gceVXU0a+21jFzFzAc9+g==} - engines: {node: '>=12.7.0'} - peerDependencies: - '@types/react': '>=16.8' - immer: '>=9.0.6' - react: '>=16.8' - peerDependenciesMeta: - '@types/react': - optional: true - immer: - optional: true - react: - optional: true - - zwitch@2.0.4: - resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} - -snapshots: - - '@aashutoshrathi/word-wrap@1.2.6': {} - - '@adobe/css-tools@4.4.0': {} - - '@ai-sdk/provider-utils@1.0.14(zod@3.23.8)': - dependencies: - '@ai-sdk/provider': 0.0.21 - eventsource-parser: 1.1.2 - nanoid: 3.3.6 - secure-json-parse: 2.7.0 - optionalDependencies: - zod: 3.23.8 - - '@ai-sdk/provider@0.0.21': - dependencies: - json-schema: 0.4.0 - - '@ai-sdk/react@0.0.47(react@18.3.1)(zod@3.23.8)': - dependencies: - '@ai-sdk/provider-utils': 1.0.14(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.34(zod@3.23.8) - swr: 2.2.5(react@18.3.1) - optionalDependencies: - react: 18.3.1 - zod: 3.23.8 - - '@ai-sdk/solid@0.0.37(zod@3.23.8)': - dependencies: - '@ai-sdk/provider-utils': 1.0.14(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.34(zod@3.23.8) - transitivePeerDependencies: - - zod - - '@ai-sdk/svelte@0.0.39(zod@3.23.8)': - dependencies: - '@ai-sdk/provider-utils': 1.0.14(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.34(zod@3.23.8) - sswr: 2.1.0 - transitivePeerDependencies: - - zod - - '@ai-sdk/ui-utils@0.0.34(zod@3.23.8)': - dependencies: - '@ai-sdk/provider': 0.0.21 - '@ai-sdk/provider-utils': 1.0.14(zod@3.23.8) - json-schema: 0.4.0 - secure-json-parse: 2.7.0 - zod-to-json-schema: 3.22.5(zod@3.23.8) - optionalDependencies: - zod: 3.23.8 - - '@ai-sdk/vue@0.0.39(zod@3.23.8)': - dependencies: - '@ai-sdk/provider-utils': 1.0.14(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.34(zod@3.23.8) - swrv: 1.0.4 - transitivePeerDependencies: - - zod - - '@alloc/quick-lru@5.2.0': {} - - '@ampproject/remapping@2.3.0': - dependencies: - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 - - '@antfu/install-pkg@0.4.1': - dependencies: - package-manager-detector: 0.2.2 - tinyexec: 0.3.1 - - '@antfu/utils@0.7.10': {} - - '@babel/code-frame@7.25.9': - dependencies: - '@babel/highlight': 7.25.9 - picocolors: 1.1.1 - - '@babel/compat-data@7.25.9': {} - - '@babel/core@7.25.9': - dependencies: - '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.25.9 - '@babel/generator': 7.25.9 - '@babel/helper-compilation-targets': 7.25.9 - '@babel/helper-module-transforms': 7.25.9(@babel/core@7.25.9) - '@babel/helpers': 7.25.9 - '@babel/parser': 7.25.9 - '@babel/template': 7.25.9 - '@babel/traverse': 7.25.9 - '@babel/types': 7.25.9 - convert-source-map: 2.0.0 - debug: 4.3.6 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.2.0': - dependencies: - '@babel/types': 7.25.9 - jsesc: 2.5.2 - lodash: 4.17.21 - source-map: 0.5.7 - trim-right: 1.0.1 - - '@babel/generator@7.25.9': - dependencies: - '@babel/types': 7.25.9 - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 - jsesc: 3.0.2 - - '@babel/helper-annotate-as-pure@7.25.9': - dependencies: - '@babel/types': 7.25.9 - - '@babel/helper-compilation-targets@7.25.9': - dependencies: - '@babel/compat-data': 7.25.9 - '@babel/helper-validator-option': 7.25.9 - browserslist: 4.24.2 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-create-class-features-plugin@7.25.9(@babel/core@7.25.9)': - dependencies: - '@babel/core': 7.25.9 - '@babel/helper-annotate-as-pure': 7.25.9 - '@babel/helper-member-expression-to-functions': 7.25.9 - '@babel/helper-optimise-call-expression': 7.25.9 - '@babel/helper-replace-supers': 7.25.9(@babel/core@7.25.9) - '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 - '@babel/traverse': 7.25.9 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/helper-member-expression-to-functions@7.25.9': - dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.25.9 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.25.9': - dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.25.9 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.25.9(@babel/core@7.25.9)': - dependencies: - '@babel/core': 7.25.9 - '@babel/helper-module-imports': 7.25.9 - '@babel/helper-simple-access': 7.25.9 - '@babel/helper-validator-identifier': 7.25.9 - '@babel/traverse': 7.25.9 - transitivePeerDependencies: - - supports-color - - '@babel/helper-optimise-call-expression@7.25.9': - dependencies: - '@babel/types': 7.25.9 - - '@babel/helper-plugin-utils@7.25.9': {} - - '@babel/helper-replace-supers@7.25.9(@babel/core@7.25.9)': - dependencies: - '@babel/core': 7.25.9 - '@babel/helper-member-expression-to-functions': 7.25.9 - '@babel/helper-optimise-call-expression': 7.25.9 - '@babel/traverse': 7.25.9 - transitivePeerDependencies: - - supports-color - - '@babel/helper-simple-access@7.25.9': - dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.25.9 - transitivePeerDependencies: - - supports-color - - '@babel/helper-skip-transparent-expression-wrappers@7.25.9': - dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.25.9 - transitivePeerDependencies: - - supports-color - - '@babel/helper-string-parser@7.25.9': {} - - '@babel/helper-validator-identifier@7.25.9': {} - - '@babel/helper-validator-option@7.25.9': {} - - '@babel/helpers@7.25.9': - dependencies: - '@babel/template': 7.25.9 - '@babel/types': 7.25.9 - - '@babel/highlight@7.25.9': - dependencies: - '@babel/helper-validator-identifier': 7.25.9 - chalk: 2.4.2 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/parser@7.25.9': - dependencies: - '@babel/types': 7.25.9 - - '@babel/plugin-proposal-class-properties@7.18.6(@babel/core@7.25.9)': - dependencies: - '@babel/core': 7.25.9 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.25.9) - '@babel/helper-plugin-utils': 7.25.9 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-proposal-decorators@7.25.9(@babel/core@7.25.9)': - dependencies: - '@babel/core': 7.25.9 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.25.9) - '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-syntax-decorators': 7.25.9(@babel/core@7.25.9) - transitivePeerDependencies: - - supports-color - - '@babel/plugin-proposal-private-methods@7.18.6(@babel/core@7.25.9)': - dependencies: - '@babel/core': 7.25.9 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.25.9) - '@babel/helper-plugin-utils': 7.25.9 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-syntax-decorators@7.25.9(@babel/core@7.25.9)': - dependencies: - '@babel/core': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 - - '@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.25.9)': - dependencies: - '@babel/core': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 - - '@babel/plugin-syntax-typescript@7.25.9(@babel/core@7.25.9)': - dependencies: - '@babel/core': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 - - '@babel/plugin-transform-modules-commonjs@7.25.9(@babel/core@7.25.9)': - dependencies: - '@babel/core': 7.25.9 - '@babel/helper-module-transforms': 7.25.9(@babel/core@7.25.9) - '@babel/helper-plugin-utils': 7.25.9 - '@babel/helper-simple-access': 7.25.9 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-react-jsx-self@7.25.9(@babel/core@7.25.9)': - dependencies: - '@babel/core': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 - - '@babel/plugin-transform-react-jsx-source@7.25.9(@babel/core@7.25.9)': - dependencies: - '@babel/core': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 - - '@babel/plugin-transform-typescript@7.25.9(@babel/core@7.25.9)': - dependencies: - '@babel/core': 7.25.9 - '@babel/helper-annotate-as-pure': 7.25.9 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.25.9) - '@babel/helper-plugin-utils': 7.25.9 - '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 - '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.25.9) - transitivePeerDependencies: - - supports-color - - '@babel/preset-typescript@7.25.9(@babel/core@7.25.9)': - dependencies: - '@babel/core': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/helper-validator-option': 7.25.9 - '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.25.9) - '@babel/plugin-transform-modules-commonjs': 7.25.9(@babel/core@7.25.9) - '@babel/plugin-transform-typescript': 7.25.9(@babel/core@7.25.9) - transitivePeerDependencies: - - supports-color - - '@babel/runtime@7.25.6': - dependencies: - regenerator-runtime: 0.14.1 - - '@babel/template@7.25.9': - dependencies: - '@babel/code-frame': 7.25.9 - '@babel/parser': 7.25.9 - '@babel/types': 7.25.9 - - '@babel/traverse@7.25.9': - dependencies: - '@babel/code-frame': 7.25.9 - '@babel/generator': 7.25.9 - '@babel/parser': 7.25.9 - '@babel/template': 7.25.9 - '@babel/types': 7.25.9 - debug: 4.3.6 - globals: 11.12.0 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.25.9': - dependencies: - '@babel/helper-string-parser': 7.25.9 - '@babel/helper-validator-identifier': 7.25.9 - - '@biomejs/biome@1.9.4': - optionalDependencies: - '@biomejs/cli-darwin-arm64': 1.9.4 - '@biomejs/cli-darwin-x64': 1.9.4 - '@biomejs/cli-linux-arm64': 1.9.4 - '@biomejs/cli-linux-arm64-musl': 1.9.4 - '@biomejs/cli-linux-x64': 1.9.4 - '@biomejs/cli-linux-x64-musl': 1.9.4 - '@biomejs/cli-win32-arm64': 1.9.4 - '@biomejs/cli-win32-x64': 1.9.4 - - '@biomejs/cli-darwin-arm64@1.9.4': - optional: true - - '@biomejs/cli-darwin-x64@1.9.4': - optional: true - - '@biomejs/cli-linux-arm64-musl@1.9.4': - optional: true - - '@biomejs/cli-linux-arm64@1.9.4': - optional: true - - '@biomejs/cli-linux-x64-musl@1.9.4': - optional: true - - '@biomejs/cli-linux-x64@1.9.4': - optional: true - - '@biomejs/cli-win32-arm64@1.9.4': - optional: true - - '@biomejs/cli-win32-x64@1.9.4': - optional: true - - '@braintree/sanitize-url@7.1.0': {} - - '@bufbuild/protobuf@1.10.0': {} - - '@chevrotain/cst-dts-gen@11.0.3': - dependencies: - '@chevrotain/gast': 11.0.3 - '@chevrotain/types': 11.0.3 - lodash-es: 4.17.21 - - '@chevrotain/gast@11.0.3': - dependencies: - '@chevrotain/types': 11.0.3 - lodash-es: 4.17.21 - - '@chevrotain/regexp-to-ast@11.0.3': {} - - '@chevrotain/types@11.0.3': {} - - '@chevrotain/utils@11.0.3': {} - - '@choojs/findup@0.2.1': - dependencies: - commander: 2.20.3 - - '@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1)': - dependencies: - '@codemirror/language': 6.10.3 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - '@lezer/common': 1.2.1 - - '@codemirror/commands@6.7.1': - dependencies: - '@codemirror/language': 6.10.3 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - '@lezer/common': 1.2.1 - - '@codemirror/lang-angular@0.1.2': - dependencies: - '@codemirror/lang-html': 6.4.7 - '@codemirror/lang-javascript': 6.2.1 - '@codemirror/language': 6.10.3 - '@lezer/common': 1.2.1 - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - '@codemirror/lang-cpp@6.0.2': - dependencies: - '@codemirror/language': 6.10.3 - '@lezer/cpp': 1.1.1 - - '@codemirror/lang-css@6.2.1(@codemirror/view@6.34.3)': - dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1) - '@codemirror/language': 6.10.3 - '@codemirror/state': 6.4.1 - '@lezer/common': 1.2.1 - '@lezer/css': 1.1.4 - transitivePeerDependencies: - - '@codemirror/view' - - '@codemirror/lang-go@6.0.0(@codemirror/view@6.34.3)': - dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1) - '@codemirror/language': 6.10.3 - '@codemirror/state': 6.4.1 - '@lezer/common': 1.2.1 - '@lezer/go': 1.0.0 - transitivePeerDependencies: - - '@codemirror/view' - - '@codemirror/lang-html@6.4.7': - dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1) - '@codemirror/lang-css': 6.2.1(@codemirror/view@6.34.3) - '@codemirror/lang-javascript': 6.2.1 - '@codemirror/language': 6.10.3 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - '@lezer/common': 1.2.1 - '@lezer/css': 1.1.4 - '@lezer/html': 1.3.7 - - '@codemirror/lang-java@6.0.1': - dependencies: - '@codemirror/language': 6.10.3 - '@lezer/java': 1.1.0 - - '@codemirror/lang-javascript@6.2.1': - dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1) - '@codemirror/language': 6.10.3 - '@codemirror/lint': 6.8.2 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - '@lezer/common': 1.2.1 - '@lezer/javascript': 1.4.18 - - '@codemirror/lang-json@6.0.1': - dependencies: - '@codemirror/language': 6.10.3 - '@lezer/json': 1.0.1 - - '@codemirror/lang-less@6.0.1(@codemirror/view@6.34.3)': - dependencies: - '@codemirror/lang-css': 6.2.1(@codemirror/view@6.34.3) - '@codemirror/language': 6.10.3 - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - transitivePeerDependencies: - - '@codemirror/view' - - '@codemirror/lang-lezer@6.0.1': - dependencies: - '@codemirror/language': 6.10.3 - '@codemirror/state': 6.4.1 - '@lezer/common': 1.2.1 - '@lezer/lezer': 1.1.2 - - '@codemirror/lang-liquid@6.2.0': - dependencies: - '@codemirror/lang-html': 6.4.7 - '@codemirror/language': 6.10.3 - '@lezer/common': 1.2.1 - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - '@codemirror/lang-markdown@6.3.0': - dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1) - '@codemirror/lang-html': 6.4.7 - '@codemirror/language': 6.10.3 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - '@lezer/common': 1.2.1 - '@lezer/markdown': 1.1.1 - - '@codemirror/lang-php@6.0.1': - dependencies: - '@codemirror/lang-html': 6.4.7 - '@codemirror/language': 6.10.3 - '@codemirror/state': 6.4.1 - '@lezer/common': 1.2.1 - '@lezer/php': 1.0.1 - - '@codemirror/lang-python@6.1.6(@codemirror/view@6.34.3)': - dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1) - '@codemirror/language': 6.10.3 - '@codemirror/state': 6.4.1 - '@lezer/common': 1.2.1 - '@lezer/python': 1.1.7 - transitivePeerDependencies: - - '@codemirror/view' - - '@codemirror/lang-rust@6.0.1': - dependencies: - '@codemirror/language': 6.10.3 - '@lezer/rust': 1.0.1 - - '@codemirror/lang-sass@6.0.2(@codemirror/view@6.34.3)': - dependencies: - '@codemirror/lang-css': 6.2.1(@codemirror/view@6.34.3) - '@codemirror/language': 6.10.3 - '@codemirror/state': 6.4.1 - '@lezer/common': 1.2.1 - '@lezer/sass': 1.0.3 - transitivePeerDependencies: - - '@codemirror/view' - - '@codemirror/lang-sql@6.8.0(@codemirror/view@6.34.3)': - dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1) - '@codemirror/language': 6.10.3 - '@codemirror/state': 6.4.1 - '@lezer/common': 1.2.1 - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - transitivePeerDependencies: - - '@codemirror/view' - - '@codemirror/lang-vue@0.1.2': - dependencies: - '@codemirror/lang-html': 6.4.7 - '@codemirror/lang-javascript': 6.2.1 - '@codemirror/language': 6.10.3 - '@lezer/common': 1.2.1 - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - '@codemirror/lang-wast@6.0.1': - dependencies: - '@codemirror/language': 6.10.3 - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - '@codemirror/lang-xml@6.0.2(@codemirror/view@6.34.3)': - dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1) - '@codemirror/language': 6.10.3 - '@codemirror/state': 6.4.1 - '@lezer/common': 1.2.1 - '@lezer/xml': 1.0.3 - transitivePeerDependencies: - - '@codemirror/view' - - '@codemirror/lang-yaml@6.0.0(@codemirror/view@6.34.3)': - dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1) - '@codemirror/language': 6.10.3 - '@codemirror/state': 6.4.1 - '@lezer/common': 1.2.1 - '@lezer/yaml': 1.0.2 - transitivePeerDependencies: - - '@codemirror/view' - - '@codemirror/language-data@6.5.1(@codemirror/view@6.34.3)': - dependencies: - '@codemirror/lang-angular': 0.1.2 - '@codemirror/lang-cpp': 6.0.2 - '@codemirror/lang-css': 6.2.1(@codemirror/view@6.34.3) - '@codemirror/lang-go': 6.0.0(@codemirror/view@6.34.3) - '@codemirror/lang-html': 6.4.7 - '@codemirror/lang-java': 6.0.1 - '@codemirror/lang-javascript': 6.2.1 - '@codemirror/lang-json': 6.0.1 - '@codemirror/lang-less': 6.0.1(@codemirror/view@6.34.3) - '@codemirror/lang-liquid': 6.2.0 - '@codemirror/lang-markdown': 6.3.0 - '@codemirror/lang-php': 6.0.1 - '@codemirror/lang-python': 6.1.6(@codemirror/view@6.34.3) - '@codemirror/lang-rust': 6.0.1 - '@codemirror/lang-sass': 6.0.2(@codemirror/view@6.34.3) - '@codemirror/lang-sql': 6.8.0(@codemirror/view@6.34.3) - '@codemirror/lang-vue': 0.1.2 - '@codemirror/lang-wast': 6.0.1 - '@codemirror/lang-xml': 6.0.2(@codemirror/view@6.34.3) - '@codemirror/lang-yaml': 6.0.0(@codemirror/view@6.34.3) - '@codemirror/language': 6.10.3 - '@codemirror/legacy-modes': 6.4.1 - transitivePeerDependencies: - - '@codemirror/view' - - '@codemirror/language@6.10.3': - dependencies: - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - '@lezer/common': 1.2.1 - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - style-mod: 4.1.2 - - '@codemirror/legacy-modes@6.4.1': - dependencies: - '@codemirror/language': 6.10.3 - - '@codemirror/lint@6.8.2': - dependencies: - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - crelt: 1.0.6 - - '@codemirror/merge@6.6.0': - dependencies: - '@codemirror/language': 6.10.3 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - '@lezer/highlight': 1.2.1 - style-mod: 4.1.2 - - '@codemirror/search@6.5.7': - dependencies: - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - crelt: 1.0.6 - - '@codemirror/state@6.4.1': {} - - '@codemirror/theme-one-dark@6.1.2': - dependencies: - '@codemirror/language': 6.10.3 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - '@lezer/highlight': 1.2.1 - - '@codemirror/view@6.34.3': - dependencies: - '@codemirror/state': 6.4.1 - style-mod: 4.1.2 - w3c-keyname: 2.2.8 - - '@connectrpc/connect-web@1.4.0(@bufbuild/protobuf@1.10.0)(@connectrpc/connect@1.4.0(@bufbuild/protobuf@1.10.0))': - dependencies: - '@bufbuild/protobuf': 1.10.0 - '@connectrpc/connect': 1.4.0(@bufbuild/protobuf@1.10.0) - - '@connectrpc/connect@1.4.0(@bufbuild/protobuf@1.10.0)': - dependencies: - '@bufbuild/protobuf': 1.10.0 - - '@csstools/css-parser-algorithms@3.0.2(@csstools/css-tokenizer@3.0.2)': - dependencies: - '@csstools/css-tokenizer': 3.0.2 - - '@csstools/css-tokenizer@3.0.2': {} - - '@csstools/media-query-list-parser@3.0.1(@csstools/css-parser-algorithms@3.0.2(@csstools/css-tokenizer@3.0.2))(@csstools/css-tokenizer@3.0.2)': - dependencies: - '@csstools/css-parser-algorithms': 3.0.2(@csstools/css-tokenizer@3.0.2) - '@csstools/css-tokenizer': 3.0.2 - - '@csstools/postcss-light-dark-function@2.0.5(postcss@8.4.49)': - dependencies: - '@csstools/css-parser-algorithms': 3.0.2(@csstools/css-tokenizer@3.0.2) - '@csstools/css-tokenizer': 3.0.2 - '@csstools/postcss-progressive-custom-properties': 4.0.0(postcss@8.4.49) - '@csstools/utilities': 2.0.0(postcss@8.4.49) - postcss: 8.4.49 - - '@csstools/postcss-progressive-custom-properties@4.0.0(postcss@8.4.49)': - dependencies: - postcss: 8.4.49 - postcss-value-parser: 4.2.0 - - '@csstools/selector-specificity@4.0.0(postcss-selector-parser@6.1.2)': - dependencies: - postcss-selector-parser: 6.1.2 - - '@csstools/utilities@2.0.0(postcss@8.4.49)': - dependencies: - postcss: 8.4.49 - - '@dagrejs/dagre@1.1.4': - dependencies: - '@dagrejs/graphlib': 2.2.4 - - '@dagrejs/graphlib@2.2.4': {} - - '@dnd-kit/accessibility@3.1.1(react@18.3.1)': - dependencies: - react: 18.3.1 - tslib: 2.8.1 - - '@dnd-kit/core@6.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@dnd-kit/accessibility': 3.1.1(react@18.3.1) - '@dnd-kit/utilities': 3.2.2(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - tslib: 2.8.1 - - '@dnd-kit/modifiers@8.0.0(@dnd-kit/core@6.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': - dependencies: - '@dnd-kit/core': 6.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@dnd-kit/utilities': 3.2.2(react@18.3.1) - react: 18.3.1 - tslib: 2.8.1 - - '@dnd-kit/sortable@9.0.0(@dnd-kit/core@6.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': - dependencies: - '@dnd-kit/core': 6.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@dnd-kit/utilities': 3.2.2(react@18.3.1) - react: 18.3.1 - tslib: 2.8.1 - - '@dnd-kit/utilities@3.2.2(react@18.3.1)': - dependencies: - react: 18.3.1 - tslib: 2.8.1 - - '@dual-bundle/import-meta-resolve@4.1.0': {} - - '@emotion/babel-plugin@11.13.5': - dependencies: - '@babel/helper-module-imports': 7.25.9 - '@babel/runtime': 7.25.6 - '@emotion/hash': 0.9.2 - '@emotion/memoize': 0.9.0 - '@emotion/serialize': 1.3.3 - babel-plugin-macros: 3.1.0 - convert-source-map: 1.9.0 - escape-string-regexp: 4.0.0 - find-root: 1.1.0 - source-map: 0.5.7 - stylis: 4.2.0 - transitivePeerDependencies: - - supports-color - - '@emotion/cache@11.13.5': - dependencies: - '@emotion/memoize': 0.9.0 - '@emotion/sheet': 1.4.0 - '@emotion/utils': 1.4.2 - '@emotion/weak-memoize': 0.4.0 - stylis: 4.2.0 - - '@emotion/hash@0.9.2': {} - - '@emotion/is-prop-valid@1.3.1': - dependencies: - '@emotion/memoize': 0.9.0 - - '@emotion/memoize@0.9.0': {} - - '@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@emotion/babel-plugin': 11.13.5 - '@emotion/cache': 11.13.5 - '@emotion/serialize': 1.3.3 - '@emotion/use-insertion-effect-with-fallbacks': 1.1.0(react@18.3.1) - '@emotion/utils': 1.4.2 - '@emotion/weak-memoize': 0.4.0 - hoist-non-react-statics: 3.3.2 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - transitivePeerDependencies: - - supports-color - - '@emotion/serialize@1.3.3': - dependencies: - '@emotion/hash': 0.9.2 - '@emotion/memoize': 0.9.0 - '@emotion/unitless': 0.10.0 - '@emotion/utils': 1.4.2 - csstype: 3.1.3 - - '@emotion/sheet@1.4.0': {} - - '@emotion/styled@11.13.0(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@emotion/babel-plugin': 11.13.5 - '@emotion/is-prop-valid': 1.3.1 - '@emotion/react': 11.13.0(@types/react@18.3.12)(react@18.3.1) - '@emotion/serialize': 1.3.3 - '@emotion/use-insertion-effect-with-fallbacks': 1.1.0(react@18.3.1) - '@emotion/utils': 1.4.2 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - transitivePeerDependencies: - - supports-color - - '@emotion/unitless@0.10.0': {} - - '@emotion/use-insertion-effect-with-fallbacks@1.1.0(react@18.3.1)': - dependencies: - react: 18.3.1 - - '@emotion/utils@1.4.2': {} - - '@emotion/weak-memoize@0.4.0': {} - - '@esbuild/aix-ppc64@0.21.5': - optional: true - - '@esbuild/android-arm64@0.21.5': - optional: true - - '@esbuild/android-arm@0.21.5': - optional: true - - '@esbuild/android-x64@0.21.5': - optional: true - - '@esbuild/darwin-arm64@0.21.5': - optional: true - - '@esbuild/darwin-x64@0.21.5': - optional: true - - '@esbuild/freebsd-arm64@0.21.5': - optional: true - - '@esbuild/freebsd-x64@0.21.5': - optional: true - - '@esbuild/linux-arm64@0.21.5': - optional: true - - '@esbuild/linux-arm@0.21.5': - optional: true - - '@esbuild/linux-ia32@0.21.5': - optional: true - - '@esbuild/linux-loong64@0.21.5': - optional: true - - '@esbuild/linux-mips64el@0.21.5': - optional: true - - '@esbuild/linux-ppc64@0.21.5': - optional: true - - '@esbuild/linux-riscv64@0.21.5': - optional: true - - '@esbuild/linux-s390x@0.21.5': - optional: true - - '@esbuild/linux-x64@0.21.5': - optional: true - - '@esbuild/netbsd-x64@0.21.5': - optional: true - - '@esbuild/openbsd-x64@0.21.5': - optional: true - - '@esbuild/sunos-x64@0.21.5': - optional: true - - '@esbuild/win32-arm64@0.21.5': - optional: true - - '@esbuild/win32-ia32@0.21.5': - optional: true - - '@esbuild/win32-x64@0.21.5': - optional: true - - '@eslint-community/eslint-utils@4.4.0(eslint@8.57.0)': - dependencies: - eslint: 8.57.0 - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.10.0': {} - - '@eslint/eslintrc@2.1.4': - dependencies: - ajv: 6.12.6 - debug: 4.3.6 - espree: 9.6.1 - globals: 13.20.0 - ignore: 5.3.2 - import-fresh: 3.3.0 - js-yaml: 4.1.0 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/eslintrc@3.1.0': - dependencies: - ajv: 6.12.6 - debug: 4.3.6 - espree: 10.1.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.0 - js-yaml: 4.1.0 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@8.57.0': {} - - '@floating-ui/core@1.6.0': - dependencies: - '@floating-ui/utils': 0.2.1 - - '@floating-ui/dom@1.6.3': - dependencies: - '@floating-ui/core': 1.6.0 - '@floating-ui/utils': 0.2.1 - - '@floating-ui/react-dom@2.0.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@floating-ui/dom': 1.6.3 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@floating-ui/utils@0.2.1': {} - - '@formatjs/ecma402-abstract@1.18.2': - dependencies: - '@formatjs/intl-localematcher': 0.5.4 - tslib: 2.8.1 - - '@formatjs/fast-memoize@2.2.0': - dependencies: - tslib: 2.8.1 - - '@formatjs/icu-messageformat-parser@2.7.6': - dependencies: - '@formatjs/ecma402-abstract': 1.18.2 - '@formatjs/icu-skeleton-parser': 1.8.0 - tslib: 2.8.1 - - '@formatjs/icu-skeleton-parser@1.8.0': - dependencies: - '@formatjs/ecma402-abstract': 1.18.2 - tslib: 2.8.1 - - '@formatjs/intl-localematcher@0.5.4': - dependencies: - tslib: 2.8.1 - - '@hookform/resolvers@3.9.0(react-hook-form@7.52.2(react@18.3.1))': - dependencies: - react-hook-form: 7.52.2(react@18.3.1) - - '@humanwhocodes/config-array@0.11.14': - dependencies: - '@humanwhocodes/object-schema': 2.0.3 - debug: 4.3.6 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/object-schema@2.0.3': {} - - '@iconify/types@2.0.0': {} - - '@iconify/utils@2.1.33': - dependencies: - '@antfu/install-pkg': 0.4.1 - '@antfu/utils': 0.7.10 - '@iconify/types': 2.0.0 - debug: 4.3.6 - kolorist: 1.8.0 - local-pkg: 0.5.0 - mlly: 1.7.2 - transitivePeerDependencies: - - supports-color - - '@internationalized/date@3.5.5': - dependencies: - '@swc/helpers': 0.5.1 - - '@internationalized/message@3.1.4': - dependencies: - '@swc/helpers': 0.5.1 - intl-messageformat: 10.5.11 - - '@internationalized/number@3.5.3': - dependencies: - '@swc/helpers': 0.5.1 - - '@internationalized/string@3.2.3': - dependencies: - '@swc/helpers': 0.5.1 - - '@jest/schemas@29.6.3': - dependencies: - '@sinclair/typebox': 0.27.8 - - '@jest/types@24.9.0': - dependencies: - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-reports': 1.1.2 - '@types/yargs': 13.0.12 - - '@joshwooding/vite-plugin-react-docgen-typescript@0.4.2(typescript@5.5.4)(vite@5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0))': - dependencies: - magic-string: 0.27.0 - react-docgen-typescript: 2.2.2(typescript@5.5.4) - vite: 5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0) - optionalDependencies: - typescript: 5.5.4 - - '@jridgewell/gen-mapping@0.3.5': - dependencies: - '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.25 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/set-array@1.2.1': {} - - '@jridgewell/source-map@0.3.6': - dependencies: - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 - - '@jridgewell/sourcemap-codec@1.5.0': {} - - '@jridgewell/trace-mapping@0.3.25': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 - - '@juggle/resize-observer@3.4.0': {} - - '@lezer/common@1.2.1': {} - - '@lezer/cpp@1.1.1': - dependencies: - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - '@lezer/css@1.1.4': - dependencies: - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - '@lezer/go@1.0.0': - dependencies: - '@lezer/common': 1.2.1 - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - '@lezer/highlight@1.2.1': - dependencies: - '@lezer/common': 1.2.1 - - '@lezer/html@1.3.7': - dependencies: - '@lezer/common': 1.2.1 - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - '@lezer/java@1.1.0': - dependencies: - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - '@lezer/javascript@1.4.18': - dependencies: - '@lezer/common': 1.2.1 - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - '@lezer/json@1.0.1': - dependencies: - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - '@lezer/lezer@1.1.2': - dependencies: - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - '@lezer/lr@1.4.2': - dependencies: - '@lezer/common': 1.2.1 - - '@lezer/markdown@1.1.1': - dependencies: - '@lezer/common': 1.2.1 - '@lezer/highlight': 1.2.1 - - '@lezer/php@1.0.1': - dependencies: - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - '@lezer/python@1.1.7': - dependencies: - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - '@lezer/rust@1.0.1': - dependencies: - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - '@lezer/sass@1.0.3': - dependencies: - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - '@lezer/xml@1.0.3': - dependencies: - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - '@lezer/yaml@1.0.2': - dependencies: - '@lezer/common': 1.2.1 - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - '@mapbox/geojson-rewind@0.5.2': - dependencies: - get-stream: 6.0.1 - minimist: 1.2.8 - - '@mapbox/geojson-types@1.0.2': {} - - '@mapbox/jsonlint-lines-primitives@2.0.2': {} - - '@mapbox/mapbox-gl-supported@1.5.0(mapbox-gl@1.13.3)': - dependencies: - mapbox-gl: 1.13.3 - - '@mapbox/point-geometry@0.1.0': {} - - '@mapbox/tiny-sdf@1.2.5': {} - - '@mapbox/tiny-sdf@2.0.6': {} - - '@mapbox/unitbezier@0.0.0': {} - - '@mapbox/unitbezier@0.0.1': {} - - '@mapbox/vector-tile@1.3.1': - dependencies: - '@mapbox/point-geometry': 0.1.0 - - '@mapbox/whoots-js@3.1.0': {} - - '@maplibre/maplibre-gl-style-spec@20.4.0': - dependencies: - '@mapbox/jsonlint-lines-primitives': 2.0.2 - '@mapbox/unitbezier': 0.0.1 - json-stringify-pretty-compact: 4.0.0 - minimist: 1.2.8 - quickselect: 2.0.0 - rw: 1.3.3 - tinyqueue: 3.0.0 - - '@marimo-team/marimo-api@file:../openapi': - dependencies: - openapi-fetch: 0.9.7 - - '@marimo-team/react-slotz@0.1.8(react@18.3.1)': - dependencies: - mitt: 3.0.1 - react: 18.3.1 - - '@mdx-js/react@3.0.1(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@types/mdx': 2.0.5 - '@types/react': 18.3.12 - react: 18.3.1 - - '@mermaid-js/parser@0.3.0': - dependencies: - langium: 3.0.0 - - '@mui/core-downloads-tracker@5.16.7': {} - - '@mui/material@5.16.7(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@mui/core-downloads-tracker': 5.16.7 - '@mui/system': 5.16.7(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) - '@mui/types': 7.2.19(@types/react@18.3.12) - '@mui/utils': 5.16.6(@types/react@18.3.12)(react@18.3.1) - '@popperjs/core': 2.11.8 - '@types/react-transition-group': 4.4.11 - clsx: 2.1.1 - csstype: 3.1.3 - prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-is: 18.3.1 - react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - optionalDependencies: - '@emotion/react': 11.13.0(@types/react@18.3.12)(react@18.3.1) - '@emotion/styled': 11.13.0(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) - '@types/react': 18.3.12 - - '@mui/private-theming@5.16.6(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@mui/utils': 5.16.6(@types/react@18.3.12)(react@18.3.1) - prop-types: 15.8.1 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@mui/styled-engine@5.16.6(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@emotion/cache': 11.13.5 - csstype: 3.1.3 - prop-types: 15.8.1 - react: 18.3.1 - optionalDependencies: - '@emotion/react': 11.13.0(@types/react@18.3.12)(react@18.3.1) - '@emotion/styled': 11.13.0(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) - - '@mui/system@5.16.7(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@mui/private-theming': 5.16.6(@types/react@18.3.12)(react@18.3.1) - '@mui/styled-engine': 5.16.6(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) - '@mui/types': 7.2.19(@types/react@18.3.12) - '@mui/utils': 5.16.6(@types/react@18.3.12)(react@18.3.1) - clsx: 2.1.1 - csstype: 3.1.3 - prop-types: 15.8.1 - react: 18.3.1 - optionalDependencies: - '@emotion/react': 11.13.0(@types/react@18.3.12)(react@18.3.1) - '@emotion/styled': 11.13.0(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) - '@types/react': 18.3.12 - - '@mui/types@7.2.19(@types/react@18.3.12)': - optionalDependencies: - '@types/react': 18.3.12 - - '@mui/utils@5.16.6(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@mui/types': 7.2.19(@types/react@18.3.12) - '@types/prop-types': 15.7.13 - clsx: 2.1.1 - prop-types: 15.8.1 - react: 18.3.1 - react-is: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@nextjournal/lang-clojure@1.0.0': - dependencies: - '@codemirror/language': 6.10.3 - '@nextjournal/lezer-clojure': 1.0.0 - - '@nextjournal/lezer-clojure@1.0.0': - dependencies: - '@lezer/lr': 1.4.2 - - '@noble/hashes@1.4.0': {} - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.13.0 - - '@open-rpc/client-js@1.8.1': - dependencies: - isomorphic-fetch: 3.0.0 - isomorphic-ws: 5.0.0(ws@7.5.9) - strict-event-emitter-types: 2.0.0 - ws: 7.5.9 - transitivePeerDependencies: - - bufferutil - - encoding - - utf-8-validate - - '@opentelemetry/api@1.9.0': {} - - '@paralleldrive/cuid2@2.2.2': - dependencies: - '@noble/hashes': 1.4.0 - - '@playwright/test@1.46.1': - dependencies: - playwright: 1.46.1 - - '@plotly/d3-sankey-circular@0.33.1': - dependencies: - d3-array: 1.2.4 - d3-collection: 1.0.7 - d3-shape: 1.3.7 - elementary-circuits-directed-graph: 1.3.1 - - '@plotly/d3-sankey@0.7.2': - dependencies: - d3-array: 1.2.4 - d3-collection: 1.0.7 - d3-shape: 1.3.7 - - '@plotly/d3@3.8.2': {} - - '@plotly/mapbox-gl@1.13.4(mapbox-gl@1.13.3)': - dependencies: - '@mapbox/geojson-rewind': 0.5.2 - '@mapbox/geojson-types': 1.0.2 - '@mapbox/jsonlint-lines-primitives': 2.0.2 - '@mapbox/mapbox-gl-supported': 1.5.0(mapbox-gl@1.13.3) - '@mapbox/point-geometry': 0.1.0 - '@mapbox/tiny-sdf': 1.2.5 - '@mapbox/unitbezier': 0.0.0 - '@mapbox/vector-tile': 1.3.1 - '@mapbox/whoots-js': 3.1.0 - csscolorparser: 1.0.3 - earcut: 2.2.4 - geojson-vt: 3.2.1 - gl-matrix: 3.4.3 - grid-index: 1.1.0 - murmurhash-js: 1.0.0 - pbf: 3.3.0 - potpack: 1.0.2 - quickselect: 2.0.0 - rw: 1.3.3 - supercluster: 7.1.5 - tinyqueue: 2.0.3 - vt-pbf: 3.1.3 - transitivePeerDependencies: - - mapbox-gl - - '@plotly/point-cluster@3.1.9': - dependencies: - array-bounds: 1.0.1 - binary-search-bounds: 2.0.5 - clamp: 1.0.1 - defined: 1.0.1 - dtype: 2.0.0 - flatten-vertex-data: 1.0.2 - is-obj: 1.0.1 - math-log2: 1.0.1 - parse-rect: 1.2.0 - pick-by-alias: 1.2.0 - - '@plotly/regl@2.1.2': {} - - '@popperjs/core@2.11.8': {} - - '@radix-ui/colors@3.0.0': {} - - '@radix-ui/number@1.0.1': - dependencies: - '@babel/runtime': 7.25.6 - - '@radix-ui/number@1.1.0': {} - - '@radix-ui/primitive@1.0.1': - dependencies: - '@babel/runtime': 7.25.6 - - '@radix-ui/primitive@1.1.0': {} - - '@radix-ui/react-accordion@1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-collapsible': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-direction': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-id': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-alert-dialog@1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-dialog': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.0.2(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-arrow@1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-checkbox@1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-previous': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-size': 1.0.1(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-collapsible@1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-id': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-collection@1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.0.2(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-compose-refs@1.0.1(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-compose-refs@1.1.0(@types/react@18.3.12)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-context-menu@2.1.5(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-menu': 2.0.6(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-context@1.0.1(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-context@1.1.1(@types/react@18.3.12)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-dialog@1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.0.2(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.12)(react@18.3.1) - aria-hidden: 1.1.3 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.5.5(@types/react@18.3.12)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-direction@1.0.1(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-direction@1.1.0(@types/react@18.3.12)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-dismissable-layer@1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-escape-keydown': 1.0.3(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-dropdown-menu@2.0.6(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-id': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-menu': 2.0.6(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-focus-guards@1.0.1(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-focus-scope@1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-icons@1.3.2(react@18.3.1)': - dependencies: - react: 18.3.1 - - '@radix-ui/react-id@1.0.1(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-label@2.0.2(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-menu@2.0.6(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-direction': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-popper': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.0.2(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.12)(react@18.3.1) - aria-hidden: 1.1.3 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.5.5(@types/react@18.3.12)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-navigation-menu@1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-direction': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-previous': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-popover@1.0.7(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-popper': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.0.2(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.12)(react@18.3.1) - aria-hidden: 1.1.3 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.5.5(@types/react@18.3.12)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-popper@1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@floating-ui/react-dom': 2.0.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-arrow': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-rect': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-size': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/rect': 1.0.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-portal@1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-presence@1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-presence@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-primitive@1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/react-slot': 1.0.2(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-primitive@2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-slot': 1.1.0(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-progress@1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-radio-group@1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-direction': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-previous': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-size': 1.0.1(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-direction': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-id': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-scroll-area@1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/number': 1.1.0 - '@radix-ui/primitive': 1.1.0 - '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-direction': 1.1.0(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-presence': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-select@2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/number': 1.0.1 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-direction': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-popper': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.0.2(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-previous': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - aria-hidden: 1.1.3 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.5.5(@types/react@18.3.12)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-slider@1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/number': 1.0.1 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-direction': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-previous': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-size': 1.0.1(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-slot@1.0.2(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-slot@1.1.0(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-switch@1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-previous': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-size': 1.0.1(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-tabs@1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-direction': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-id': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-toast@1.1.5(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-toggle@1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-tooltip@1.0.7(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-popper': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.0.2(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.12)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-use-controllable-state@1.0.1(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-use-escape-keydown@1.0.3(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-use-layout-effect@1.0.1(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-use-layout-effect@1.1.0(@types/react@18.3.12)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-use-previous@1.0.1(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-use-rect@1.0.1(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/rect': 1.0.1 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-use-size@1.0.1(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-visually-hidden@1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@radix-ui/rect@1.0.1': - dependencies: - '@babel/runtime': 7.25.6 - - '@react-aria/breadcrumbs@3.5.16(react@18.3.1)': - dependencies: - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/link': 3.7.4(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-types/breadcrumbs': 3.7.7(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-aria/button@3.9.8(react@18.3.1)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/toggle': 3.7.7(react@18.3.1) - '@react-types/button': 3.9.6(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-aria/calendar@3.5.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@internationalized/date': 3.5.5 - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/live-announcer': 3.3.4 - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/calendar': 3.5.4(react@18.3.1) - '@react-types/button': 3.9.6(react@18.3.1) - '@react-types/calendar': 3.4.9(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-aria/checkbox@3.14.6(react@18.3.1)': - dependencies: - '@react-aria/form': 3.0.8(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/label': 3.7.11(react@18.3.1) - '@react-aria/toggle': 3.10.7(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/checkbox': 3.6.8(react@18.3.1) - '@react-stately/form': 3.0.5(react@18.3.1) - '@react-stately/toggle': 3.7.7(react@18.3.1) - '@react-types/checkbox': 3.8.3(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-aria/collections@3.0.0-alpha.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@react-aria/ssr': 3.9.5(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - use-sync-external-store: 1.2.0(react@18.3.1) - - '@react-aria/color@3.0.0-rc.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/numberfield': 3.11.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/slider': 3.7.11(react@18.3.1) - '@react-aria/spinbutton': 3.6.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/textfield': 3.14.8(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-aria/visually-hidden': 3.8.15(react@18.3.1) - '@react-stately/color': 3.7.2(react@18.3.1) - '@react-stately/form': 3.0.5(react@18.3.1) - '@react-types/color': 3.0.0-rc.1(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-aria/combobox@3.10.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/listbox': 3.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/live-announcer': 3.3.4 - '@react-aria/menu': 3.15.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/overlays': 3.23.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/selection': 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/textfield': 3.14.8(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/collections': 3.10.9(react@18.3.1) - '@react-stately/combobox': 3.9.2(react@18.3.1) - '@react-stately/form': 3.0.5(react@18.3.1) - '@react-types/button': 3.9.6(react@18.3.1) - '@react-types/combobox': 3.12.1(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-aria/datepicker@3.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@internationalized/date': 3.5.5 - '@internationalized/number': 3.5.3 - '@internationalized/string': 3.2.3 - '@react-aria/focus': 3.18.2(react@18.3.1) - '@react-aria/form': 3.0.8(react@18.3.1) - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/label': 3.7.11(react@18.3.1) - '@react-aria/spinbutton': 3.6.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/datepicker': 3.10.2(react@18.3.1) - '@react-stately/form': 3.0.5(react@18.3.1) - '@react-types/button': 3.9.6(react@18.3.1) - '@react-types/calendar': 3.4.9(react@18.3.1) - '@react-types/datepicker': 3.8.2(react@18.3.1) - '@react-types/dialog': 3.5.12(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-aria/dialog@3.5.17(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.3.1) - '@react-aria/overlays': 3.23.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-types/dialog': 3.5.12(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-aria/dnd@3.7.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@internationalized/string': 3.2.3 - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/live-announcer': 3.3.4 - '@react-aria/overlays': 3.23.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/dnd': 3.4.2(react@18.3.1) - '@react-types/button': 3.9.6(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-aria/focus@3.18.2(react@18.3.1)': - dependencies: - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - clsx: 2.1.1 - react: 18.3.1 - - '@react-aria/form@3.0.8(react@18.3.1)': - dependencies: - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/form': 3.0.5(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-aria/grid@3.10.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.3.1) - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/live-announcer': 3.3.4 - '@react-aria/selection': 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/collections': 3.10.9(react@18.3.1) - '@react-stately/grid': 3.9.2(react@18.3.1) - '@react-stately/selection': 3.16.2(react@18.3.1) - '@react-types/checkbox': 3.8.3(react@18.3.1) - '@react-types/grid': 3.2.8(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-aria/gridlist@3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.3.1) - '@react-aria/grid': 3.10.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/selection': 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/collections': 3.10.9(react@18.3.1) - '@react-stately/list': 3.10.8(react@18.3.1) - '@react-stately/tree': 3.8.4(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-aria/i18n@3.12.2(react@18.3.1)': - dependencies: - '@internationalized/date': 3.5.5 - '@internationalized/message': 3.1.4 - '@internationalized/number': 3.5.3 - '@internationalized/string': 3.2.3 - '@react-aria/ssr': 3.9.5(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-aria/interactions@3.22.2(react@18.3.1)': - dependencies: - '@react-aria/ssr': 3.9.5(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-aria/label@3.7.11(react@18.3.1)': - dependencies: - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-aria/link@3.7.4(react@18.3.1)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-types/link': 3.5.7(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-aria/listbox@3.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/label': 3.7.11(react@18.3.1) - '@react-aria/selection': 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/collections': 3.10.9(react@18.3.1) - '@react-stately/list': 3.10.8(react@18.3.1) - '@react-types/listbox': 3.5.1(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-aria/live-announcer@3.3.4': - dependencies: - '@swc/helpers': 0.5.1 - - '@react-aria/menu@3.15.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.3.1) - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/overlays': 3.23.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/selection': 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/collections': 3.10.9(react@18.3.1) - '@react-stately/menu': 3.8.2(react@18.3.1) - '@react-stately/tree': 3.8.4(react@18.3.1) - '@react-types/button': 3.9.6(react@18.3.1) - '@react-types/menu': 3.9.11(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-aria/meter@3.4.16(react@18.3.1)': - dependencies: - '@react-aria/progress': 3.4.16(react@18.3.1) - '@react-types/meter': 3.4.3(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-aria/numberfield@3.11.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/spinbutton': 3.6.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/textfield': 3.14.8(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/form': 3.0.5(react@18.3.1) - '@react-stately/numberfield': 3.9.6(react@18.3.1) - '@react-types/button': 3.9.6(react@18.3.1) - '@react-types/numberfield': 3.8.5(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-aria/overlays@3.23.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.3.1) - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/ssr': 3.9.5(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-aria/visually-hidden': 3.8.15(react@18.3.1) - '@react-stately/overlays': 3.6.10(react@18.3.1) - '@react-types/button': 3.9.6(react@18.3.1) - '@react-types/overlays': 3.8.9(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-aria/progress@3.4.16(react@18.3.1)': - dependencies: - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/label': 3.7.11(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-types/progress': 3.5.6(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-aria/radio@3.10.7(react@18.3.1)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.3.1) - '@react-aria/form': 3.0.8(react@18.3.1) - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/label': 3.7.11(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/radio': 3.10.7(react@18.3.1) - '@react-types/radio': 3.8.3(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-aria/searchfield@3.7.8(react@18.3.1)': - dependencies: - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/textfield': 3.14.8(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/searchfield': 3.5.6(react@18.3.1) - '@react-types/button': 3.9.6(react@18.3.1) - '@react-types/searchfield': 3.5.8(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-aria/select@3.14.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@react-aria/form': 3.0.8(react@18.3.1) - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/label': 3.7.11(react@18.3.1) - '@react-aria/listbox': 3.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/menu': 3.15.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/selection': 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-aria/visually-hidden': 3.8.15(react@18.3.1) - '@react-stately/select': 3.6.7(react@18.3.1) - '@react-types/button': 3.9.6(react@18.3.1) - '@react-types/select': 3.9.6(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-aria/selection@3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.3.1) - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/selection': 3.16.2(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-aria/separator@3.4.2(react@18.3.1)': - dependencies: - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-aria/slider@3.7.11(react@18.3.1)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.3.1) - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/label': 3.7.11(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/slider': 3.5.7(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@react-types/slider': 3.7.5(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-aria/spinbutton@3.6.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/live-announcer': 3.3.4 - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-types/button': 3.9.6(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-aria/ssr@3.9.5(react@18.3.1)': - dependencies: - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-aria/switch@3.6.7(react@18.3.1)': - dependencies: - '@react-aria/toggle': 3.10.7(react@18.3.1) - '@react-stately/toggle': 3.7.7(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@react-types/switch': 3.5.5(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-aria/table@3.15.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.3.1) - '@react-aria/grid': 3.10.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/live-announcer': 3.3.4 - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-aria/visually-hidden': 3.8.15(react@18.3.1) - '@react-stately/collections': 3.10.9(react@18.3.1) - '@react-stately/flags': 3.0.3 - '@react-stately/table': 3.12.2(react@18.3.1) - '@react-types/checkbox': 3.8.3(react@18.3.1) - '@react-types/grid': 3.2.8(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@react-types/table': 3.10.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-aria/tabs@3.9.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.3.1) - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/selection': 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/tabs': 3.6.9(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@react-types/tabs': 3.3.9(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-aria/tag@3.4.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@react-aria/gridlist': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/label': 3.7.11(react@18.3.1) - '@react-aria/selection': 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/list': 3.10.8(react@18.3.1) - '@react-types/button': 3.9.6(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-aria/textfield@3.14.8(react@18.3.1)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.3.1) - '@react-aria/form': 3.0.8(react@18.3.1) - '@react-aria/label': 3.7.11(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/form': 3.0.5(react@18.3.1) - '@react-stately/utils': 3.10.3(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@react-types/textfield': 3.9.6(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-aria/toggle@3.10.7(react@18.3.1)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/toggle': 3.7.7(react@18.3.1) - '@react-types/checkbox': 3.8.3(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-aria/toolbar@3.0.0-beta.8(react@18.3.1)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.3.1) - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-aria/tooltip@3.7.7(react@18.3.1)': - dependencies: - '@react-aria/focus': 3.18.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/tooltip': 3.4.12(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@react-types/tooltip': 3.4.11(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-aria/tree@3.0.0-alpha.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@react-aria/gridlist': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/selection': 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/tree': 3.8.4(react@18.3.1) - '@react-types/button': 3.9.6(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-aria/utils@3.25.2(react@18.3.1)': - dependencies: - '@react-aria/ssr': 3.9.5(react@18.3.1) - '@react-stately/utils': 3.10.3(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - clsx: 2.1.1 - react: 18.3.1 - - '@react-aria/virtualizer@4.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-stately/virtualizer': 4.0.2(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-aria/visually-hidden@3.8.15(react@18.3.1)': - dependencies: - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-dnd/asap@4.0.1': {} - - '@react-dnd/invariant@2.0.0': {} - - '@react-dnd/shallowequal@2.0.0': {} - - '@react-stately/calendar@3.5.4(react@18.3.1)': - dependencies: - '@internationalized/date': 3.5.5 - '@react-stately/utils': 3.10.3(react@18.3.1) - '@react-types/calendar': 3.4.9(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/checkbox@3.6.8(react@18.3.1)': - dependencies: - '@react-stately/form': 3.0.5(react@18.3.1) - '@react-stately/utils': 3.10.3(react@18.3.1) - '@react-types/checkbox': 3.8.3(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/collections@3.10.9(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/color@3.7.2(react@18.3.1)': - dependencies: - '@internationalized/number': 3.5.3 - '@internationalized/string': 3.2.3 - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-stately/form': 3.0.5(react@18.3.1) - '@react-stately/numberfield': 3.9.6(react@18.3.1) - '@react-stately/slider': 3.5.7(react@18.3.1) - '@react-stately/utils': 3.10.3(react@18.3.1) - '@react-types/color': 3.0.0-rc.1(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/combobox@3.9.2(react@18.3.1)': - dependencies: - '@react-stately/collections': 3.10.9(react@18.3.1) - '@react-stately/form': 3.0.5(react@18.3.1) - '@react-stately/list': 3.10.8(react@18.3.1) - '@react-stately/overlays': 3.6.10(react@18.3.1) - '@react-stately/select': 3.6.7(react@18.3.1) - '@react-stately/utils': 3.10.3(react@18.3.1) - '@react-types/combobox': 3.12.1(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/data@3.11.6(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/datepicker@3.10.2(react@18.3.1)': - dependencies: - '@internationalized/date': 3.5.5 - '@internationalized/string': 3.2.3 - '@react-stately/form': 3.0.5(react@18.3.1) - '@react-stately/overlays': 3.6.10(react@18.3.1) - '@react-stately/utils': 3.10.3(react@18.3.1) - '@react-types/datepicker': 3.8.2(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/dnd@3.4.2(react@18.3.1)': - dependencies: - '@react-stately/selection': 3.16.2(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/flags@3.0.3': - dependencies: - '@swc/helpers': 0.5.1 - - '@react-stately/form@3.0.5(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/grid@3.9.2(react@18.3.1)': - dependencies: - '@react-stately/collections': 3.10.9(react@18.3.1) - '@react-stately/selection': 3.16.2(react@18.3.1) - '@react-types/grid': 3.2.8(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/layout@4.0.2(react@18.3.1)': - dependencies: - '@react-stately/collections': 3.10.9(react@18.3.1) - '@react-stately/table': 3.12.2(react@18.3.1) - '@react-stately/virtualizer': 4.0.2(react@18.3.1) - '@react-types/grid': 3.2.8(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@react-types/table': 3.10.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/list@3.10.8(react@18.3.1)': - dependencies: - '@react-stately/collections': 3.10.9(react@18.3.1) - '@react-stately/selection': 3.16.2(react@18.3.1) - '@react-stately/utils': 3.10.3(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/menu@3.8.2(react@18.3.1)': - dependencies: - '@react-stately/overlays': 3.6.10(react@18.3.1) - '@react-types/menu': 3.9.11(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/numberfield@3.9.6(react@18.3.1)': - dependencies: - '@internationalized/number': 3.5.3 - '@react-stately/form': 3.0.5(react@18.3.1) - '@react-stately/utils': 3.10.3(react@18.3.1) - '@react-types/numberfield': 3.8.5(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/overlays@3.6.10(react@18.3.1)': - dependencies: - '@react-stately/utils': 3.10.3(react@18.3.1) - '@react-types/overlays': 3.8.9(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/radio@3.10.7(react@18.3.1)': - dependencies: - '@react-stately/form': 3.0.5(react@18.3.1) - '@react-stately/utils': 3.10.3(react@18.3.1) - '@react-types/radio': 3.8.3(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/searchfield@3.5.6(react@18.3.1)': - dependencies: - '@react-stately/utils': 3.10.3(react@18.3.1) - '@react-types/searchfield': 3.5.8(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/select@3.6.7(react@18.3.1)': - dependencies: - '@react-stately/form': 3.0.5(react@18.3.1) - '@react-stately/list': 3.10.8(react@18.3.1) - '@react-stately/overlays': 3.6.10(react@18.3.1) - '@react-types/select': 3.9.6(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/selection@3.16.2(react@18.3.1)': - dependencies: - '@react-stately/collections': 3.10.9(react@18.3.1) - '@react-stately/utils': 3.10.3(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/slider@3.5.7(react@18.3.1)': - dependencies: - '@react-stately/utils': 3.10.3(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@react-types/slider': 3.7.5(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/table@3.12.2(react@18.3.1)': - dependencies: - '@react-stately/collections': 3.10.9(react@18.3.1) - '@react-stately/flags': 3.0.3 - '@react-stately/grid': 3.9.2(react@18.3.1) - '@react-stately/selection': 3.16.2(react@18.3.1) - '@react-stately/utils': 3.10.3(react@18.3.1) - '@react-types/grid': 3.2.8(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@react-types/table': 3.10.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/tabs@3.6.9(react@18.3.1)': - dependencies: - '@react-stately/list': 3.10.8(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@react-types/tabs': 3.3.9(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/toggle@3.7.7(react@18.3.1)': - dependencies: - '@react-stately/utils': 3.10.3(react@18.3.1) - '@react-types/checkbox': 3.8.3(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/tooltip@3.4.12(react@18.3.1)': - dependencies: - '@react-stately/overlays': 3.6.10(react@18.3.1) - '@react-types/tooltip': 3.4.11(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/tree@3.8.4(react@18.3.1)': - dependencies: - '@react-stately/collections': 3.10.9(react@18.3.1) - '@react-stately/selection': 3.16.2(react@18.3.1) - '@react-stately/utils': 3.10.3(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/utils@3.10.3(react@18.3.1)': - dependencies: - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-stately/virtualizer@4.0.2(react@18.3.1)': - dependencies: - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.1 - react: 18.3.1 - - '@react-types/breadcrumbs@3.7.7(react@18.3.1)': - dependencies: - '@react-types/link': 3.5.7(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/button@3.9.6(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/calendar@3.4.9(react@18.3.1)': - dependencies: - '@internationalized/date': 3.5.5 - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/checkbox@3.8.3(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/color@3.0.0-rc.1(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - '@react-types/slider': 3.7.5(react@18.3.1) - react: 18.3.1 - - '@react-types/combobox@3.12.1(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/datepicker@3.8.2(react@18.3.1)': - dependencies: - '@internationalized/date': 3.5.5 - '@react-types/calendar': 3.4.9(react@18.3.1) - '@react-types/overlays': 3.8.9(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/dialog@3.5.12(react@18.3.1)': - dependencies: - '@react-types/overlays': 3.8.9(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/form@3.7.6(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/grid@3.2.8(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/link@3.5.7(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/listbox@3.5.1(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/menu@3.9.11(react@18.3.1)': - dependencies: - '@react-types/overlays': 3.8.9(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/meter@3.4.3(react@18.3.1)': - dependencies: - '@react-types/progress': 3.5.6(react@18.3.1) - react: 18.3.1 - - '@react-types/numberfield@3.8.5(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/overlays@3.8.9(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/progress@3.5.6(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/radio@3.8.3(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/searchfield@3.5.8(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - '@react-types/textfield': 3.9.6(react@18.3.1) - react: 18.3.1 - - '@react-types/select@3.9.6(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/shared@3.24.1(react@18.3.1)': - dependencies: - react: 18.3.1 - - '@react-types/slider@3.7.5(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/switch@3.5.5(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/table@3.10.1(react@18.3.1)': - dependencies: - '@react-types/grid': 3.2.8(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/tabs@3.3.9(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/textfield@3.9.6(react@18.3.1)': - dependencies: - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@react-types/tooltip@3.4.11(react@18.3.1)': - dependencies: - '@react-types/overlays': 3.8.9(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - '@reactflow/background@11.3.14(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@reactflow/core': 11.11.4(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - classcat: 5.0.4 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - zustand: 4.5.2(@types/react@18.3.12)(react@18.3.1) - transitivePeerDependencies: - - '@types/react' - - immer - - '@reactflow/controls@11.2.14(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@reactflow/core': 11.11.4(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - classcat: 5.0.4 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - zustand: 4.5.2(@types/react@18.3.12)(react@18.3.1) - transitivePeerDependencies: - - '@types/react' - - immer - - '@reactflow/core@11.11.4(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@types/d3': 7.4.3 - '@types/d3-drag': 3.0.7 - '@types/d3-selection': 3.0.10 - '@types/d3-zoom': 3.0.8 - classcat: 5.0.4 - d3-drag: 3.0.0 - d3-selection: 3.0.0 - d3-zoom: 3.0.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - zustand: 4.5.2(@types/react@18.3.12)(react@18.3.1) - transitivePeerDependencies: - - '@types/react' - - immer - - '@reactflow/minimap@11.7.14(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@reactflow/core': 11.11.4(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@types/d3-selection': 3.0.10 - '@types/d3-zoom': 3.0.8 - classcat: 5.0.4 - d3-selection: 3.0.0 - d3-zoom: 3.0.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - zustand: 4.5.2(@types/react@18.3.12)(react@18.3.1) - transitivePeerDependencies: - - '@types/react' - - immer - - '@reactflow/node-resizer@2.2.14(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@reactflow/core': 11.11.4(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - classcat: 5.0.4 - d3-drag: 3.0.0 - d3-selection: 3.0.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - zustand: 4.5.2(@types/react@18.3.12)(react@18.3.1) - transitivePeerDependencies: - - '@types/react' - - immer - - '@reactflow/node-toolbar@1.3.14(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@reactflow/core': 11.11.4(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - classcat: 5.0.4 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - zustand: 4.5.2(@types/react@18.3.12)(react@18.3.1) - transitivePeerDependencies: - - '@types/react' - - immer - - '@replit/codemirror-lang-csharp@6.2.0(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1))(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1)(@lezer/highlight@1.2.1)(@lezer/lr@1.4.2)': - dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1) - '@codemirror/language': 6.10.3 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - '@lezer/common': 1.2.1 - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - '@replit/codemirror-lang-nix@6.0.1(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1))(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1)(@lezer/highlight@1.2.1)(@lezer/lr@1.4.2)': - dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1) - '@codemirror/language': 6.10.3 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - '@lezer/common': 1.2.1 - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - '@replit/codemirror-lang-solidity@6.0.1(@codemirror/language@6.10.3)': - dependencies: - '@codemirror/language': 6.10.3 - - '@replit/codemirror-lang-svelte@6.0.0(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1))(@codemirror/lang-css@6.2.1(@codemirror/view@6.34.3))(@codemirror/lang-html@6.4.7)(@codemirror/lang-javascript@6.2.1)(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1)(@lezer/highlight@1.2.1)(@lezer/javascript@1.4.18)(@lezer/lr@1.4.2)': - dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1) - '@codemirror/lang-css': 6.2.1(@codemirror/view@6.34.3) - '@codemirror/lang-html': 6.4.7 - '@codemirror/lang-javascript': 6.2.1 - '@codemirror/language': 6.10.3 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - '@lezer/common': 1.2.1 - '@lezer/highlight': 1.2.1 - '@lezer/javascript': 1.4.18 - '@lezer/lr': 1.4.2 - - '@replit/codemirror-vim@6.2.1(@codemirror/commands@6.7.1)(@codemirror/language@6.10.3)(@codemirror/search@6.5.7)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)': - dependencies: - '@codemirror/commands': 6.7.1 - '@codemirror/language': 6.10.3 - '@codemirror/search': 6.5.7 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - - '@rollup/pluginutils@5.0.2': - dependencies: - '@types/estree': 1.0.6 - estree-walker: 2.0.2 - picomatch: 2.3.1 - - '@rollup/rollup-android-arm-eabi@4.21.3': - optional: true - - '@rollup/rollup-android-arm64@4.21.3': - optional: true - - '@rollup/rollup-darwin-arm64@4.21.3': - optional: true - - '@rollup/rollup-darwin-x64@4.21.3': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.21.3': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.21.3': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.21.3': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.21.3': - optional: true - - '@rollup/rollup-linux-powerpc64le-gnu@4.21.3': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.21.3': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.21.3': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.21.3': - optional: true - - '@rollup/rollup-linux-x64-musl@4.21.3': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.21.3': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.21.3': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.21.3': - optional: true - - '@sinclair/typebox@0.27.8': {} - - '@storybook/addon-actions@8.4.6(storybook@8.4.6)': - dependencies: - '@storybook/global': 5.0.0 - '@types/uuid': 9.0.7 - dequal: 2.0.3 - polished: 4.2.2 - storybook: 8.4.6 - uuid: 9.0.1 - - '@storybook/addon-backgrounds@8.4.6(storybook@8.4.6)': - dependencies: - '@storybook/global': 5.0.0 - memoizerific: 1.11.3 - storybook: 8.4.6 - ts-dedent: 2.2.0 - - '@storybook/addon-controls@8.4.6(storybook@8.4.6)': - dependencies: - '@storybook/global': 5.0.0 - dequal: 2.0.3 - storybook: 8.4.6 - ts-dedent: 2.2.0 - - '@storybook/addon-docs@8.4.6(@types/react@18.3.12)(storybook@8.4.6)': - dependencies: - '@mdx-js/react': 3.0.1(@types/react@18.3.12)(react@18.3.1) - '@storybook/blocks': 8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6) - '@storybook/csf-plugin': 8.4.6(storybook@8.4.6) - '@storybook/react-dom-shim': 8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - storybook: 8.4.6 - ts-dedent: 2.2.0 - transitivePeerDependencies: - - '@types/react' - - '@storybook/addon-essentials@8.4.6(@types/react@18.3.12)(storybook@8.4.6)': - dependencies: - '@storybook/addon-actions': 8.4.6(storybook@8.4.6) - '@storybook/addon-backgrounds': 8.4.6(storybook@8.4.6) - '@storybook/addon-controls': 8.4.6(storybook@8.4.6) - '@storybook/addon-docs': 8.4.6(@types/react@18.3.12)(storybook@8.4.6) - '@storybook/addon-highlight': 8.4.6(storybook@8.4.6) - '@storybook/addon-measure': 8.4.6(storybook@8.4.6) - '@storybook/addon-outline': 8.4.6(storybook@8.4.6) - '@storybook/addon-toolbars': 8.4.6(storybook@8.4.6) - '@storybook/addon-viewport': 8.4.6(storybook@8.4.6) - storybook: 8.4.6 - ts-dedent: 2.2.0 - transitivePeerDependencies: - - '@types/react' - - '@storybook/addon-highlight@8.4.6(storybook@8.4.6)': - dependencies: - '@storybook/global': 5.0.0 - storybook: 8.4.6 - - '@storybook/addon-interactions@8.4.6(storybook@8.4.6)': - dependencies: - '@storybook/global': 5.0.0 - '@storybook/instrumenter': 8.4.6(storybook@8.4.6) - '@storybook/test': 8.4.6(storybook@8.4.6) - polished: 4.2.2 - storybook: 8.4.6 - ts-dedent: 2.2.0 - - '@storybook/addon-links@8.4.6(react@18.3.1)(storybook@8.4.6)': - dependencies: - '@storybook/csf': 0.1.11 - '@storybook/global': 5.0.0 - storybook: 8.4.6 - ts-dedent: 2.2.0 - optionalDependencies: - react: 18.3.1 - - '@storybook/addon-measure@8.4.6(storybook@8.4.6)': - dependencies: - '@storybook/global': 5.0.0 - storybook: 8.4.6 - tiny-invariant: 1.3.3 - - '@storybook/addon-outline@8.4.6(storybook@8.4.6)': - dependencies: - '@storybook/global': 5.0.0 - storybook: 8.4.6 - ts-dedent: 2.2.0 - - '@storybook/addon-toolbars@8.4.6(storybook@8.4.6)': - dependencies: - storybook: 8.4.6 - - '@storybook/addon-viewport@8.4.6(storybook@8.4.6)': - dependencies: - memoizerific: 1.11.3 - storybook: 8.4.6 - - '@storybook/blocks@8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6)': - dependencies: - '@storybook/csf': 0.1.11 - '@storybook/icons': 1.2.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - storybook: 8.4.6 - ts-dedent: 2.2.0 - optionalDependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@storybook/builder-vite@8.4.6(storybook@8.4.6)(vite@5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0))': - dependencies: - '@storybook/csf-plugin': 8.4.6(storybook@8.4.6) - browser-assert: 1.2.1 - storybook: 8.4.6 - ts-dedent: 2.2.0 - vite: 5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0) - - '@storybook/components@8.4.6(storybook@8.4.6)': - dependencies: - storybook: 8.4.6 - - '@storybook/core@8.4.6': - dependencies: - '@storybook/csf': 0.1.11 - better-opn: 3.0.2 - browser-assert: 1.2.1 - esbuild: 0.21.5 - esbuild-register: 3.5.0(esbuild@0.21.5) - jsdoc-type-pratt-parser: 4.1.0 - process: 0.11.10 - recast: 0.23.6 - semver: 7.6.3 - util: 0.12.5 - ws: 8.18.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - '@storybook/csf-plugin@8.4.6(storybook@8.4.6)': - dependencies: - storybook: 8.4.6 - unplugin: 1.4.0 - - '@storybook/csf@0.1.11': - dependencies: - type-fest: 2.19.0 - - '@storybook/global@5.0.0': {} - - '@storybook/icons@1.2.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@storybook/instrumenter@8.4.6(storybook@8.4.6)': - dependencies: - '@storybook/global': 5.0.0 - '@vitest/utils': 2.1.3 - storybook: 8.4.6 - - '@storybook/manager-api@8.4.6(storybook@8.4.6)': - dependencies: - storybook: 8.4.6 - - '@storybook/preview-api@8.4.6(storybook@8.4.6)': - dependencies: - storybook: 8.4.6 - - '@storybook/react-dom-shim@8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6)': - dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - storybook: 8.4.6 - - '@storybook/react-vite@8.4.6(@storybook/test@8.4.6(storybook@8.4.6))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6)(typescript@5.5.4)(vite@5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0))': - dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.4.2(typescript@5.5.4)(vite@5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0)) - '@rollup/pluginutils': 5.0.2 - '@storybook/builder-vite': 8.4.6(storybook@8.4.6)(vite@5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0)) - '@storybook/react': 8.4.6(@storybook/test@8.4.6(storybook@8.4.6))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6)(typescript@5.5.4) - find-up: 5.0.0 - magic-string: 0.30.11 - react: 18.3.1 - react-docgen: 7.0.1 - react-dom: 18.3.1(react@18.3.1) - resolve: 1.22.8 - storybook: 8.4.6 - tsconfig-paths: 4.2.0 - vite: 5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0) - transitivePeerDependencies: - - '@storybook/test' - - rollup - - supports-color - - typescript - - '@storybook/react@8.4.6(@storybook/test@8.4.6(storybook@8.4.6))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6)(typescript@5.5.4)': - dependencies: - '@storybook/components': 8.4.6(storybook@8.4.6) - '@storybook/global': 5.0.0 - '@storybook/manager-api': 8.4.6(storybook@8.4.6) - '@storybook/preview-api': 8.4.6(storybook@8.4.6) - '@storybook/react-dom-shim': 8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6) - '@storybook/theming': 8.4.6(storybook@8.4.6) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - storybook: 8.4.6 - optionalDependencies: - '@storybook/test': 8.4.6(storybook@8.4.6) - typescript: 5.5.4 - - '@storybook/test@8.4.6(storybook@8.4.6)': - dependencies: - '@storybook/csf': 0.1.11 - '@storybook/global': 5.0.0 - '@storybook/instrumenter': 8.4.6(storybook@8.4.6) - '@testing-library/dom': 10.4.0 - '@testing-library/jest-dom': 6.5.0 - '@testing-library/user-event': 14.5.2(@testing-library/dom@10.4.0) - '@vitest/expect': 2.0.5 - '@vitest/spy': 2.0.5 - storybook: 8.4.6 - - '@storybook/theming@8.4.6(storybook@8.4.6)': - dependencies: - storybook: 8.4.6 - - '@swc-jotai/react-refresh@0.1.1': {} - - '@swc/core-darwin-arm64@1.7.39': - optional: true - - '@swc/core-darwin-x64@1.7.39': - optional: true - - '@swc/core-linux-arm-gnueabihf@1.7.39': - optional: true - - '@swc/core-linux-arm64-gnu@1.7.39': - optional: true - - '@swc/core-linux-arm64-musl@1.7.39': - optional: true - - '@swc/core-linux-x64-gnu@1.7.39': - optional: true - - '@swc/core-linux-x64-musl@1.7.39': - optional: true - - '@swc/core-win32-arm64-msvc@1.7.39': - optional: true - - '@swc/core-win32-ia32-msvc@1.7.39': - optional: true - - '@swc/core-win32-x64-msvc@1.7.39': - optional: true - - '@swc/core@1.7.39(@swc/helpers@0.5.1)': - dependencies: - '@swc/counter': 0.1.3 - '@swc/types': 0.1.13 - optionalDependencies: - '@swc/core-darwin-arm64': 1.7.39 - '@swc/core-darwin-x64': 1.7.39 - '@swc/core-linux-arm-gnueabihf': 1.7.39 - '@swc/core-linux-arm64-gnu': 1.7.39 - '@swc/core-linux-arm64-musl': 1.7.39 - '@swc/core-linux-x64-gnu': 1.7.39 - '@swc/core-linux-x64-musl': 1.7.39 - '@swc/core-win32-arm64-msvc': 1.7.39 - '@swc/core-win32-ia32-msvc': 1.7.39 - '@swc/core-win32-x64-msvc': 1.7.39 - '@swc/helpers': 0.5.1 - - '@swc/counter@0.1.3': {} - - '@swc/helpers@0.5.1': - dependencies: - tslib: 2.8.1 - - '@swc/types@0.1.13': - dependencies: - '@swc/counter': 0.1.3 - - '@tailwindcss/typography@0.5.14(tailwindcss@3.4.10)': - dependencies: - lodash.castarray: 4.4.0 - lodash.isplainobject: 4.0.6 - lodash.merge: 4.6.2 - postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.10 - - '@tanstack/react-table@8.20.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@tanstack/table-core': 8.20.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@tanstack/table-core@8.20.1': {} - - '@testing-library/dom@10.4.0': - dependencies: - '@babel/code-frame': 7.25.9 - '@babel/runtime': 7.25.6 - '@types/aria-query': 5.0.4 - aria-query: 5.3.0 - chalk: 4.1.2 - dom-accessibility-api: 0.5.16 - lz-string: 1.5.0 - pretty-format: 27.5.1 - - '@testing-library/jest-dom@6.5.0': - dependencies: - '@adobe/css-tools': 4.4.0 - aria-query: 5.3.1 - chalk: 3.0.0 - css.escape: 1.5.1 - dom-accessibility-api: 0.6.3 - lodash: 4.17.21 - redent: 3.0.0 - - '@testing-library/react-hooks@8.0.1(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react-test-renderer@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - react: 18.3.1 - react-error-boundary: 3.1.4(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - react-dom: 18.3.1(react@18.3.1) - react-test-renderer: 18.3.1(react@18.3.1) - - '@testing-library/react@16.0.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@testing-library/dom': 10.4.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.0 - - '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': - dependencies: - '@testing-library/dom': 10.4.0 - - '@textea/json-viewer@3.4.1(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@mui/material@5.16.7(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@emotion/react': 11.13.0(@types/react@18.3.12)(react@18.3.1) - '@emotion/styled': 11.13.0(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) - '@mui/material': 5.16.7(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - clsx: 2.1.1 - copy-to-clipboard: 3.3.3 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - zustand: 4.5.2(@types/react@18.3.12)(react@18.3.1) - transitivePeerDependencies: - - '@types/react' - - immer - - '@trysound/sax@0.2.0': {} - - '@turf/area@7.1.0': - dependencies: - '@turf/helpers': 7.1.0 - '@turf/meta': 7.1.0 - '@types/geojson': 7946.0.14 - tslib: 2.8.1 - - '@turf/bbox@7.1.0': - dependencies: - '@turf/helpers': 7.1.0 - '@turf/meta': 7.1.0 - '@types/geojson': 7946.0.14 - tslib: 2.8.1 - - '@turf/centroid@7.1.0': - dependencies: - '@turf/helpers': 7.1.0 - '@turf/meta': 7.1.0 - '@types/geojson': 7946.0.14 - tslib: 2.8.1 - - '@turf/helpers@7.1.0': - dependencies: - '@types/geojson': 7946.0.14 - tslib: 2.8.1 - - '@turf/meta@7.1.0': - dependencies: - '@turf/helpers': 7.1.0 - '@types/geojson': 7946.0.14 - - '@types/aria-query@5.0.4': {} - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.25.9 - '@babel/types': 7.25.9 - '@types/babel__generator': 7.6.4 - '@types/babel__template': 7.4.1 - '@types/babel__traverse': 7.18.5 - - '@types/babel__generator@7.6.4': - dependencies: - '@babel/types': 7.25.9 - - '@types/babel__template@7.4.1': - dependencies: - '@babel/parser': 7.25.9 - '@babel/types': 7.25.9 - - '@types/babel__traverse@7.18.5': - dependencies: - '@babel/types': 7.25.9 - - '@types/clone@0.1.30': {} - - '@types/d3-array@3.2.1': {} - - '@types/d3-axis@3.0.6': - dependencies: - '@types/d3-selection': 3.0.10 - - '@types/d3-brush@3.0.6': - dependencies: - '@types/d3-selection': 3.0.10 - - '@types/d3-chord@3.0.6': {} - - '@types/d3-color@3.1.3': {} - - '@types/d3-contour@3.0.6': - dependencies: - '@types/d3-array': 3.2.1 - '@types/geojson': 7946.0.14 - - '@types/d3-delaunay@6.0.4': {} - - '@types/d3-dispatch@3.0.6': {} - - '@types/d3-drag@3.0.7': - dependencies: - '@types/d3-selection': 3.0.10 - - '@types/d3-dsv@3.0.7': {} - - '@types/d3-ease@3.0.2': {} - - '@types/d3-fetch@3.0.7': - dependencies: - '@types/d3-dsv': 3.0.7 - - '@types/d3-force@3.0.9': {} - - '@types/d3-format@3.0.4': {} - - '@types/d3-geo@3.0.8': - dependencies: - '@types/geojson': 7946.0.14 - - '@types/d3-hierarchy@3.1.6': {} - - '@types/d3-interpolate@3.0.4': - dependencies: - '@types/d3-color': 3.1.3 - - '@types/d3-path@3.0.2': {} - - '@types/d3-polygon@3.0.2': {} - - '@types/d3-quadtree@3.0.5': {} - - '@types/d3-random@3.0.3': {} - - '@types/d3-scale-chromatic@3.0.2': {} - - '@types/d3-scale@4.0.8': - dependencies: - '@types/d3-time': 3.0.3 - - '@types/d3-selection@3.0.10': {} - - '@types/d3-shape@3.1.5': - dependencies: - '@types/d3-path': 3.0.2 - - '@types/d3-time-format@4.0.3': {} - - '@types/d3-time@3.0.3': {} - - '@types/d3-timer@3.0.2': {} - - '@types/d3-transition@3.0.8': - dependencies: - '@types/d3-selection': 3.0.10 - - '@types/d3-zoom@3.0.8': - dependencies: - '@types/d3-interpolate': 3.0.4 - '@types/d3-selection': 3.0.10 - - '@types/d3@7.4.3': - dependencies: - '@types/d3-array': 3.2.1 - '@types/d3-axis': 3.0.6 - '@types/d3-brush': 3.0.6 - '@types/d3-chord': 3.0.6 - '@types/d3-color': 3.1.3 - '@types/d3-contour': 3.0.6 - '@types/d3-delaunay': 6.0.4 - '@types/d3-dispatch': 3.0.6 - '@types/d3-drag': 3.0.7 - '@types/d3-dsv': 3.0.7 - '@types/d3-ease': 3.0.2 - '@types/d3-fetch': 3.0.7 - '@types/d3-force': 3.0.9 - '@types/d3-format': 3.0.4 - '@types/d3-geo': 3.0.8 - '@types/d3-hierarchy': 3.1.6 - '@types/d3-interpolate': 3.0.4 - '@types/d3-path': 3.0.2 - '@types/d3-polygon': 3.0.2 - '@types/d3-quadtree': 3.0.5 - '@types/d3-random': 3.0.3 - '@types/d3-scale': 4.0.8 - '@types/d3-scale-chromatic': 3.0.2 - '@types/d3-selection': 3.0.10 - '@types/d3-shape': 3.1.5 - '@types/d3-time': 3.0.3 - '@types/d3-time-format': 4.0.3 - '@types/d3-timer': 3.0.2 - '@types/d3-transition': 3.0.8 - '@types/d3-zoom': 3.0.8 - - '@types/debug@4.1.12': - dependencies: - '@types/ms': 0.7.34 - - '@types/diff-match-patch@1.0.36': {} - - '@types/doctrine@0.0.9': {} - - '@types/emscripten@1.39.13': {} - - '@types/eslint-scope@3.7.7': - dependencies: - '@types/eslint': 9.6.1 - '@types/estree': 1.0.6 - - '@types/eslint@9.6.1': - dependencies: - '@types/estree': 1.0.6 - '@types/json-schema': 7.0.15 - - '@types/estree-jsx@1.0.5': - dependencies: - '@types/estree': 1.0.6 - - '@types/estree@1.0.5': {} - - '@types/estree@1.0.6': {} - - '@types/fast-json-stable-stringify@2.1.0': - dependencies: - fast-json-stable-stringify: 2.1.0 - - '@types/filesystem@0.0.35': - dependencies: - '@types/filewriter': 0.0.33 - - '@types/filewriter@0.0.33': {} - - '@types/geojson-vt@3.2.5': - dependencies: - '@types/geojson': 7946.0.14 - - '@types/geojson@7946.0.14': {} - - '@types/geojson@7946.0.4': {} - - '@types/har-format@1.2.15': {} - - '@types/hast@3.0.4': - dependencies: - '@types/unist': 3.0.3 - - '@types/humanize-duration@3.27.4': {} - - '@types/istanbul-lib-coverage@2.0.6': {} - - '@types/istanbul-lib-report@3.0.3': - dependencies: - '@types/istanbul-lib-coverage': 2.0.6 - - '@types/istanbul-reports@1.1.2': - dependencies: - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-lib-report': 3.0.3 - - '@types/js-cookie@3.0.6': {} - - '@types/jsdom@21.1.7': - dependencies: - '@types/node': 20.17.6 - '@types/tough-cookie': 4.0.4 - parse5: 7.1.2 - - '@types/json-schema@7.0.15': {} - - '@types/katex@0.16.7': {} - - '@types/lodash-es@4.17.12': - dependencies: - '@types/lodash': 4.14.194 - - '@types/lodash@4.14.194': {} - - '@types/mapbox__point-geometry@0.1.4': {} - - '@types/mapbox__vector-tile@1.3.4': - dependencies: - '@types/geojson': 7946.0.14 - '@types/mapbox__point-geometry': 0.1.4 - '@types/pbf': 3.0.5 - - '@types/mdast@4.0.4': - dependencies: - '@types/unist': 3.0.3 - - '@types/mdx@2.0.5': {} - - '@types/ms@0.7.34': {} - - '@types/node@20.17.6': - dependencies: - undici-types: 6.19.8 - - '@types/normalize-package-data@2.4.1': {} - - '@types/parse-json@4.0.0': {} - - '@types/pbf@3.0.5': {} - - '@types/plotly.js@2.12.29': {} - - '@types/prop-types@15.7.13': {} - - '@types/react-dom@18.3.0': - dependencies: - '@types/react': 18.3.12 - - '@types/react-grid-layout@1.3.5': - dependencies: - '@types/react': 18.3.12 - - '@types/react-plotly.js@2.6.3': - dependencies: - '@types/plotly.js': 2.12.29 - '@types/react': 18.3.12 - - '@types/react-transition-group@4.4.11': - dependencies: - '@types/react': 18.3.12 - - '@types/react@18.3.12': - dependencies: - '@types/prop-types': 15.7.13 - csstype: 3.1.3 - - '@types/resolve@1.20.4': {} - - '@types/supercluster@7.1.3': - dependencies: - '@types/geojson': 7946.0.14 - - '@types/timestring@6.0.5': {} - - '@types/tough-cookie@4.0.4': {} - - '@types/unist@2.0.11': {} - - '@types/unist@3.0.3': {} - - '@types/uuid@9.0.7': {} - - '@types/webextension-polyfill@0.10.7': {} - - '@types/yargs-parser@21.0.3': {} - - '@types/yargs@13.0.12': - dependencies: - '@types/yargs-parser': 21.0.3 - - '@typescript-eslint/eslint-plugin@7.15.0(@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4)': - dependencies: - '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 7.15.0(eslint@8.57.0)(typescript@5.5.4) - '@typescript-eslint/scope-manager': 7.15.0 - '@typescript-eslint/type-utils': 7.15.0(eslint@8.57.0)(typescript@5.5.4) - '@typescript-eslint/utils': 7.15.0(eslint@8.57.0)(typescript@5.5.4) - '@typescript-eslint/visitor-keys': 7.15.0 - eslint: 8.57.0 - graphemer: 1.4.0 - ignore: 5.3.2 - natural-compare: 1.4.0 - ts-api-utils: 1.3.0(typescript@5.5.4) - optionalDependencies: - typescript: 5.5.4 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.5.4)': - dependencies: - '@typescript-eslint/scope-manager': 7.15.0 - '@typescript-eslint/types': 7.15.0 - '@typescript-eslint/typescript-estree': 7.15.0(typescript@5.5.4) - '@typescript-eslint/visitor-keys': 7.15.0 - debug: 4.3.6 - eslint: 8.57.0 - optionalDependencies: - typescript: 5.5.4 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@7.15.0': - dependencies: - '@typescript-eslint/types': 7.15.0 - '@typescript-eslint/visitor-keys': 7.15.0 - - '@typescript-eslint/scope-manager@8.11.0': - dependencies: - '@typescript-eslint/types': 8.11.0 - '@typescript-eslint/visitor-keys': 8.11.0 - - '@typescript-eslint/type-utils@7.15.0(eslint@8.57.0)(typescript@5.5.4)': - dependencies: - '@typescript-eslint/typescript-estree': 7.15.0(typescript@5.5.4) - '@typescript-eslint/utils': 7.15.0(eslint@8.57.0)(typescript@5.5.4) - debug: 4.3.6 - eslint: 8.57.0 - ts-api-utils: 1.3.0(typescript@5.5.4) - optionalDependencies: - typescript: 5.5.4 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@7.15.0': {} - - '@typescript-eslint/types@8.11.0': {} - - '@typescript-eslint/typescript-estree@7.15.0(typescript@5.5.4)': - dependencies: - '@typescript-eslint/types': 7.15.0 - '@typescript-eslint/visitor-keys': 7.15.0 - debug: 4.3.6 - globby: 11.1.0 - is-glob: 4.0.3 - minimatch: 9.0.4 - semver: 7.6.3 - ts-api-utils: 1.3.0(typescript@5.5.4) - optionalDependencies: - typescript: 5.5.4 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/typescript-estree@8.11.0(typescript@5.5.4)': - dependencies: - '@typescript-eslint/types': 8.11.0 - '@typescript-eslint/visitor-keys': 8.11.0 - debug: 4.3.6 - fast-glob: 3.3.2 - is-glob: 4.0.3 - minimatch: 9.0.4 - semver: 7.6.3 - ts-api-utils: 1.3.0(typescript@5.5.4) - optionalDependencies: - typescript: 5.5.4 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@7.15.0(eslint@8.57.0)(typescript@5.5.4)': - dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) - '@typescript-eslint/scope-manager': 7.15.0 - '@typescript-eslint/types': 7.15.0 - '@typescript-eslint/typescript-estree': 7.15.0(typescript@5.5.4) - eslint: 8.57.0 - transitivePeerDependencies: - - supports-color - - typescript - - '@typescript-eslint/utils@8.11.0(eslint@8.57.0)(typescript@5.5.4)': - dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) - '@typescript-eslint/scope-manager': 8.11.0 - '@typescript-eslint/types': 8.11.0 - '@typescript-eslint/typescript-estree': 8.11.0(typescript@5.5.4) - eslint: 8.57.0 - transitivePeerDependencies: - - supports-color - - typescript - - '@typescript-eslint/visitor-keys@7.15.0': - dependencies: - '@typescript-eslint/types': 7.15.0 - eslint-visitor-keys: 3.4.3 - - '@typescript-eslint/visitor-keys@8.11.0': - dependencies: - '@typescript-eslint/types': 8.11.0 - eslint-visitor-keys: 3.4.3 - - '@uidotdev/usehooks@2.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@uiw/codemirror-extensions-basic-setup@4.23.5(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1))(@codemirror/commands@6.7.1)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)': - dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1) - '@codemirror/commands': 6.7.1 - '@codemirror/language': 6.10.3 - '@codemirror/lint': 6.8.2 - '@codemirror/search': 6.5.7 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - - '@uiw/codemirror-extensions-langs@4.23.5(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1))(@codemirror/language-data@6.5.1(@codemirror/view@6.34.3))(@codemirror/language@6.10.3)(@codemirror/legacy-modes@6.4.1)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1)(@lezer/highlight@1.2.1)(@lezer/javascript@1.4.18)(@lezer/lr@1.4.2)': - dependencies: - '@codemirror/lang-angular': 0.1.2 - '@codemirror/lang-cpp': 6.0.2 - '@codemirror/lang-css': 6.2.1(@codemirror/view@6.34.3) - '@codemirror/lang-html': 6.4.7 - '@codemirror/lang-java': 6.0.1 - '@codemirror/lang-javascript': 6.2.1 - '@codemirror/lang-json': 6.0.1 - '@codemirror/lang-less': 6.0.1(@codemirror/view@6.34.3) - '@codemirror/lang-lezer': 6.0.1 - '@codemirror/lang-liquid': 6.2.0 - '@codemirror/lang-markdown': 6.3.0 - '@codemirror/lang-php': 6.0.1 - '@codemirror/lang-python': 6.1.6(@codemirror/view@6.34.3) - '@codemirror/lang-rust': 6.0.1 - '@codemirror/lang-sass': 6.0.2(@codemirror/view@6.34.3) - '@codemirror/lang-sql': 6.8.0(@codemirror/view@6.34.3) - '@codemirror/lang-vue': 0.1.2 - '@codemirror/lang-wast': 6.0.1 - '@codemirror/lang-xml': 6.0.2(@codemirror/view@6.34.3) - '@codemirror/language-data': 6.5.1(@codemirror/view@6.34.3) - '@codemirror/legacy-modes': 6.4.1 - '@nextjournal/lang-clojure': 1.0.0 - '@replit/codemirror-lang-csharp': 6.2.0(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1))(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1)(@lezer/highlight@1.2.1)(@lezer/lr@1.4.2) - '@replit/codemirror-lang-nix': 6.0.1(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1))(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1)(@lezer/highlight@1.2.1)(@lezer/lr@1.4.2) - '@replit/codemirror-lang-solidity': 6.0.1(@codemirror/language@6.10.3) - '@replit/codemirror-lang-svelte': 6.0.0(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1))(@codemirror/lang-css@6.2.1(@codemirror/view@6.34.3))(@codemirror/lang-html@6.4.7)(@codemirror/lang-javascript@6.2.1)(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1)(@lezer/highlight@1.2.1)(@lezer/javascript@1.4.18)(@lezer/lr@1.4.2) - codemirror-lang-mermaid: 0.5.0 - transitivePeerDependencies: - - '@codemirror/autocomplete' - - '@codemirror/language' - - '@codemirror/state' - - '@codemirror/view' - - '@lezer/common' - - '@lezer/highlight' - - '@lezer/javascript' - - '@lezer/lr' - - '@uiw/codemirror-extensions-mentions@4.23.5(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)': - dependencies: - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - - '@uiw/react-codemirror@4.23.5(@babel/runtime@7.25.6)(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1))(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.34.3)(codemirror@6.0.1(@lezer/common@1.2.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.25.6 - '@codemirror/commands': 6.7.1 - '@codemirror/state': 6.4.1 - '@codemirror/theme-one-dark': 6.1.2 - '@codemirror/view': 6.34.3 - '@uiw/codemirror-extensions-basic-setup': 4.23.5(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1))(@codemirror/commands@6.7.1)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3) - codemirror: 6.0.1(@lezer/common@1.2.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - transitivePeerDependencies: - - '@codemirror/autocomplete' - - '@codemirror/language' - - '@codemirror/lint' - - '@codemirror/search' - - '@ungap/structured-clone@1.2.0': {} - - '@valtown/codemirror-codeium@1.1.1(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1))(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)': - dependencies: - '@bufbuild/protobuf': 1.10.0 - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1) - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - '@connectrpc/connect': 1.4.0(@bufbuild/protobuf@1.10.0) - '@connectrpc/connect-web': 1.4.0(@bufbuild/protobuf@1.10.0)(@connectrpc/connect@1.4.0(@bufbuild/protobuf@1.10.0)) - - '@vitejs/plugin-react-swc@3.7.1(@swc/helpers@0.5.1)(vite@5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0))': - dependencies: - '@swc/core': 1.7.39(@swc/helpers@0.5.1) - vite: 5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0) - transitivePeerDependencies: - - '@swc/helpers' - - '@vitejs/plugin-react@4.3.3(vite@5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0))': - dependencies: - '@babel/core': 7.25.9 - '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.25.9) - '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.25.9) - '@types/babel__core': 7.20.5 - react-refresh: 0.14.2 - vite: 5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0) - transitivePeerDependencies: - - supports-color - - '@vitest/expect@1.6.0': - dependencies: - '@vitest/spy': 1.6.0 - '@vitest/utils': 1.6.0 - chai: 4.4.1 - - '@vitest/expect@2.0.5': - dependencies: - '@vitest/spy': 2.0.5 - '@vitest/utils': 2.0.5 - chai: 5.1.1 - tinyrainbow: 1.2.0 - - '@vitest/pretty-format@2.0.5': - dependencies: - tinyrainbow: 1.2.0 - - '@vitest/pretty-format@2.1.3': - dependencies: - tinyrainbow: 1.2.0 - - '@vitest/runner@1.6.0': - dependencies: - '@vitest/utils': 1.6.0 - p-limit: 5.0.0 - pathe: 1.1.2 - - '@vitest/snapshot@1.6.0': - dependencies: - magic-string: 0.30.11 - pathe: 1.1.2 - pretty-format: 29.7.0 - - '@vitest/spy@1.6.0': - dependencies: - tinyspy: 2.2.0 - - '@vitest/spy@2.0.5': - dependencies: - tinyspy: 3.0.2 - - '@vitest/utils@1.6.0': - dependencies: - diff-sequences: 29.6.3 - estree-walker: 3.0.3 - loupe: 2.3.7 - pretty-format: 29.7.0 - - '@vitest/utils@2.0.5': - dependencies: - '@vitest/pretty-format': 2.0.5 - estree-walker: 3.0.3 - loupe: 3.1.2 - tinyrainbow: 1.2.0 - - '@vitest/utils@2.1.3': - dependencies: - '@vitest/pretty-format': 2.1.3 - loupe: 3.1.2 - tinyrainbow: 1.2.0 - - '@webassemblyjs/ast@1.14.1': - dependencies: - '@webassemblyjs/helper-numbers': 1.13.2 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - - '@webassemblyjs/floating-point-hex-parser@1.13.2': {} - - '@webassemblyjs/helper-api-error@1.13.2': {} - - '@webassemblyjs/helper-buffer@1.14.1': {} - - '@webassemblyjs/helper-numbers@1.13.2': - dependencies: - '@webassemblyjs/floating-point-hex-parser': 1.13.2 - '@webassemblyjs/helper-api-error': 1.13.2 - '@xtuc/long': 4.2.2 - - '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} - - '@webassemblyjs/helper-wasm-section@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/wasm-gen': 1.14.1 - - '@webassemblyjs/ieee754@1.13.2': - dependencies: - '@xtuc/ieee754': 1.2.0 - - '@webassemblyjs/leb128@1.13.2': - dependencies: - '@xtuc/long': 4.2.2 - - '@webassemblyjs/utf8@1.13.2': {} - - '@webassemblyjs/wasm-edit@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/helper-wasm-section': 1.14.1 - '@webassemblyjs/wasm-gen': 1.14.1 - '@webassemblyjs/wasm-opt': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - '@webassemblyjs/wast-printer': 1.14.1 - - '@webassemblyjs/wasm-gen@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/ieee754': 1.13.2 - '@webassemblyjs/leb128': 1.13.2 - '@webassemblyjs/utf8': 1.13.2 - - '@webassemblyjs/wasm-opt@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/wasm-gen': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - - '@webassemblyjs/wasm-parser@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-api-error': 1.13.2 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/ieee754': 1.13.2 - '@webassemblyjs/leb128': 1.13.2 - '@webassemblyjs/utf8': 1.13.2 - - '@webassemblyjs/wast-printer@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@xtuc/long': 4.2.2 - - '@xterm/addon-attach@0.11.0(@xterm/xterm@5.5.0)': - dependencies: - '@xterm/xterm': 5.5.0 - - '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': - dependencies: - '@xterm/xterm': 5.5.0 - - '@xterm/xterm@5.5.0': {} - - '@xtuc/ieee754@1.2.0': {} - - '@xtuc/long@4.2.2': {} - - abs-svg-path@0.1.1: {} - - acorn-jsx@5.3.2(acorn@8.14.0): - dependencies: - acorn: 8.14.0 - - acorn-walk@8.3.2: {} - - acorn@7.4.1: {} - - acorn@8.14.0: {} - - ag-charts-types@10.2.0: {} - - ag-grid-community@32.2.2: - dependencies: - ag-charts-types: 10.2.0 - - ag-grid-react@32.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - ag-grid-community: 32.2.2 - prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - agent-base@7.1.0: - dependencies: - debug: 4.3.6 - transitivePeerDependencies: - - supports-color - - ai@3.3.12(react@18.3.1)(sswr@2.1.0)(zod@3.23.8): - dependencies: - '@ai-sdk/provider': 0.0.21 - '@ai-sdk/provider-utils': 1.0.14(zod@3.23.8) - '@ai-sdk/react': 0.0.47(react@18.3.1)(zod@3.23.8) - '@ai-sdk/solid': 0.0.37(zod@3.23.8) - '@ai-sdk/svelte': 0.0.39(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.34(zod@3.23.8) - '@ai-sdk/vue': 0.0.39(zod@3.23.8) - '@opentelemetry/api': 1.9.0 - eventsource-parser: 1.1.2 - json-schema: 0.4.0 - jsondiffpatch: 0.6.0 - nanoid: 3.3.6 - secure-json-parse: 2.7.0 - zod-to-json-schema: 3.22.5(zod@3.23.8) - optionalDependencies: - react: 18.3.1 - sswr: 2.1.0 - zod: 3.23.8 - transitivePeerDependencies: - - solid-js - - vue - - ajv-keywords@3.5.2(ajv@6.12.6): - dependencies: - ajv: 6.12.6 - - ajv@6.12.6: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ajv@8.12.0: - dependencies: - fast-deep-equal: 3.1.3 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - uri-js: 4.4.1 - - almost-equal@1.1.0: {} - - ansi-regex@2.1.1: {} - - ansi-regex@4.1.1: {} - - ansi-regex@5.0.1: {} - - ansi-regex@6.0.1: {} - - ansi-styles@3.2.1: - dependencies: - color-convert: 1.9.3 - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansi-styles@5.2.0: {} - - ansi-styles@6.2.1: {} - - ansi_up@6.0.2: {} - - any-promise@1.3.0: {} - - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - - arg@5.0.2: {} - - argparse@2.0.1: {} - - aria-hidden@1.1.3: - dependencies: - tslib: 1.14.1 - - aria-query@5.1.3: - dependencies: - deep-equal: 2.2.3 - - aria-query@5.3.0: - dependencies: - dequal: 2.0.3 - - aria-query@5.3.1: {} - - array-bounds@1.0.1: {} - - array-buffer-byte-length@1.0.1: - dependencies: - call-bind: 1.0.7 - is-array-buffer: 3.0.4 - - array-find-index@1.0.2: {} - - array-includes@3.1.8: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-abstract: 1.23.3 - es-object-atoms: 1.0.0 - get-intrinsic: 1.2.4 - is-string: 1.0.7 - - array-normalize@1.1.4: - dependencies: - array-bounds: 1.0.1 - - array-range@1.0.1: {} - - array-rearrange@2.2.2: {} - - array-union@2.1.0: {} - - array.prototype.findlast@1.2.5: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-abstract: 1.23.3 - es-errors: 1.3.0 - es-object-atoms: 1.0.0 - es-shim-unscopables: 1.0.2 - - array.prototype.flat@1.3.2: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-abstract: 1.23.3 - es-shim-unscopables: 1.0.2 - - array.prototype.flatmap@1.3.2: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-abstract: 1.23.3 - es-shim-unscopables: 1.0.2 - - array.prototype.toreversed@1.1.2: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-abstract: 1.23.3 - es-shim-unscopables: 1.0.2 - - array.prototype.tosorted@1.1.4: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-abstract: 1.23.3 - es-errors: 1.3.0 - es-shim-unscopables: 1.0.2 - - arraybuffer.prototype.slice@1.0.3: - dependencies: - array-buffer-byte-length: 1.0.1 - call-bind: 1.0.7 - define-properties: 1.2.1 - es-abstract: 1.23.3 - es-errors: 1.3.0 - get-intrinsic: 1.2.4 - is-array-buffer: 3.0.4 - is-shared-array-buffer: 1.0.3 - - asap@2.0.6: {} - - asn1@0.2.6: - dependencies: - safer-buffer: 2.1.2 - - assert-plus@1.0.0: {} - - assertion-error@1.1.0: {} - - assertion-error@2.0.1: {} - - ast-types-flow@0.0.8: {} - - ast-types@0.16.1: - dependencies: - tslib: 2.8.1 - - astral-regex@2.0.0: {} - - asynckit@0.4.0: {} - - attr-accept@2.2.2: {} - - autoprefixer@10.4.20(postcss@8.4.49): - dependencies: - browserslist: 4.24.2 - caniuse-lite: 1.0.30001669 - fraction.js: 4.3.7 - normalize-range: 0.1.2 - picocolors: 1.1.1 - postcss: 8.4.49 - postcss-value-parser: 4.2.0 - - available-typed-arrays@1.0.7: - dependencies: - possible-typed-array-names: 1.0.0 - - aws-sign2@0.7.0: {} - - aws4@1.12.0: {} - - axe-core@4.9.1: {} - - axobject-query@3.1.1: - dependencies: - deep-equal: 2.2.3 - - babel-plugin-macros@3.1.0: - dependencies: - '@babel/runtime': 7.25.6 - cosmiconfig: 7.0.1 - resolve: 1.22.8 - - babel-plugin-react-compiler@19.0.0-beta-8a03594-20241020: - dependencies: - '@babel/generator': 7.2.0 - '@babel/types': 7.25.9 - chalk: 4.1.2 - invariant: 2.2.4 - pretty-format: 24.9.0 - zod: 3.23.8 - zod-validation-error: 2.1.0(zod@3.23.8) - - bail@2.0.2: {} - - balanced-match@1.0.2: {} - - balanced-match@2.0.0: {} - - base64-arraybuffer@1.0.2: {} - - bcrypt-pbkdf@1.0.2: - dependencies: - tweetnacl: 0.14.5 - - better-opn@3.0.2: - dependencies: - open: 8.4.2 - - binary-extensions@2.2.0: {} - - binary-search-bounds@2.0.5: {} - - bit-twiddle@1.0.2: {} - - bitmap-sdf@1.0.4: {} - - bl@2.2.1: - dependencies: - readable-stream: 2.3.8 - safe-buffer: 5.2.1 - - blob-polyfill@7.0.20220408: {} - - boolbase@1.0.0: {} - - brace-expansion@1.1.11: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.0.1: - dependencies: - balanced-match: 1.0.2 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - brfs@1.6.1: - dependencies: - quote-stream: 1.0.2 - resolve: 1.22.8 - static-module: 2.2.5 - through2: 2.0.5 - - browser-assert@1.2.1: {} - - browser-namespace@1.4.0: - dependencies: - '@types/filesystem': 0.0.35 - '@types/har-format': 1.2.15 - '@types/webextension-polyfill': 0.10.7 - - browserslist@4.24.2: - dependencies: - caniuse-lite: 1.0.30001669 - electron-to-chromium: 1.5.43 - node-releases: 2.0.18 - update-browserslist-db: 1.1.1(browserslist@4.24.2) - - buffer-equal@0.0.1: {} - - buffer-from@1.1.2: {} - - builtin-modules@3.3.0: {} - - cac@6.7.14: {} - - call-bind@1.0.7: - dependencies: - es-define-property: 1.0.0 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.2.4 - set-function-length: 1.2.2 - - callsites@3.1.0: {} - - camelcase-css@2.0.1: {} - - camelcase@3.0.0: {} - - camelcase@5.3.1: {} - - caniuse-api@3.0.0: - dependencies: - browserslist: 4.24.2 - caniuse-lite: 1.0.30001669 - lodash.memoize: 4.1.2 - lodash.uniq: 4.5.0 - - caniuse-lite@1.0.30001669: {} - - canvas-fit@1.5.0: - dependencies: - element-size: 1.1.1 - - caseless@0.11.0: {} - - caseless@0.12.0: {} - - ccount@2.0.1: {} - - chai@4.4.1: - dependencies: - assertion-error: 1.1.0 - check-error: 1.0.3 - deep-eql: 4.1.3 - get-func-name: 2.0.2 - loupe: 2.3.7 - pathval: 1.1.1 - type-detect: 4.0.8 - - chai@5.1.1: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.1 - deep-eql: 5.0.2 - loupe: 3.1.2 - pathval: 2.0.0 - - chalk@2.4.2: - dependencies: - ansi-styles: 3.2.1 - escape-string-regexp: 1.0.5 - supports-color: 5.5.0 - - chalk@3.0.0: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - chalk@5.3.0: {} - - character-entities-html4@2.1.0: {} - - character-entities-legacy@3.0.0: {} - - character-entities@2.0.2: {} - - character-reference-invalid@2.0.1: {} - - check-error@1.0.3: - dependencies: - get-func-name: 2.0.2 - - check-error@2.1.1: {} - - chevrotain-allstar@0.3.1(chevrotain@11.0.3): - dependencies: - chevrotain: 11.0.3 - lodash-es: 4.17.21 - - chevrotain@11.0.3: - dependencies: - '@chevrotain/cst-dts-gen': 11.0.3 - '@chevrotain/gast': 11.0.3 - '@chevrotain/regexp-to-ast': 11.0.3 - '@chevrotain/types': 11.0.3 - '@chevrotain/utils': 11.0.3 - lodash-es: 4.17.21 - - chokidar@3.5.3: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - - chrome-trace-event@1.0.4: {} - - ci-info@4.0.0: {} - - clamp@1.0.1: {} - - class-variance-authority@0.7.0: - dependencies: - clsx: 2.0.0 - - classcat@5.0.4: {} - - clean-regexp@1.0.0: - dependencies: - escape-string-regexp: 1.0.5 - - client-only@0.0.1: {} - - cliui@3.2.0: - dependencies: - string-width: 1.0.2 - strip-ansi: 3.0.1 - wrap-ansi: 2.1.0 - - cliui@5.0.0: - dependencies: - string-width: 3.1.0 - strip-ansi: 5.2.0 - wrap-ansi: 5.1.0 - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - clone@2.1.2: {} - - clsx@1.2.1: {} - - clsx@2.0.0: {} - - clsx@2.1.1: {} - - cmdk@1.0.0(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@radix-ui/react-dialog': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - transitivePeerDependencies: - - '@types/react' - - '@types/react-dom' - - code-point-at@1.1.0: {} - - codemirror-extension-inline-suggestion@0.0.3(@codemirror/state@6.4.1)(@codemirror/view@6.34.3): - dependencies: - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - - codemirror-lang-mermaid@0.5.0: - dependencies: - '@codemirror/language': 6.10.3 - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - - codemirror-languageserver@1.11.0(@codemirror/language@6.10.3)(@lezer/common@1.2.1): - dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1) - '@codemirror/lint': 6.8.2 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - '@open-rpc/client-js': 1.8.1 - vscode-languageserver-protocol: 3.17.5 - transitivePeerDependencies: - - '@codemirror/language' - - '@lezer/common' - - bufferutil - - encoding - - utf-8-validate - - codemirror@6.0.1(@lezer/common@1.2.1): - dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1) - '@codemirror/commands': 6.7.1 - '@codemirror/language': 6.10.3 - '@codemirror/lint': 6.8.2 - '@codemirror/search': 6.5.7 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - transitivePeerDependencies: - - '@lezer/common' - - color-alpha@1.0.4: - dependencies: - color-parse: 1.3.8 - - color-convert@1.9.3: - dependencies: - color-name: 1.1.3 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-id@1.1.0: - dependencies: - clamp: 1.0.1 - - color-name@1.1.3: {} - - color-name@1.1.4: {} - - color-normalize@1.5.0: - dependencies: - clamp: 1.0.1 - color-rgba: 2.1.1 - dtype: 2.0.0 - - color-parse@1.3.8: - dependencies: - color-name: 1.1.4 - defined: 1.0.1 - is-plain-obj: 1.1.0 - - color-parse@2.0.0: - dependencies: - color-name: 1.1.4 - - color-rgba@2.1.1: - dependencies: - clamp: 1.0.1 - color-parse: 1.3.8 - color-space: 1.16.0 - - color-space@1.16.0: - dependencies: - hsluv: 0.0.3 - mumath: 3.3.4 - - colord@2.9.3: {} - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - comma-separated-tokens@2.0.3: {} - - commander@2.20.3: {} - - commander@4.1.1: {} - - commander@7.2.0: {} - - commander@8.3.0: {} - - compassql@0.21.2(vega@5.30.0): - dependencies: - datalib: 1.7.3 - typescript-json-schema: 0.9.2 - vega-lite: 3.4.0(vega@5.30.0) - yargs: 13.3.2 - transitivePeerDependencies: - - vega - - concat-map@0.0.1: {} - - concat-stream@1.6.2: - dependencies: - buffer-from: 1.1.2 - inherits: 2.0.4 - readable-stream: 2.3.8 - typedarray: 0.0.6 - - confbox@0.1.8: {} - - convert-source-map@1.9.0: {} - - convert-source-map@2.0.0: {} - - copy-anything@2.0.6: - dependencies: - is-what: 3.14.1 - optional: true - - copy-to-clipboard@3.3.3: - dependencies: - toggle-selection: 1.0.6 - - core-js-compat@3.37.1: - dependencies: - browserslist: 4.24.2 - - core-util-is@1.0.2: {} - - core-util-is@1.0.3: {} - - cose-base@1.0.3: - dependencies: - layout-base: 1.0.2 - - cose-base@2.2.0: - dependencies: - layout-base: 2.0.1 - - cosmiconfig@7.0.1: - dependencies: - '@types/parse-json': 4.0.0 - import-fresh: 3.3.0 - parse-json: 5.2.0 - path-type: 4.0.0 - yaml: 1.10.2 - - cosmiconfig@9.0.0(typescript@5.5.4): - dependencies: - env-paths: 2.2.1 - import-fresh: 3.3.0 - js-yaml: 4.1.0 - parse-json: 5.2.0 - optionalDependencies: - typescript: 5.5.4 - - country-regex@1.1.0: {} - - crelt@1.0.6: {} - - cross-spawn@7.0.3: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - css-declaration-sorter@7.2.0(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - - css-font-size-keywords@1.0.0: {} - - css-font-stretch-keywords@1.0.1: {} - - css-font-style-keywords@1.0.1: {} - - css-font-weight-keywords@1.0.0: {} - - css-font@1.2.0: - dependencies: - css-font-size-keywords: 1.0.0 - css-font-stretch-keywords: 1.0.1 - css-font-style-keywords: 1.0.1 - css-font-weight-keywords: 1.0.0 - css-global-keywords: 1.0.1 - css-system-font-keywords: 1.0.0 - pick-by-alias: 1.2.0 - string-split-by: 1.0.0 - unquote: 1.1.1 - - css-functions-list@3.2.2: {} - - css-global-keywords@1.0.1: {} - - css-loader@7.1.2(webpack@5.96.1(esbuild@0.21.5)): - dependencies: - icss-utils: 5.1.0(postcss@8.4.49) - postcss: 8.4.49 - postcss-modules-extract-imports: 3.1.0(postcss@8.4.49) - postcss-modules-local-by-default: 4.1.0(postcss@8.4.49) - postcss-modules-scope: 3.2.1(postcss@8.4.49) - postcss-modules-values: 4.0.0(postcss@8.4.49) - postcss-value-parser: 4.2.0 - semver: 7.6.3 - optionalDependencies: - webpack: 5.96.1(esbuild@0.21.5) - - css-select@5.1.0: - dependencies: - boolbase: 1.0.0 - css-what: 6.1.0 - domhandler: 5.0.3 - domutils: 3.1.0 - nth-check: 2.1.1 - - css-system-font-keywords@1.0.0: {} - - css-tree@2.2.1: - dependencies: - mdn-data: 2.0.28 - source-map-js: 1.2.1 - - css-tree@2.3.1: - dependencies: - mdn-data: 2.0.30 - source-map-js: 1.2.1 - - css-what@6.1.0: {} - - css.escape@1.5.1: {} - - csscolorparser@1.0.3: {} - - cssesc@3.0.0: {} - - cssnano-preset-default@7.0.5(postcss@8.4.49): - dependencies: - browserslist: 4.24.2 - css-declaration-sorter: 7.2.0(postcss@8.4.49) - cssnano-utils: 5.0.0(postcss@8.4.49) - postcss: 8.4.49 - postcss-calc: 10.0.2(postcss@8.4.49) - postcss-colormin: 7.0.2(postcss@8.4.49) - postcss-convert-values: 7.0.3(postcss@8.4.49) - postcss-discard-comments: 7.0.2(postcss@8.4.49) - postcss-discard-duplicates: 7.0.1(postcss@8.4.49) - postcss-discard-empty: 7.0.0(postcss@8.4.49) - postcss-discard-overridden: 7.0.0(postcss@8.4.49) - postcss-merge-longhand: 7.0.3(postcss@8.4.49) - postcss-merge-rules: 7.0.3(postcss@8.4.49) - postcss-minify-font-values: 7.0.0(postcss@8.4.49) - postcss-minify-gradients: 7.0.0(postcss@8.4.49) - postcss-minify-params: 7.0.2(postcss@8.4.49) - postcss-minify-selectors: 7.0.3(postcss@8.4.49) - postcss-normalize-charset: 7.0.0(postcss@8.4.49) - postcss-normalize-display-values: 7.0.0(postcss@8.4.49) - postcss-normalize-positions: 7.0.0(postcss@8.4.49) - postcss-normalize-repeat-style: 7.0.0(postcss@8.4.49) - postcss-normalize-string: 7.0.0(postcss@8.4.49) - postcss-normalize-timing-functions: 7.0.0(postcss@8.4.49) - postcss-normalize-unicode: 7.0.2(postcss@8.4.49) - postcss-normalize-url: 7.0.0(postcss@8.4.49) - postcss-normalize-whitespace: 7.0.0(postcss@8.4.49) - postcss-ordered-values: 7.0.1(postcss@8.4.49) - postcss-reduce-initial: 7.0.2(postcss@8.4.49) - postcss-reduce-transforms: 7.0.0(postcss@8.4.49) - postcss-svgo: 7.0.1(postcss@8.4.49) - postcss-unique-selectors: 7.0.2(postcss@8.4.49) - - cssnano-utils@5.0.0(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - - cssnano@7.0.5(postcss@8.4.49): - dependencies: - cssnano-preset-default: 7.0.5(postcss@8.4.49) - lilconfig: 3.1.2 - postcss: 8.4.49 - - csso@5.0.5: - dependencies: - css-tree: 2.2.1 - - cssstyle@4.0.1: - dependencies: - rrweb-cssom: 0.6.0 - - csstype@3.1.3: {} - - cytoscape-cose-bilkent@4.1.0(cytoscape@3.30.2): - dependencies: - cose-base: 1.0.3 - cytoscape: 3.30.2 - - cytoscape-fcose@2.2.0(cytoscape@3.30.2): - dependencies: - cose-base: 2.2.0 - cytoscape: 3.30.2 - - cytoscape@3.30.2: {} - - d3-array@1.2.4: {} - - d3-array@3.2.4: - dependencies: - internmap: 2.0.3 - - d3-axis@3.0.0: {} - - d3-brush@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-drag: 3.0.0 - d3-interpolate: 3.0.1 - d3-selection: 3.0.0 - d3-transition: 3.0.1(d3-selection@3.0.0) - - d3-chord@3.0.1: - dependencies: - d3-path: 3.1.0 - - d3-collection@1.0.7: {} - - d3-color@3.1.0: {} - - d3-contour@4.0.2: - dependencies: - d3-array: 3.2.4 - - d3-delaunay@6.0.4: - dependencies: - delaunator: 5.0.0 - - d3-dispatch@1.0.6: {} - - d3-dispatch@3.0.1: {} - - d3-drag@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-selection: 3.0.0 - - d3-dsv@0.1.14: {} - - d3-dsv@3.0.1: - dependencies: - commander: 7.2.0 - iconv-lite: 0.6.3 - rw: 1.3.3 - - d3-ease@3.0.1: {} - - d3-fetch@3.0.1: - dependencies: - d3-dsv: 3.0.1 - - d3-force@1.2.1: - dependencies: - d3-collection: 1.0.7 - d3-dispatch: 1.0.6 - d3-quadtree: 1.0.7 - d3-timer: 1.0.10 - - d3-force@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-quadtree: 3.0.1 - d3-timer: 3.0.1 - - d3-format@0.4.2: {} - - d3-format@1.4.5: {} - - d3-format@3.1.0: {} - - d3-geo-projection@0.2.16: - dependencies: - brfs: 1.6.1 - - d3-geo-projection@2.9.0: - dependencies: - commander: 2.20.3 - d3-array: 1.2.4 - d3-geo: 1.12.1 - resolve: 1.22.8 - - d3-geo-projection@4.0.0: - dependencies: - commander: 7.2.0 - d3-array: 3.2.4 - d3-geo: 3.1.1 - - d3-geo@1.12.1: - dependencies: - d3-array: 1.2.4 - - d3-geo@3.1.1: - dependencies: - d3-array: 3.2.4 - - d3-hierarchy@1.1.9: {} - - d3-hierarchy@3.1.2: {} - - d3-interpolate@3.0.1: - dependencies: - d3-color: 3.1.0 - - d3-path@1.0.9: {} - - d3-path@3.1.0: {} - - d3-polygon@3.0.1: {} - - d3-quadtree@1.0.7: {} - - d3-quadtree@3.0.1: {} - - d3-queue@1.2.3: {} - - d3-queue@2.0.3: {} - - d3-random@3.0.1: {} - - d3-sankey@0.12.3: - dependencies: - d3-array: 1.2.4 - d3-shape: 1.3.7 - - d3-scale-chromatic@3.1.0: - dependencies: - d3-color: 3.1.0 - d3-interpolate: 3.0.1 - - d3-scale@4.0.2: - dependencies: - d3-array: 3.2.4 - d3-format: 3.1.0 - d3-interpolate: 3.0.1 - d3-time: 3.1.0 - d3-time-format: 4.1.0 - - d3-selection@3.0.0: {} - - d3-shape@1.3.7: - dependencies: - d3-path: 1.0.9 - - d3-shape@3.2.0: - dependencies: - d3-path: 3.1.0 - - d3-time-format@0.2.1: - dependencies: - d3-time: 0.1.1 - - d3-time-format@2.3.0: - dependencies: - d3-time: 1.1.0 - - d3-time-format@4.1.0: - dependencies: - d3-time: 3.1.0 - - d3-time@0.1.1: {} - - d3-time@1.1.0: {} - - d3-time@3.1.0: - dependencies: - d3-array: 3.2.4 - - d3-timer@1.0.10: {} - - d3-timer@3.0.1: {} - - d3-transition@3.0.1(d3-selection@3.0.0): - dependencies: - d3-color: 3.1.0 - d3-dispatch: 3.0.1 - d3-ease: 3.0.1 - d3-interpolate: 3.0.1 - d3-selection: 3.0.0 - d3-timer: 3.0.1 - - d3-zoom@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-drag: 3.0.0 - d3-interpolate: 3.0.1 - d3-selection: 3.0.0 - d3-transition: 3.0.1(d3-selection@3.0.0) - - d3@3.5.17: {} - - d3@7.9.0: - dependencies: - d3-array: 3.2.4 - d3-axis: 3.0.0 - d3-brush: 3.0.0 - d3-chord: 3.0.1 - d3-color: 3.1.0 - d3-contour: 4.0.2 - d3-delaunay: 6.0.4 - d3-dispatch: 3.0.1 - d3-drag: 3.0.0 - d3-dsv: 3.0.1 - d3-ease: 3.0.1 - d3-fetch: 3.0.1 - d3-force: 3.0.0 - d3-format: 3.1.0 - d3-geo: 3.1.1 - d3-hierarchy: 3.1.2 - d3-interpolate: 3.0.1 - d3-path: 3.1.0 - d3-polygon: 3.0.1 - d3-quadtree: 3.0.1 - d3-random: 3.0.1 - d3-scale: 4.0.2 - d3-scale-chromatic: 3.1.0 - d3-selection: 3.0.0 - d3-shape: 3.2.0 - d3-time: 3.1.0 - d3-time-format: 4.1.0 - d3-timer: 3.0.1 - d3-transition: 3.0.1(d3-selection@3.0.0) - d3-zoom: 3.0.0 - - d@1.0.1: - dependencies: - es5-ext: 0.10.62 - type: 1.2.0 - - dagre-d3-es@7.0.10: - dependencies: - d3: 7.9.0 - lodash-es: 4.17.21 - - damerau-levenshtein@1.0.8: {} - - dashdash@1.14.1: - dependencies: - assert-plus: 1.0.0 - - data-urls@5.0.0: - dependencies: - whatwg-mimetype: 4.0.0 - whatwg-url: 14.0.0 - - data-view-buffer@1.0.1: - dependencies: - call-bind: 1.0.7 - es-errors: 1.3.0 - is-data-view: 1.0.1 - - data-view-byte-length@1.0.1: - dependencies: - call-bind: 1.0.7 - es-errors: 1.3.0 - is-data-view: 1.0.1 - - data-view-byte-offset@1.0.0: - dependencies: - call-bind: 1.0.7 - es-errors: 1.3.0 - is-data-view: 1.0.1 - - datalib@1.7.3: - dependencies: - d3-dsv: 0.1.14 - d3-format: 0.4.2 - d3-time: 0.1.1 - d3-time-format: 0.2.1 - request: 2.88.2 - sync-request: 2.2.0 - topojson: 1.6.27 - - date-fns@3.6.0: {} - - dayjs@1.11.10: {} - - debug@2.6.9: - dependencies: - ms: 2.0.0 - - debug@3.2.7: - dependencies: - ms: 2.1.3 - - debug@4.3.6: - dependencies: - ms: 2.1.2 - - decamelize@1.2.0: {} - - decimal.js@10.4.3: {} - - decode-named-character-reference@1.0.2: - dependencies: - character-entities: 2.0.2 - - deep-eql@4.1.3: - dependencies: - type-detect: 4.0.8 - - deep-eql@5.0.2: {} - - deep-equal@2.2.3: - dependencies: - array-buffer-byte-length: 1.0.1 - call-bind: 1.0.7 - es-get-iterator: 1.1.3 - get-intrinsic: 1.2.4 - is-arguments: 1.1.1 - is-array-buffer: 3.0.4 - is-date-object: 1.0.5 - is-regex: 1.1.4 - is-shared-array-buffer: 1.0.3 - isarray: 2.0.5 - object-is: 1.1.5 - object-keys: 1.1.1 - object.assign: 4.1.5 - regexp.prototype.flags: 1.5.2 - side-channel: 1.0.6 - which-boxed-primitive: 1.0.2 - which-collection: 1.0.1 - which-typed-array: 1.1.15 - - deep-is@0.1.4: {} - - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.0 - es-errors: 1.3.0 - gopd: 1.0.1 - - define-lazy-prop@2.0.0: {} - - define-properties@1.2.1: - dependencies: - define-data-property: 1.1.4 - has-property-descriptors: 1.0.2 - object-keys: 1.1.1 - - defined@1.0.1: {} - - delaunator@5.0.0: - dependencies: - robust-predicates: 3.0.2 - - delayed-stream@1.0.0: {} - - dequal@2.0.3: {} - - detect-kerning@2.1.2: {} - - detect-node-es@1.1.0: {} - - devlop@1.1.0: - dependencies: - dequal: 2.0.3 - - didyoumean@1.2.2: {} - - diff-match-patch@1.0.5: {} - - diff-sequences@29.6.3: {} - - dir-glob@3.0.1: - dependencies: - path-type: 4.0.0 - - dlv@1.1.3: {} - - dnd-core@14.0.1: - dependencies: - '@react-dnd/asap': 4.0.1 - '@react-dnd/invariant': 2.0.0 - redux: 4.2.1 - - doctrine@2.1.0: - dependencies: - esutils: 2.0.3 - - doctrine@3.0.0: - dependencies: - esutils: 2.0.3 - - dom-accessibility-api@0.5.16: {} - - dom-accessibility-api@0.6.3: {} - - dom-helpers@5.2.1: - dependencies: - '@babel/runtime': 7.25.6 - csstype: 3.1.3 - - dom-serializer@2.0.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - entities: 4.5.0 - - domelementtype@2.3.0: {} - - domhandler@5.0.3: - dependencies: - domelementtype: 2.3.0 - - dompurify@3.1.6: {} - - domutils@3.1.0: - dependencies: - dom-serializer: 2.0.0 - domelementtype: 2.3.0 - domhandler: 5.0.3 - - draw-svg-path@1.0.0: - dependencies: - abs-svg-path: 0.1.1 - normalize-svg-path: 0.1.0 - - dtype@2.0.0: {} - - dup@1.0.0: {} - - duplexer2@0.1.4: - dependencies: - readable-stream: 2.3.8 - - duplexify@3.7.1: - dependencies: - end-of-stream: 1.4.4 - inherits: 2.0.4 - readable-stream: 2.3.8 - stream-shift: 1.0.1 - - earcut@2.2.4: {} - - earcut@3.0.0: {} - - ecc-jsbn@0.1.2: - dependencies: - jsbn: 0.1.1 - safer-buffer: 2.1.2 - - electron-to-chromium@1.5.43: {} - - element-size@1.1.1: {} - - elementary-circuits-directed-graph@1.3.1: - dependencies: - strongly-connected-components: 1.0.1 - - emoji-regex@7.0.3: {} - - emoji-regex@8.0.0: {} - - emoji-regex@9.2.2: {} - - end-of-stream@1.4.4: - dependencies: - once: 1.4.0 - - enhanced-resolve@5.17.1: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.2.1 - - entities@4.5.0: {} - - env-paths@2.2.1: {} - - errno@0.1.8: - dependencies: - prr: 1.0.1 - optional: true - - error-ex@1.3.2: - dependencies: - is-arrayish: 0.2.1 - - es-abstract@1.23.3: - dependencies: - array-buffer-byte-length: 1.0.1 - arraybuffer.prototype.slice: 1.0.3 - available-typed-arrays: 1.0.7 - call-bind: 1.0.7 - data-view-buffer: 1.0.1 - data-view-byte-length: 1.0.1 - data-view-byte-offset: 1.0.0 - es-define-property: 1.0.0 - es-errors: 1.3.0 - es-object-atoms: 1.0.0 - es-set-tostringtag: 2.0.3 - es-to-primitive: 1.2.1 - function.prototype.name: 1.1.6 - get-intrinsic: 1.2.4 - get-symbol-description: 1.0.2 - globalthis: 1.0.3 - gopd: 1.0.1 - has-property-descriptors: 1.0.2 - has-proto: 1.0.3 - has-symbols: 1.0.3 - hasown: 2.0.2 - internal-slot: 1.0.7 - is-array-buffer: 3.0.4 - is-callable: 1.2.7 - is-data-view: 1.0.1 - is-negative-zero: 2.0.3 - is-regex: 1.1.4 - is-shared-array-buffer: 1.0.3 - is-string: 1.0.7 - is-typed-array: 1.1.13 - is-weakref: 1.0.2 - object-inspect: 1.13.1 - object-keys: 1.1.1 - object.assign: 4.1.5 - regexp.prototype.flags: 1.5.2 - safe-array-concat: 1.1.2 - safe-regex-test: 1.0.3 - string.prototype.trim: 1.2.9 - string.prototype.trimend: 1.0.8 - string.prototype.trimstart: 1.0.8 - typed-array-buffer: 1.0.2 - typed-array-byte-length: 1.0.1 - typed-array-byte-offset: 1.0.2 - typed-array-length: 1.0.6 - unbox-primitive: 1.0.2 - which-typed-array: 1.1.15 - - es-define-property@1.0.0: - dependencies: - get-intrinsic: 1.2.4 - - es-errors@1.3.0: {} - - es-get-iterator@1.1.3: - dependencies: - call-bind: 1.0.7 - get-intrinsic: 1.2.4 - has-symbols: 1.0.3 - is-arguments: 1.1.1 - is-map: 2.0.2 - is-set: 2.0.2 - is-string: 1.0.7 - isarray: 2.0.5 - stop-iteration-iterator: 1.0.0 - - es-iterator-helpers@1.0.19: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-abstract: 1.23.3 - es-errors: 1.3.0 - es-set-tostringtag: 2.0.3 - function-bind: 1.1.2 - get-intrinsic: 1.2.4 - globalthis: 1.0.3 - has-property-descriptors: 1.0.2 - has-proto: 1.0.3 - has-symbols: 1.0.3 - internal-slot: 1.0.7 - iterator.prototype: 1.1.2 - safe-array-concat: 1.1.2 - - es-module-lexer@1.5.4: {} - - es-object-atoms@1.0.0: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.0.3: - dependencies: - get-intrinsic: 1.2.4 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - es-shim-unscopables@1.0.2: - dependencies: - hasown: 2.0.2 - - es-to-primitive@1.2.1: - dependencies: - is-callable: 1.2.7 - is-date-object: 1.0.5 - is-symbol: 1.0.4 - - es5-ext@0.10.62: - dependencies: - es6-iterator: 2.0.3 - es6-symbol: 3.1.3 - next-tick: 1.1.0 - - es6-iterator@2.0.3: - dependencies: - d: 1.0.1 - es5-ext: 0.10.62 - es6-symbol: 3.1.3 - - es6-symbol@3.1.3: - dependencies: - d: 1.0.1 - ext: 1.7.0 - - es6-weak-map@2.0.3: - dependencies: - d: 1.0.1 - es5-ext: 0.10.62 - es6-iterator: 2.0.3 - es6-symbol: 3.1.3 - - esbuild-register@3.5.0(esbuild@0.21.5): - dependencies: - debug: 4.3.6 - esbuild: 0.21.5 - transitivePeerDependencies: - - supports-color - - esbuild@0.21.5: - optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 - - escalade@3.2.0: {} - - escape-string-regexp@1.0.5: {} - - escape-string-regexp@4.0.0: {} - - escodegen@1.14.3: - dependencies: - esprima: 4.0.1 - estraverse: 4.3.0 - esutils: 2.0.3 - optionator: 0.8.3 - optionalDependencies: - source-map: 0.6.1 - - escodegen@1.9.1: - dependencies: - esprima: 3.1.3 - estraverse: 4.3.0 - esutils: 2.0.3 - optionator: 0.8.3 - optionalDependencies: - source-map: 0.6.1 - - eslint-config-prettier@9.1.0(eslint@8.57.0): - dependencies: - eslint: 8.57.0 - - eslint-plugin-header@3.1.1(eslint@8.57.0): - dependencies: - eslint: 8.57.0 - - eslint-plugin-jsx-a11y@6.9.0(eslint@8.57.0): - dependencies: - aria-query: 5.1.3 - array-includes: 3.1.8 - array.prototype.flatmap: 1.3.2 - ast-types-flow: 0.0.8 - axe-core: 4.9.1 - axobject-query: 3.1.1 - damerau-levenshtein: 1.0.8 - emoji-regex: 9.2.2 - es-iterator-helpers: 1.0.19 - eslint: 8.57.0 - hasown: 2.0.2 - jsx-ast-utils: 3.3.5 - language-tags: 1.0.9 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - safe-regex-test: 1.0.3 - string.prototype.includes: 2.0.0 - - eslint-plugin-react-compiler@19.0.0-beta-8a03594-20241020(eslint@8.57.0): - dependencies: - '@babel/core': 7.25.9 - '@babel/parser': 7.25.9 - '@babel/plugin-proposal-private-methods': 7.18.6(@babel/core@7.25.9) - eslint: 8.57.0 - hermes-parser: 0.20.1 - zod: 3.23.8 - zod-validation-error: 3.4.0(zod@3.23.8) - transitivePeerDependencies: - - supports-color - - eslint-plugin-react-hooks@4.6.2(eslint@8.57.0): - dependencies: - eslint: 8.57.0 - - eslint-plugin-react@7.34.3(eslint@8.57.0): - dependencies: - array-includes: 3.1.8 - array.prototype.findlast: 1.2.5 - array.prototype.flatmap: 1.3.2 - array.prototype.toreversed: 1.1.2 - array.prototype.tosorted: 1.1.4 - doctrine: 2.1.0 - es-iterator-helpers: 1.0.19 - eslint: 8.57.0 - estraverse: 5.3.0 - jsx-ast-utils: 3.3.5 - minimatch: 3.1.2 - object.entries: 1.1.8 - object.fromentries: 2.0.8 - object.hasown: 1.1.4 - object.values: 1.2.0 - prop-types: 15.8.1 - resolve: 2.0.0-next.5 - semver: 6.3.1 - string.prototype.matchall: 4.0.11 - - eslint-plugin-ssr-friendly@1.3.0(eslint@8.57.0): - dependencies: - eslint: 8.57.0 - globals: 13.20.0 - - eslint-plugin-storybook@0.10.1(eslint@8.57.0)(typescript@5.5.4): - dependencies: - '@storybook/csf': 0.1.11 - '@typescript-eslint/utils': 8.11.0(eslint@8.57.0)(typescript@5.5.4) - eslint: 8.57.0 - ts-dedent: 2.2.0 - transitivePeerDependencies: - - supports-color - - typescript - - eslint-plugin-unicorn@54.0.0(eslint@8.57.0): - dependencies: - '@babel/helper-validator-identifier': 7.25.9 - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) - '@eslint/eslintrc': 3.1.0 - ci-info: 4.0.0 - clean-regexp: 1.0.0 - core-js-compat: 3.37.1 - eslint: 8.57.0 - esquery: 1.5.0 - indent-string: 4.0.0 - is-builtin-module: 3.2.1 - jsesc: 3.0.2 - pluralize: 8.0.0 - read-pkg-up: 7.0.1 - regexp-tree: 0.1.27 - regjsparser: 0.10.0 - semver: 7.6.3 - strip-indent: 3.0.0 - transitivePeerDependencies: - - supports-color - - eslint-plugin-vitest@0.4.1(@typescript-eslint/eslint-plugin@7.15.0(@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4)(vitest@1.6.0(@types/node@20.17.6)(jsdom@24.1.1)(less@4.2.0)(terser@5.36.0)): - dependencies: - '@typescript-eslint/utils': 7.15.0(eslint@8.57.0)(typescript@5.5.4) - eslint: 8.57.0 - optionalDependencies: - '@typescript-eslint/eslint-plugin': 7.15.0(@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4) - vitest: 1.6.0(@types/node@20.17.6)(jsdom@24.1.1)(less@4.2.0)(terser@5.36.0) - transitivePeerDependencies: - - supports-color - - typescript - - eslint-scope@5.1.1: - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - - eslint-scope@7.2.2: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.0.0: {} - - eslint@8.57.0: - dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) - '@eslint-community/regexpp': 4.10.0 - '@eslint/eslintrc': 2.1.4 - '@eslint/js': 8.57.0 - '@humanwhocodes/config-array': 0.11.14 - '@humanwhocodes/module-importer': 1.0.1 - '@nodelib/fs.walk': 1.2.8 - '@ungap/structured-clone': 1.2.0 - ajv: 6.12.6 - chalk: 4.1.2 - cross-spawn: 7.0.3 - debug: 4.3.6 - doctrine: 3.0.0 - escape-string-regexp: 4.0.0 - eslint-scope: 7.2.2 - eslint-visitor-keys: 3.4.3 - espree: 9.6.1 - esquery: 1.5.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 6.0.1 - find-up: 5.0.0 - glob-parent: 6.0.2 - globals: 13.20.0 - graphemer: 1.4.0 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - is-path-inside: 3.0.3 - js-yaml: 4.1.0 - json-stable-stringify-without-jsonify: 1.0.1 - levn: 0.4.1 - lodash.merge: 4.6.2 - minimatch: 3.1.2 - natural-compare: 1.4.0 - optionator: 0.9.3 - strip-ansi: 6.0.1 - text-table: 0.2.0 - transitivePeerDependencies: - - supports-color - - espree@10.1.0: - dependencies: - acorn: 8.14.0 - acorn-jsx: 5.3.2(acorn@8.14.0) - eslint-visitor-keys: 4.0.0 - - espree@9.6.1: - dependencies: - acorn: 8.14.0 - acorn-jsx: 5.3.2(acorn@8.14.0) - eslint-visitor-keys: 3.4.3 - - esprima@3.1.3: {} - - esprima@4.0.1: {} - - esquery@1.5.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@4.3.0: {} - - estraverse@5.3.0: {} - - estree-util-is-identifier-name@3.0.0: {} - - estree-walker@2.0.2: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.6 - - esutils@2.0.3: {} - - event-target-shim@6.0.2: {} - - events@3.3.0: {} - - eventsource-parser@1.1.2: {} - - execa@8.0.1: - dependencies: - cross-spawn: 7.0.3 - get-stream: 8.0.1 - human-signals: 5.0.0 - is-stream: 3.0.0 - merge-stream: 2.0.0 - npm-run-path: 5.1.0 - onetime: 6.0.0 - signal-exit: 4.1.0 - strip-final-newline: 3.0.0 - - ext@1.7.0: - dependencies: - type: 2.7.2 - - extend@3.0.2: {} - - extsprintf@1.3.0: {} - - falafel@2.2.5: - dependencies: - acorn: 7.4.1 - isarray: 2.0.5 - - fast-deep-equal@2.0.1: {} - - fast-deep-equal@3.1.3: {} - - fast-equals@4.0.3: {} - - fast-glob@3.3.2: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.7 - - fast-isnumeric@1.1.4: - dependencies: - is-string-blank: 1.0.1 - - fast-json-patch@3.1.1: {} - - fast-json-stable-stringify@2.0.0: {} - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fastest-levenshtein@1.0.16: {} - - fastq@1.13.0: - dependencies: - reusify: 1.0.4 - - file-entry-cache@6.0.1: - dependencies: - flat-cache: 3.2.0 - - file-entry-cache@9.0.0: - dependencies: - flat-cache: 5.0.0 - - file-selector@0.6.0: - dependencies: - tslib: 2.8.1 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - find-root@1.1.0: {} - - find-up@1.1.2: - dependencies: - path-exists: 2.1.0 - pinkie-promise: 2.0.1 - - find-up@3.0.0: - dependencies: - locate-path: 3.0.0 - - find-up@4.1.0: - dependencies: - locate-path: 5.0.0 - path-exists: 4.0.0 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@3.2.0: - dependencies: - flatted: 3.3.1 - keyv: 4.5.4 - rimraf: 3.0.2 - - flat-cache@5.0.0: - dependencies: - flatted: 3.3.1 - keyv: 4.5.4 - - flatted@3.3.1: {} - - flatten-vertex-data@1.0.2: - dependencies: - dtype: 2.0.0 - - font-atlas@2.1.0: - dependencies: - css-font: 1.2.0 - - font-measure@1.2.2: - dependencies: - css-font: 1.2.0 - - for-each@0.3.3: - dependencies: - is-callable: 1.2.7 - - forever-agent@0.6.1: {} - - form-data@2.3.3: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - mime-types: 2.1.35 - - form-data@4.0.0: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - mime-types: 2.1.35 - - fraction.js@4.3.7: {} - - from2@2.3.0: - dependencies: - inherits: 2.0.4 - readable-stream: 2.3.8 - - fs.realpath@1.0.0: {} - - fsevents@2.3.2: - optional: true - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - function.prototype.name@1.1.6: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-abstract: 1.23.3 - functions-have-names: 1.2.3 - - functions-have-names@1.2.3: {} - - gensync@1.0.0-beta.2: {} - - geojson-vt@3.2.1: {} - - geojson-vt@4.0.2: {} - - get-caller-file@1.0.3: {} - - get-caller-file@2.0.5: {} - - get-canvas-context@1.0.2: {} - - get-func-name@2.0.2: {} - - get-intrinsic@1.2.4: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - has-proto: 1.0.3 - has-symbols: 1.0.3 - hasown: 2.0.2 - - get-nonce@1.0.1: {} - - get-stream@6.0.1: {} - - get-stream@8.0.1: {} - - get-symbol-description@1.0.2: - dependencies: - call-bind: 1.0.7 - es-errors: 1.3.0 - get-intrinsic: 1.2.4 - - getpass@0.1.7: - dependencies: - assert-plus: 1.0.0 - - gl-mat4@1.2.0: {} - - gl-matrix@3.4.3: {} - - gl-text@1.4.0: - dependencies: - bit-twiddle: 1.0.2 - color-normalize: 1.5.0 - css-font: 1.2.0 - detect-kerning: 2.1.2 - es6-weak-map: 2.0.3 - flatten-vertex-data: 1.0.2 - font-atlas: 2.1.0 - font-measure: 1.2.2 - gl-util: 3.1.3 - is-plain-obj: 1.1.0 - object-assign: 4.1.1 - parse-rect: 1.2.0 - parse-unit: 1.0.1 - pick-by-alias: 1.2.0 - regl: 2.1.1 - to-px: 1.0.1 - typedarray-pool: 1.2.0 - - gl-util@3.1.3: - dependencies: - is-browser: 2.1.0 - is-firefox: 1.0.3 - is-plain-obj: 1.1.0 - number-is-integer: 1.0.1 - object-assign: 4.1.1 - pick-by-alias: 1.2.0 - weak-map: 1.0.8 - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - glob-to-regexp@0.4.1: {} - - glob@7.1.6: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - - glob@7.2.0: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - - global-modules@2.0.0: - dependencies: - global-prefix: 3.0.0 - - global-prefix@3.0.0: - dependencies: - ini: 1.3.8 - kind-of: 6.0.3 - which: 1.3.1 - - global-prefix@4.0.0: - dependencies: - ini: 4.1.3 - kind-of: 6.0.3 - which: 4.0.0 - - globals@11.12.0: {} - - globals@13.20.0: - dependencies: - type-fest: 0.20.2 - - globals@14.0.0: {} - - globalthis@1.0.3: - dependencies: - define-properties: 1.2.1 - - globby@11.1.0: - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.2 - ignore: 5.3.2 - merge2: 1.4.1 - slash: 3.0.0 - - globjoin@0.1.4: {} - - globrex@0.1.2: {} - - glsl-inject-defines@1.0.3: - dependencies: - glsl-token-inject-block: 1.1.0 - glsl-token-string: 1.0.1 - glsl-tokenizer: 2.1.5 - - glsl-resolve@0.0.1: - dependencies: - resolve: 0.6.3 - xtend: 2.2.0 - - glsl-token-assignments@2.0.2: {} - - glsl-token-defines@1.0.0: - dependencies: - glsl-tokenizer: 2.1.5 - - glsl-token-depth@1.1.2: {} - - glsl-token-descope@1.0.2: - dependencies: - glsl-token-assignments: 2.0.2 - glsl-token-depth: 1.1.2 - glsl-token-properties: 1.0.1 - glsl-token-scope: 1.1.2 - - glsl-token-inject-block@1.1.0: {} - - glsl-token-properties@1.0.1: {} - - glsl-token-scope@1.1.2: {} - - glsl-token-string@1.0.1: {} - - glsl-token-whitespace-trim@1.0.0: {} - - glsl-tokenizer@2.1.5: - dependencies: - through2: 0.6.5 - - glslify-bundle@5.1.1: - dependencies: - glsl-inject-defines: 1.0.3 - glsl-token-defines: 1.0.0 - glsl-token-depth: 1.1.2 - glsl-token-descope: 1.0.2 - glsl-token-scope: 1.1.2 - glsl-token-string: 1.0.1 - glsl-token-whitespace-trim: 1.0.0 - glsl-tokenizer: 2.1.5 - murmurhash-js: 1.0.0 - shallow-copy: 0.0.1 - - glslify-deps@1.3.2: - dependencies: - '@choojs/findup': 0.2.1 - events: 3.3.0 - glsl-resolve: 0.0.1 - glsl-tokenizer: 2.1.5 - graceful-fs: 4.2.11 - inherits: 2.0.4 - map-limit: 0.0.1 - resolve: 1.22.8 - - glslify@7.1.1: - dependencies: - bl: 2.2.1 - concat-stream: 1.6.2 - duplexify: 3.7.1 - falafel: 2.2.5 - from2: 2.3.0 - glsl-resolve: 0.0.1 - glsl-token-whitespace-trim: 1.0.0 - glslify-bundle: 5.1.1 - glslify-deps: 1.3.2 - minimist: 1.2.8 - resolve: 1.22.8 - stack-trace: 0.0.9 - static-eval: 2.1.0 - through2: 2.0.5 - xtend: 4.0.2 - - gopd@1.0.1: - dependencies: - get-intrinsic: 1.2.4 - - graceful-fs@4.2.11: {} - - graphemer@1.4.0: {} - - grid-index@1.1.0: {} - - hachure-fill@0.5.2: {} - - har-schema@2.0.0: {} - - har-validator@5.1.5: - dependencies: - ajv: 6.12.6 - har-schema: 2.0.0 - - has-bigints@1.0.2: {} - - has-flag@3.0.0: {} - - has-flag@4.0.0: {} - - has-hover@1.0.1: - dependencies: - is-browser: 2.1.0 - - has-passive-events@1.0.0: - dependencies: - is-browser: 2.1.0 - - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.0 - - has-proto@1.0.3: {} - - has-symbols@1.0.3: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.0.3 - - has@1.0.3: - dependencies: - function-bind: 1.1.2 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - hast-util-to-jsx-runtime@2.3.2: - dependencies: - '@types/estree': 1.0.6 - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - comma-separated-tokens: 2.0.3 - devlop: 1.1.0 - estree-util-is-identifier-name: 3.0.0 - hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.1.3 - mdast-util-mdxjs-esm: 2.0.1 - property-information: 6.5.0 - space-separated-tokens: 2.0.2 - style-to-object: 1.0.6 - unist-util-position: 5.0.0 - vfile-message: 4.0.2 - transitivePeerDependencies: - - supports-color - - hast-util-whitespace@3.0.0: - dependencies: - '@types/hast': 3.0.4 - - hermes-estree@0.20.1: {} - - hermes-parser@0.20.1: - dependencies: - hermes-estree: 0.20.1 - - hoist-non-react-statics@3.3.2: - dependencies: - react-is: 16.13.1 - - hosted-git-info@2.8.9: {} - - hsluv@0.0.3: {} - - htm@3.1.1: {} - - html-dom-parser@5.0.9: - dependencies: - domhandler: 5.0.3 - htmlparser2: 9.1.0 - - html-encoding-sniffer@4.0.0: - dependencies: - whatwg-encoding: 3.1.1 - - html-react-parser@5.1.12(@types/react@18.3.12)(react@18.3.1): - dependencies: - domhandler: 5.0.3 - html-dom-parser: 5.0.9 - react: 18.3.1 - react-property: 2.0.2 - style-to-js: 1.1.12 - optionalDependencies: - '@types/react': 18.3.12 - - html-tags@3.3.1: {} - - html-to-image@1.11.11: {} - - html-url-attributes@3.0.1: {} - - htmlparser2@9.1.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.1.0 - entities: 4.5.0 - - http-basic@2.5.1: - dependencies: - caseless: 0.11.0 - concat-stream: 1.6.2 - http-response-object: 1.1.0 - - http-proxy-agent@7.0.2: - dependencies: - agent-base: 7.1.0 - debug: 4.3.6 - transitivePeerDependencies: - - supports-color - - http-response-object@1.1.0: {} - - http-signature@1.2.0: - dependencies: - assert-plus: 1.0.0 - jsprim: 1.4.2 - sshpk: 1.18.0 - - https-proxy-agent@7.0.5: - dependencies: - agent-base: 7.1.0 - debug: 4.3.6 - transitivePeerDependencies: - - supports-color - - human-signals@5.0.0: {} - - humanize-duration@3.32.1: {} - - iconify-icon@2.1.0: - dependencies: - '@iconify/types': 2.0.0 - - iconv-lite@0.2.11: {} - - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - - icss-utils@5.1.0(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - - ieee754@1.2.1: {} - - ignore@5.3.2: {} - - image-size@0.5.5: - optional: true - - import-fresh@3.3.0: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - imurmurhash@0.1.4: {} - - indent-string@4.0.0: {} - - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - - ini@1.3.8: {} - - ini@4.1.3: {} - - inline-style-parser@0.2.3: {} - - internal-slot@1.0.7: - dependencies: - es-errors: 1.3.0 - hasown: 2.0.2 - side-channel: 1.0.6 - - internmap@2.0.3: {} - - intl-messageformat@10.5.11: - dependencies: - '@formatjs/ecma402-abstract': 1.18.2 - '@formatjs/fast-memoize': 2.2.0 - '@formatjs/icu-messageformat-parser': 2.7.6 - tslib: 2.8.1 - - invariant@2.2.4: - dependencies: - loose-envify: 1.4.0 - - invert-kv@1.0.0: {} - - is-alphabetical@2.0.1: {} - - is-alphanumerical@2.0.1: - dependencies: - is-alphabetical: 2.0.1 - is-decimal: 2.0.1 - - is-arguments@1.1.1: - dependencies: - call-bind: 1.0.7 - has-tostringtag: 1.0.2 - - is-array-buffer@3.0.4: - dependencies: - call-bind: 1.0.7 - get-intrinsic: 1.2.4 - - is-arrayish@0.2.1: {} - - is-async-function@2.0.0: - dependencies: - has-tostringtag: 1.0.2 - - is-bigint@1.0.4: - dependencies: - has-bigints: 1.0.2 - - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.2.0 - - is-boolean-object@1.1.2: - dependencies: - call-bind: 1.0.7 - has-tostringtag: 1.0.2 - - is-browser@2.1.0: {} - - is-builtin-module@3.2.1: - dependencies: - builtin-modules: 3.3.0 - - is-callable@1.2.7: {} - - is-core-module@2.13.1: - dependencies: - hasown: 2.0.2 - - is-data-view@1.0.1: - dependencies: - is-typed-array: 1.1.13 - - is-date-object@1.0.5: - dependencies: - has-tostringtag: 1.0.2 - - is-decimal@2.0.1: {} - - is-docker@2.2.1: {} - - is-extglob@2.1.1: {} - - is-finalizationregistry@1.0.2: - dependencies: - call-bind: 1.0.7 - - is-finite@1.1.0: {} - - is-firefox@1.0.3: {} - - is-fullwidth-code-point@1.0.0: - dependencies: - number-is-nan: 1.0.1 - - is-fullwidth-code-point@2.0.0: {} - - is-fullwidth-code-point@3.0.0: {} - - is-generator-function@1.0.10: - dependencies: - has-tostringtag: 1.0.2 - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-hexadecimal@2.0.1: {} - - is-iexplorer@1.0.0: {} - - is-map@2.0.2: {} - - is-mobile@4.0.0: {} - - is-negative-zero@2.0.3: {} - - is-number-object@1.0.7: - dependencies: - has-tostringtag: 1.0.2 - - is-number@7.0.0: {} - - is-obj@1.0.1: {} - - is-path-inside@3.0.3: {} - - is-plain-obj@1.1.0: {} - - is-plain-obj@4.1.0: {} - - is-plain-object@5.0.0: {} - - is-potential-custom-element-name@1.0.1: {} - - is-regex@1.1.4: - dependencies: - call-bind: 1.0.7 - has-tostringtag: 1.0.2 - - is-set@2.0.2: {} - - is-shared-array-buffer@1.0.3: - dependencies: - call-bind: 1.0.7 - - is-stream@3.0.0: {} - - is-string-blank@1.0.1: {} - - is-string@1.0.7: - dependencies: - has-tostringtag: 1.0.2 - - is-svg-path@1.0.2: {} - - is-symbol@1.0.4: - dependencies: - has-symbols: 1.0.3 - - is-typed-array@1.1.13: - dependencies: - which-typed-array: 1.1.15 - - is-typedarray@1.0.0: {} - - is-utf8@0.2.1: {} - - is-weakmap@2.0.1: {} - - is-weakref@1.0.2: - dependencies: - call-bind: 1.0.7 - - is-weakset@2.0.2: - dependencies: - call-bind: 1.0.7 - get-intrinsic: 1.2.4 - - is-what@3.14.1: - optional: true - - is-wsl@2.2.0: - dependencies: - is-docker: 2.2.1 - - isarray@0.0.1: {} - - isarray@1.0.0: {} - - isarray@2.0.5: {} - - isexe@2.0.0: {} - - isexe@3.1.1: {} - - isomorphic-fetch@3.0.0: - dependencies: - node-fetch: 2.6.7 - whatwg-fetch: 3.6.17 - transitivePeerDependencies: - - encoding - - isomorphic-ws@5.0.0(ws@7.5.9): - dependencies: - ws: 7.5.9 - - isstream@0.1.2: {} - - iterator.prototype@1.1.2: - dependencies: - define-properties: 1.2.1 - get-intrinsic: 1.2.4 - has-symbols: 1.0.3 - reflect.getprototypeof: 1.0.4 - set-function-name: 2.0.2 - - jest-worker@27.5.1: - dependencies: - '@types/node': 20.17.6 - merge-stream: 2.0.0 - supports-color: 8.1.1 - - jiti@1.21.0: {} - - jotai@2.9.3(@types/react@18.3.12)(react@18.3.1): - optionalDependencies: - '@types/react': 18.3.12 - react: 18.3.1 - - js-cookie@3.0.5: {} - - js-tokens@4.0.0: {} - - js-tokens@8.0.3: {} - - js-yaml@4.1.0: - dependencies: - argparse: 2.0.1 - - jsbn@0.1.1: {} - - jsdoc-type-pratt-parser@4.1.0: {} - - jsdom@24.1.1: - dependencies: - cssstyle: 4.0.1 - data-urls: 5.0.0 - decimal.js: 10.4.3 - form-data: 4.0.0 - html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.5 - is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.12 - parse5: 7.1.2 - rrweb-cssom: 0.7.1 - saxes: 6.0.0 - symbol-tree: 3.2.4 - tough-cookie: 4.1.4 - w3c-xmlserializer: 5.0.0 - webidl-conversions: 7.0.0 - whatwg-encoding: 3.1.1 - whatwg-mimetype: 4.0.0 - whatwg-url: 14.0.0 - ws: 8.18.0 - xml-name-validator: 5.0.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - jsesc@0.5.0: {} - - jsesc@2.5.2: {} - - jsesc@3.0.2: {} - - json-buffer@3.0.1: {} - - json-parse-even-better-errors@2.3.1: {} - - json-parse-even-better-errors@3.0.1: {} - - json-schema-traverse@0.4.1: {} - - json-schema-traverse@1.0.0: {} - - json-schema@0.4.0: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - json-stable-stringify@1.1.1: - dependencies: - call-bind: 1.0.7 - isarray: 2.0.5 - jsonify: 0.0.1 - object-keys: 1.1.1 - - json-stringify-pretty-compact@2.0.0: {} - - json-stringify-pretty-compact@3.0.0: {} - - json-stringify-pretty-compact@4.0.0: {} - - json-stringify-safe@5.0.1: {} - - json5@2.2.3: {} - - jsondiffpatch@0.6.0: - dependencies: - '@types/diff-match-patch': 1.0.36 - chalk: 5.3.0 - diff-match-patch: 1.0.5 - - jsonify@0.0.1: {} - - jsprim@1.4.2: - dependencies: - assert-plus: 1.0.0 - extsprintf: 1.3.0 - json-schema: 0.4.0 - verror: 1.10.0 - - jsx-ast-utils@3.3.5: - dependencies: - array-includes: 3.1.8 - array.prototype.flat: 1.3.2 - object.assign: 4.1.5 - object.values: 1.2.0 - - katex@0.16.11: - dependencies: - commander: 8.3.0 - - kdbush@3.0.0: {} - - kdbush@4.0.2: {} - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - khroma@2.1.0: {} - - kind-of@6.0.3: {} - - known-css-properties@0.34.0: {} - - kolorist@1.8.0: {} - - langium@3.0.0: - dependencies: - chevrotain: 11.0.3 - chevrotain-allstar: 0.3.1(chevrotain@11.0.3) - vscode-languageserver: 9.0.1 - vscode-languageserver-textdocument: 1.0.12 - vscode-uri: 3.0.8 - - language-subtag-registry@0.3.22: {} - - language-tags@1.0.9: - dependencies: - language-subtag-registry: 0.3.22 - - layout-base@1.0.2: {} - - layout-base@2.0.1: {} - - lcid@1.0.0: - dependencies: - invert-kv: 1.0.0 - - less@4.2.0: - dependencies: - copy-anything: 2.0.6 - parse-node-version: 1.0.1 - tslib: 2.8.1 - optionalDependencies: - errno: 0.1.8 - graceful-fs: 4.2.11 - image-size: 0.5.5 - make-dir: 2.1.0 - mime: 1.6.0 - needle: 3.3.1 - source-map: 0.6.1 - optional: true - - levn@0.3.0: - dependencies: - prelude-ls: 1.1.2 - type-check: 0.3.2 - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - lilconfig@2.1.0: {} - - lilconfig@3.1.2: {} - - lines-and-columns@1.2.4: {} - - load-json-file@1.1.0: - dependencies: - graceful-fs: 4.2.11 - parse-json: 2.2.0 - pify: 2.3.0 - pinkie-promise: 2.0.1 - strip-bom: 2.0.0 - - loader-runner@4.3.0: {} - - local-pkg@0.5.0: - dependencies: - mlly: 1.7.2 - pkg-types: 1.2.1 - - locate-path@3.0.0: - dependencies: - p-locate: 3.0.0 - path-exists: 3.0.0 - - locate-path@5.0.0: - dependencies: - p-locate: 4.1.0 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lodash-es@4.17.21: {} - - lodash.castarray@4.4.0: {} - - lodash.isplainobject@4.0.6: {} - - lodash.memoize@4.1.2: {} - - lodash.merge@4.6.2: {} - - lodash.truncate@4.4.2: {} - - lodash.uniq@4.5.0: {} - - lodash@4.17.21: {} - - longest-streak@3.1.0: {} - - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - - loupe@2.3.7: - dependencies: - get-func-name: 2.0.2 - - loupe@3.1.2: {} - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - lucide-react@0.428.0(react@18.3.1): - dependencies: - react: 18.3.1 - - lz-string@1.5.0: {} - - magic-string@0.22.5: - dependencies: - vlq: 0.2.3 - - magic-string@0.27.0: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 - - magic-string@0.30.11: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 - - make-dir@2.1.0: - dependencies: - pify: 4.0.1 - semver: 5.7.2 - optional: true - - map-limit@0.0.1: - dependencies: - once: 1.3.3 - - map-or-similar@1.5.0: {} - - mapbox-gl@1.13.3: - dependencies: - '@mapbox/geojson-rewind': 0.5.2 - '@mapbox/geojson-types': 1.0.2 - '@mapbox/jsonlint-lines-primitives': 2.0.2 - '@mapbox/mapbox-gl-supported': 1.5.0(mapbox-gl@1.13.3) - '@mapbox/point-geometry': 0.1.0 - '@mapbox/tiny-sdf': 1.2.5 - '@mapbox/unitbezier': 0.0.0 - '@mapbox/vector-tile': 1.3.1 - '@mapbox/whoots-js': 3.1.0 - csscolorparser: 1.0.3 - earcut: 2.2.4 - geojson-vt: 3.2.1 - gl-matrix: 3.4.3 - grid-index: 1.1.0 - murmurhash-js: 1.0.0 - pbf: 3.3.0 - potpack: 1.0.2 - quickselect: 2.0.0 - rw: 1.3.3 - supercluster: 7.1.5 - tinyqueue: 2.0.3 - vt-pbf: 3.1.3 - - maplibre-gl@4.7.1: - dependencies: - '@mapbox/geojson-rewind': 0.5.2 - '@mapbox/jsonlint-lines-primitives': 2.0.2 - '@mapbox/point-geometry': 0.1.0 - '@mapbox/tiny-sdf': 2.0.6 - '@mapbox/unitbezier': 0.0.1 - '@mapbox/vector-tile': 1.3.1 - '@mapbox/whoots-js': 3.1.0 - '@maplibre/maplibre-gl-style-spec': 20.4.0 - '@types/geojson': 7946.0.14 - '@types/geojson-vt': 3.2.5 - '@types/mapbox__point-geometry': 0.1.4 - '@types/mapbox__vector-tile': 1.3.4 - '@types/pbf': 3.0.5 - '@types/supercluster': 7.1.3 - earcut: 3.0.0 - geojson-vt: 4.0.2 - gl-matrix: 3.4.3 - global-prefix: 4.0.0 - kdbush: 4.0.2 - murmurhash-js: 1.0.0 - pbf: 3.3.0 - potpack: 2.0.0 - quickselect: 3.0.0 - supercluster: 8.0.1 - tinyqueue: 3.0.0 - vt-pbf: 3.1.3 - - marked@13.0.3: {} - - math-log2@1.0.1: {} - - mathml-tag-names@2.1.3: {} - - mdast-util-from-markdown@2.0.2: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - decode-named-character-reference: 1.0.2 - devlop: 1.1.0 - mdast-util-to-string: 4.0.0 - micromark: 4.0.1 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-decode-string: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.1 - unist-util-stringify-position: 4.0.0 - transitivePeerDependencies: - - supports-color - - mdast-util-mdx-expression@2.0.1: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-mdx-jsx@3.1.3: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - ccount: 2.0.1 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - parse-entities: 4.0.1 - stringify-entities: 4.0.4 - unist-util-stringify-position: 4.0.0 - vfile-message: 4.0.2 - transitivePeerDependencies: - - supports-color - - mdast-util-mdxjs-esm@2.0.1: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-phrasing@4.1.0: - dependencies: - '@types/mdast': 4.0.4 - unist-util-is: 6.0.0 - - mdast-util-to-hast@13.2.0: - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - '@ungap/structured-clone': 1.2.0 - devlop: 1.1.0 - micromark-util-sanitize-uri: 2.0.1 - trim-lines: 3.0.1 - unist-util-position: 5.0.0 - unist-util-visit: 5.0.0 - vfile: 6.0.3 - - mdast-util-to-markdown@2.1.2: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - longest-streak: 3.1.0 - mdast-util-phrasing: 4.1.0 - mdast-util-to-string: 4.0.0 - micromark-util-classify-character: 2.0.1 - micromark-util-decode-string: 2.0.1 - unist-util-visit: 5.0.0 - zwitch: 2.0.4 - - mdast-util-to-string@4.0.0: - dependencies: - '@types/mdast': 4.0.4 - - mdn-data@2.0.28: {} - - mdn-data@2.0.30: {} - - memoize-one@5.2.1: {} - - memoizerific@1.11.3: - dependencies: - map-or-similar: 1.5.0 - - memorystream@0.3.1: {} - - meow@13.2.0: {} - - merge-source-map@1.0.4: - dependencies: - source-map: 0.5.7 - - merge-stream@2.0.0: {} - - merge2@1.4.1: {} - - mermaid@11.3.0: - dependencies: - '@braintree/sanitize-url': 7.1.0 - '@iconify/utils': 2.1.33 - '@mermaid-js/parser': 0.3.0 - cytoscape: 3.30.2 - cytoscape-cose-bilkent: 4.1.0(cytoscape@3.30.2) - cytoscape-fcose: 2.2.0(cytoscape@3.30.2) - d3: 7.9.0 - d3-sankey: 0.12.3 - dagre-d3-es: 7.0.10 - dayjs: 1.11.10 - dompurify: 3.1.6 - katex: 0.16.11 - khroma: 2.1.0 - lodash-es: 4.17.21 - marked: 13.0.3 - roughjs: 4.6.6 - stylis: 4.3.4 - ts-dedent: 2.2.0 - uuid: 9.0.1 - transitivePeerDependencies: - - supports-color - - micromark-core-commonmark@2.0.2: - dependencies: - decode-named-character-reference: 1.0.2 - devlop: 1.1.0 - micromark-factory-destination: 2.0.1 - micromark-factory-label: 2.0.1 - micromark-factory-space: 2.0.1 - micromark-factory-title: 2.0.1 - micromark-factory-whitespace: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.1 - micromark-util-classify-character: 2.0.1 - micromark-util-html-tag-name: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-subtokenize: 2.0.3 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.1 - - micromark-factory-destination@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.1 - - micromark-factory-label@2.0.1: - dependencies: - devlop: 1.1.0 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.1 - - micromark-factory-space@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-types: 2.0.1 - - micromark-factory-title@2.0.1: - dependencies: - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.1 - - micromark-factory-whitespace@2.0.1: - dependencies: - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.1 - - micromark-util-character@2.1.1: - dependencies: - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.1 - - micromark-util-chunked@2.0.1: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-classify-character@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.1 - - micromark-util-combine-extensions@2.0.1: - dependencies: - micromark-util-chunked: 2.0.1 - micromark-util-types: 2.0.1 - - micromark-util-decode-numeric-character-reference@2.0.2: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-decode-string@2.0.1: - dependencies: - decode-named-character-reference: 1.0.2 - micromark-util-character: 2.1.1 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-symbol: 2.0.1 - - micromark-util-encode@2.0.1: {} - - micromark-util-html-tag-name@2.0.1: {} - - micromark-util-normalize-identifier@2.0.1: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-resolve-all@2.0.1: - dependencies: - micromark-util-types: 2.0.1 - - micromark-util-sanitize-uri@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-encode: 2.0.1 - micromark-util-symbol: 2.0.1 - - micromark-util-subtokenize@2.0.3: - dependencies: - devlop: 1.1.0 - micromark-util-chunked: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.1 - - micromark-util-symbol@2.0.1: {} - - micromark-util-types@2.0.1: {} - - micromark@4.0.1: - dependencies: - '@types/debug': 4.1.12 - debug: 4.3.6 - decode-named-character-reference: 1.0.2 - devlop: 1.1.0 - micromark-core-commonmark: 2.0.2 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.1 - micromark-util-combine-extensions: 2.0.1 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-encode: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-subtokenize: 2.0.3 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.1 - transitivePeerDependencies: - - supports-color - - micromatch@4.0.7: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - mime@1.6.0: - optional: true - - mimic-fn@4.0.0: {} - - min-indent@1.0.1: {} - - minimatch@3.1.2: - dependencies: - brace-expansion: 1.1.11 - - minimatch@9.0.4: - dependencies: - brace-expansion: 2.0.1 - - minimist@1.2.8: {} - - mitt@3.0.1: {} - - mlly@1.7.2: - dependencies: - acorn: 8.14.0 - pathe: 1.1.2 - pkg-types: 1.2.1 - ufo: 1.5.4 - - mouse-change@1.4.0: - dependencies: - mouse-event: 1.0.5 - - mouse-event-offset@3.0.2: {} - - mouse-event@1.0.5: {} - - mouse-wheel@1.2.0: - dependencies: - right-now: 1.0.0 - signum: 1.0.0 - to-px: 1.0.1 - - ms@2.0.0: {} - - ms@2.1.2: {} - - ms@2.1.3: {} - - mumath@3.3.4: - dependencies: - almost-equal: 1.1.0 - - murmurhash-js@1.0.0: {} - - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - - nanoid@3.3.6: {} - - nanoid@3.3.7: {} - - native-promise-only@0.8.1: {} - - natural-compare@1.4.0: {} - - needle@2.9.1: - dependencies: - debug: 3.2.7 - iconv-lite: 0.4.24 - sax: 1.4.1 - transitivePeerDependencies: - - supports-color - - needle@3.3.1: - dependencies: - iconv-lite: 0.6.3 - sax: 1.4.1 - optional: true - - neo-async@2.6.2: {} - - next-tick@1.1.0: {} - - node-fetch@2.6.7: - dependencies: - whatwg-url: 5.0.0 - - node-releases@2.0.18: {} - - normalize-package-data@2.5.0: - dependencies: - hosted-git-info: 2.8.9 - resolve: 1.22.8 - semver: 5.7.2 - validate-npm-package-license: 3.0.4 - - normalize-path@3.0.0: {} - - normalize-range@0.1.2: {} - - normalize-svg-path@0.1.0: {} - - normalize-svg-path@1.1.0: - dependencies: - svg-arc-to-cubic-bezier: 3.2.0 - - npm-normalize-package-bin@3.0.1: {} - - npm-run-all2@6.2.2: - dependencies: - ansi-styles: 6.2.1 - cross-spawn: 7.0.3 - memorystream: 0.3.1 - minimatch: 9.0.4 - pidtree: 0.6.0 - read-package-json-fast: 3.0.2 - shell-quote: 1.7.4 - - npm-run-path@5.1.0: - dependencies: - path-key: 4.0.0 - - nth-check@2.1.1: - dependencies: - boolbase: 1.0.0 - - number-is-integer@1.0.1: - dependencies: - is-finite: 1.1.0 - - number-is-nan@1.0.1: {} - - nwsapi@2.2.12: {} - - oauth-sign@0.9.0: {} - - object-assign@4.1.1: {} - - object-hash@3.0.0: {} - - object-inspect@1.13.1: {} - - object-inspect@1.4.1: {} - - object-is@1.1.5: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - - object-keys@1.1.1: {} - - object.assign@4.1.5: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - has-symbols: 1.0.3 - object-keys: 1.1.1 - - object.entries@1.1.8: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-object-atoms: 1.0.0 - - object.fromentries@2.0.8: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-abstract: 1.23.3 - es-object-atoms: 1.0.0 - - object.hasown@1.1.4: - dependencies: - define-properties: 1.2.1 - es-abstract: 1.23.3 - es-object-atoms: 1.0.0 - - object.values@1.2.0: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-object-atoms: 1.0.0 - - once@1.3.3: - dependencies: - wrappy: 1.0.2 - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - onetime@6.0.0: - dependencies: - mimic-fn: 4.0.0 - - open@8.4.2: - dependencies: - define-lazy-prop: 2.0.0 - is-docker: 2.2.1 - is-wsl: 2.2.0 - - openapi-fetch@0.9.7: - dependencies: - openapi-typescript-helpers: 0.0.8 - - openapi-typescript-helpers@0.0.8: {} - - optimist@0.3.7: - dependencies: - wordwrap: 0.0.3 - - optionator@0.8.3: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.3.0 - prelude-ls: 1.1.2 - type-check: 0.3.2 - word-wrap: 1.2.5 - - optionator@0.9.3: - dependencies: - '@aashutoshrathi/word-wrap': 1.2.6 - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - - os-locale@1.4.0: - dependencies: - lcid: 1.0.0 - - os-shim@0.1.3: {} - - p-limit@2.3.0: - dependencies: - p-try: 2.2.0 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-limit@5.0.0: - dependencies: - yocto-queue: 1.0.0 - - p-locate@3.0.0: - dependencies: - p-limit: 2.3.0 - - p-locate@4.1.0: - dependencies: - p-limit: 2.3.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - p-try@2.2.0: {} - - package-manager-detector@0.2.2: {} - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - - parenthesis@3.1.8: {} - - parse-entities@4.0.1: - dependencies: - '@types/unist': 2.0.11 - character-entities: 2.0.2 - character-entities-legacy: 3.0.0 - character-reference-invalid: 2.0.1 - decode-named-character-reference: 1.0.2 - is-alphanumerical: 2.0.1 - is-decimal: 2.0.1 - is-hexadecimal: 2.0.1 - - parse-json@2.2.0: - dependencies: - error-ex: 1.3.2 - - parse-json@5.2.0: - dependencies: - '@babel/code-frame': 7.25.9 - error-ex: 1.3.2 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 - - parse-node-version@1.0.1: - optional: true - - parse-rect@1.2.0: - dependencies: - pick-by-alias: 1.2.0 - - parse-svg-path@0.1.2: {} - - parse-unit@1.0.1: {} - - parse5@7.1.2: - dependencies: - entities: 4.5.0 - - partysocket@1.0.2: - dependencies: - event-target-shim: 6.0.2 - - path-data-parser@0.1.0: {} - - path-exists@2.1.0: - dependencies: - pinkie-promise: 2.0.1 - - path-exists@3.0.0: {} - - path-exists@4.0.0: {} - - path-is-absolute@1.0.1: {} - - path-key@3.1.1: {} - - path-key@4.0.0: {} - - path-parse@1.0.7: {} - - path-to-regexp@8.1.0: {} - - path-type@1.1.0: - dependencies: - graceful-fs: 4.2.11 - pify: 2.3.0 - pinkie-promise: 2.0.1 - - path-type@4.0.0: {} - - pathe@1.1.2: {} - - pathval@1.1.1: {} - - pathval@2.0.0: {} - - pbf@3.3.0: - dependencies: - ieee754: 1.2.1 - resolve-protobuf-schema: 2.1.0 - - performance-now@2.1.0: {} - - pick-by-alias@1.2.0: {} - - picocolors@0.2.1: {} - - picocolors@1.1.1: {} - - picomatch@2.3.1: {} - - pidtree@0.6.0: {} - - pify@2.3.0: {} - - pify@4.0.1: - optional: true - - pinkie-promise@2.0.1: - dependencies: - pinkie: 2.0.4 - - pinkie@2.0.4: {} - - pirates@4.0.5: {} - - pkg-types@1.2.1: - dependencies: - confbox: 0.1.8 - mlly: 1.7.2 - pathe: 1.1.2 - - playwright-core@1.46.1: {} - - playwright@1.46.1: - dependencies: - playwright-core: 1.46.1 - optionalDependencies: - fsevents: 2.3.2 - - plotly.js@2.35.2(mapbox-gl@1.13.3)(webpack@5.96.1(esbuild@0.21.5)): - dependencies: - '@plotly/d3': 3.8.2 - '@plotly/d3-sankey': 0.7.2 - '@plotly/d3-sankey-circular': 0.33.1 - '@plotly/mapbox-gl': 1.13.4(mapbox-gl@1.13.3) - '@turf/area': 7.1.0 - '@turf/bbox': 7.1.0 - '@turf/centroid': 7.1.0 - base64-arraybuffer: 1.0.2 - canvas-fit: 1.5.0 - color-alpha: 1.0.4 - color-normalize: 1.5.0 - color-parse: 2.0.0 - color-rgba: 2.1.1 - country-regex: 1.1.0 - css-loader: 7.1.2(webpack@5.96.1(esbuild@0.21.5)) - d3-force: 1.2.1 - d3-format: 1.4.5 - d3-geo: 1.12.1 - d3-geo-projection: 2.9.0 - d3-hierarchy: 1.1.9 - d3-interpolate: 3.0.1 - d3-time: 1.1.0 - d3-time-format: 2.3.0 - fast-isnumeric: 1.1.4 - gl-mat4: 1.2.0 - gl-text: 1.4.0 - has-hover: 1.0.1 - has-passive-events: 1.0.0 - is-mobile: 4.0.0 - maplibre-gl: 4.7.1 - mouse-change: 1.4.0 - mouse-event-offset: 3.0.2 - mouse-wheel: 1.2.0 - native-promise-only: 0.8.1 - parse-svg-path: 0.1.2 - point-in-polygon: 1.1.0 - polybooljs: 1.2.2 - probe-image-size: 7.2.3 - regl: '@plotly/regl@2.1.2' - regl-error2d: 2.0.12 - regl-line2d: 3.1.3 - regl-scatter2d: 3.3.1 - regl-splom: 1.0.14 - strongly-connected-components: 1.0.1 - style-loader: 4.0.0(webpack@5.96.1(esbuild@0.21.5)) - superscript-text: 1.0.0 - svg-path-sdf: 1.1.3 - tinycolor2: 1.6.0 - to-px: 1.0.1 - topojson-client: 3.1.0 - webgl-context: 2.2.0 - world-calendars: 1.0.3 - transitivePeerDependencies: - - '@rspack/core' - - mapbox-gl - - supports-color - - webpack - - pluralize@8.0.0: {} - - point-in-polygon@1.1.0: {} - - points-on-curve@0.2.0: {} - - points-on-path@0.2.1: - dependencies: - path-data-parser: 0.1.0 - points-on-curve: 0.2.0 - - polished@4.2.2: - dependencies: - '@babel/runtime': 7.25.6 - - polybooljs@1.2.2: {} - - possible-typed-array-names@1.0.0: {} - - postcss-calc@10.0.2(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - postcss-selector-parser: 6.1.2 - postcss-value-parser: 4.2.0 - - postcss-colormin@7.0.2(postcss@8.4.49): - dependencies: - browserslist: 4.24.2 - caniuse-api: 3.0.0 - colord: 2.9.3 - postcss: 8.4.49 - postcss-value-parser: 4.2.0 - - postcss-convert-values@7.0.3(postcss@8.4.49): - dependencies: - browserslist: 4.24.2 - postcss: 8.4.49 - postcss-value-parser: 4.2.0 - - postcss-discard-comments@7.0.2(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - postcss-selector-parser: 6.1.2 - - postcss-discard-duplicates@7.0.1(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - - postcss-discard-empty@7.0.0(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - - postcss-discard-overridden@7.0.0(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - - postcss-import@15.1.0(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - postcss-value-parser: 4.2.0 - read-cache: 1.0.0 - resolve: 1.22.8 - - postcss-js@4.0.1(postcss@8.4.49): - dependencies: - camelcase-css: 2.0.1 - postcss: 8.4.49 - - postcss-load-config@4.0.1(postcss@8.4.49): - dependencies: - lilconfig: 2.1.0 - yaml: 2.2.2 - optionalDependencies: - postcss: 8.4.49 - - postcss-merge-longhand@7.0.3(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - postcss-value-parser: 4.2.0 - stylehacks: 7.0.3(postcss@8.4.49) - - postcss-merge-rules@7.0.3(postcss@8.4.49): - dependencies: - browserslist: 4.24.2 - caniuse-api: 3.0.0 - cssnano-utils: 5.0.0(postcss@8.4.49) - postcss: 8.4.49 - postcss-selector-parser: 6.1.2 - - postcss-minify-font-values@7.0.0(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - postcss-value-parser: 4.2.0 - - postcss-minify-gradients@7.0.0(postcss@8.4.49): - dependencies: - colord: 2.9.3 - cssnano-utils: 5.0.0(postcss@8.4.49) - postcss: 8.4.49 - postcss-value-parser: 4.2.0 - - postcss-minify-params@7.0.2(postcss@8.4.49): - dependencies: - browserslist: 4.24.2 - cssnano-utils: 5.0.0(postcss@8.4.49) - postcss: 8.4.49 - postcss-value-parser: 4.2.0 - - postcss-minify-selectors@7.0.3(postcss@8.4.49): - dependencies: - cssesc: 3.0.0 - postcss: 8.4.49 - postcss-selector-parser: 6.1.2 - - postcss-modules-extract-imports@3.1.0(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - - postcss-modules-local-by-default@4.1.0(postcss@8.4.49): - dependencies: - icss-utils: 5.1.0(postcss@8.4.49) - postcss: 8.4.49 - postcss-selector-parser: 7.0.0 - postcss-value-parser: 4.2.0 - - postcss-modules-scope@3.2.1(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - postcss-selector-parser: 7.0.0 - - postcss-modules-values@4.0.0(postcss@8.4.49): - dependencies: - icss-utils: 5.1.0(postcss@8.4.49) - postcss: 8.4.49 - - postcss-nested@6.0.1(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - postcss-selector-parser: 6.1.2 - - postcss-normalize-charset@7.0.0(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - - postcss-normalize-display-values@7.0.0(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - postcss-value-parser: 4.2.0 - - postcss-normalize-positions@7.0.0(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - postcss-value-parser: 4.2.0 - - postcss-normalize-repeat-style@7.0.0(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - postcss-value-parser: 4.2.0 - - postcss-normalize-string@7.0.0(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - postcss-value-parser: 4.2.0 - - postcss-normalize-timing-functions@7.0.0(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - postcss-value-parser: 4.2.0 - - postcss-normalize-unicode@7.0.2(postcss@8.4.49): - dependencies: - browserslist: 4.24.2 - postcss: 8.4.49 - postcss-value-parser: 4.2.0 - - postcss-normalize-url@7.0.0(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - postcss-value-parser: 4.2.0 - - postcss-normalize-whitespace@7.0.0(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - postcss-value-parser: 4.2.0 - - postcss-ordered-values@7.0.1(postcss@8.4.49): - dependencies: - cssnano-utils: 5.0.0(postcss@8.4.49) - postcss: 8.4.49 - postcss-value-parser: 4.2.0 - - postcss-plugin-namespace@0.0.3: - dependencies: - postcss: 7.0.39 - - postcss-reduce-initial@7.0.2(postcss@8.4.49): - dependencies: - browserslist: 4.24.2 - caniuse-api: 3.0.0 - postcss: 8.4.49 - - postcss-reduce-transforms@7.0.0(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - postcss-value-parser: 4.2.0 - - postcss-resolve-nested-selector@0.1.6: {} - - postcss-safe-parser@7.0.0(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - - postcss-selector-parser@6.0.10: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss-selector-parser@6.1.2: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss-selector-parser@7.0.0: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss-svgo@7.0.1(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - postcss-value-parser: 4.2.0 - svgo: 3.3.2 - - postcss-unique-selectors@7.0.2(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - postcss-selector-parser: 6.1.2 - - postcss-value-parser@4.2.0: {} - - postcss@7.0.39: - dependencies: - picocolors: 0.2.1 - source-map: 0.6.1 - - postcss@8.4.49: - dependencies: - nanoid: 3.3.7 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - potpack@1.0.2: {} - - potpack@2.0.0: {} - - prelude-ls@1.1.2: {} - - prelude-ls@1.2.1: {} - - pretty-format@24.9.0: - dependencies: - '@jest/types': 24.9.0 - ansi-regex: 4.1.1 - ansi-styles: 3.2.1 - react-is: 16.13.1 - - pretty-format@27.5.1: - dependencies: - ansi-regex: 5.0.1 - ansi-styles: 5.2.0 - react-is: 17.0.2 - - pretty-format@29.7.0: - dependencies: - '@jest/schemas': 29.6.3 - ansi-styles: 5.2.0 - react-is: 18.3.1 - - probe-image-size@7.2.3: - dependencies: - lodash.merge: 4.6.2 - needle: 2.9.1 - stream-parser: 0.3.1 - transitivePeerDependencies: - - supports-color - - process-nextick-args@2.0.1: {} - - process@0.11.10: {} - - promise@7.3.1: - dependencies: - asap: 2.0.6 - - prop-types@15.8.1: - dependencies: - loose-envify: 1.4.0 - object-assign: 4.1.1 - react-is: 16.13.1 - - property-information@6.5.0: {} - - protocol-buffers-schema@3.6.0: {} - - prr@1.0.1: - optional: true - - psl@1.9.0: {} - - punycode@2.3.1: {} - - pyodide@0.26.2: - dependencies: - ws: 8.18.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - qs@6.11.2: - dependencies: - side-channel: 1.0.6 - - qs@6.5.3: {} - - querystringify@2.2.0: {} - - queue-microtask@1.2.3: {} - - quickselect@2.0.0: {} - - quickselect@3.0.0: {} - - quote-stream@1.0.2: - dependencies: - buffer-equal: 0.0.1 - minimist: 1.2.8 - through2: 2.0.5 - - raf@3.4.1: - dependencies: - performance-now: 2.1.0 - - randombytes@2.1.0: - dependencies: - safe-buffer: 5.2.1 - - react-arborist@3.4.0(@types/node@20.17.6)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - react: 18.3.1 - react-dnd: 14.0.5(@types/node@20.17.6)(@types/react@18.3.12)(react@18.3.1) - react-dnd-html5-backend: 14.1.0 - react-dom: 18.3.1(react@18.3.1) - react-window: 1.8.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - redux: 5.0.1 - use-sync-external-store: 1.2.0(react@18.3.1) - transitivePeerDependencies: - - '@types/hoist-non-react-statics' - - '@types/node' - - '@types/react' - - react-aria-components@1.3.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@internationalized/date': 3.5.5 - '@internationalized/string': 3.2.3 - '@react-aria/collections': 3.0.0-alpha.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/color': 3.0.0-rc.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/dnd': 3.7.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/focus': 3.18.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/menu': 3.15.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/toolbar': 3.0.0-beta.8(react@18.3.1) - '@react-aria/tree': 3.0.0-alpha.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-aria/virtualizer': 4.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-stately/color': 3.7.2(react@18.3.1) - '@react-stately/layout': 4.0.2(react@18.3.1) - '@react-stately/menu': 3.8.2(react@18.3.1) - '@react-stately/table': 3.12.2(react@18.3.1) - '@react-stately/utils': 3.10.3(react@18.3.1) - '@react-stately/virtualizer': 4.0.2(react@18.3.1) - '@react-types/color': 3.0.0-rc.1(react@18.3.1) - '@react-types/form': 3.7.6(react@18.3.1) - '@react-types/grid': 3.2.8(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@react-types/table': 3.10.1(react@18.3.1) - '@swc/helpers': 0.5.1 - client-only: 0.0.1 - react: 18.3.1 - react-aria: 3.34.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-dom: 18.3.1(react@18.3.1) - react-stately: 3.32.2(react@18.3.1) - use-sync-external-store: 1.2.0(react@18.3.1) - - react-aria@3.34.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@internationalized/string': 3.2.3 - '@react-aria/breadcrumbs': 3.5.16(react@18.3.1) - '@react-aria/button': 3.9.8(react@18.3.1) - '@react-aria/calendar': 3.5.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/checkbox': 3.14.6(react@18.3.1) - '@react-aria/combobox': 3.10.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/datepicker': 3.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/dialog': 3.5.17(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/dnd': 3.7.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/focus': 3.18.2(react@18.3.1) - '@react-aria/gridlist': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/i18n': 3.12.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/label': 3.7.11(react@18.3.1) - '@react-aria/link': 3.7.4(react@18.3.1) - '@react-aria/listbox': 3.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/menu': 3.15.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/meter': 3.4.16(react@18.3.1) - '@react-aria/numberfield': 3.11.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/overlays': 3.23.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/progress': 3.4.16(react@18.3.1) - '@react-aria/radio': 3.10.7(react@18.3.1) - '@react-aria/searchfield': 3.7.8(react@18.3.1) - '@react-aria/select': 3.14.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/selection': 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/separator': 3.4.2(react@18.3.1) - '@react-aria/slider': 3.7.11(react@18.3.1) - '@react-aria/ssr': 3.9.5(react@18.3.1) - '@react-aria/switch': 3.6.7(react@18.3.1) - '@react-aria/table': 3.15.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/tabs': 3.9.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/tag': 3.4.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/textfield': 3.14.8(react@18.3.1) - '@react-aria/tooltip': 3.7.7(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-aria/visually-hidden': 3.8.15(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - react-codemirror-merge@4.23.5(@babel/runtime@7.25.6)(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1))(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.34.3)(codemirror@6.0.1(@lezer/common@1.2.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@babel/runtime': 7.25.6 - '@codemirror/merge': 6.6.0 - '@codemirror/state': 6.4.1 - '@codemirror/theme-one-dark': 6.1.2 - '@codemirror/view': 6.34.3 - '@uiw/react-codemirror': 4.23.5(@babel/runtime@7.25.6)(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.1))(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.34.3)(codemirror@6.0.1(@lezer/common@1.2.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - codemirror: 6.0.1(@lezer/common@1.2.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - transitivePeerDependencies: - - '@codemirror/autocomplete' - - '@codemirror/language' - - '@codemirror/lint' - - '@codemirror/search' - - react-compiler-runtime@19.0.0-beta-8a03594-20241020(react@18.3.1): - dependencies: - react: 18.3.1 - - react-dnd-html5-backend@14.1.0: - dependencies: - dnd-core: 14.0.1 - - react-dnd@14.0.5(@types/node@20.17.6)(@types/react@18.3.12)(react@18.3.1): - dependencies: - '@react-dnd/invariant': 2.0.0 - '@react-dnd/shallowequal': 2.0.0 - dnd-core: 14.0.1 - fast-deep-equal: 3.1.3 - hoist-non-react-statics: 3.3.2 - react: 18.3.1 - optionalDependencies: - '@types/node': 20.17.6 - '@types/react': 18.3.12 - - react-docgen-typescript@2.2.2(typescript@5.5.4): - dependencies: - typescript: 5.5.4 - - react-docgen@7.0.1: - dependencies: - '@babel/core': 7.25.9 - '@babel/traverse': 7.25.9 - '@babel/types': 7.25.9 - '@types/babel__core': 7.20.5 - '@types/babel__traverse': 7.18.5 - '@types/doctrine': 0.0.9 - '@types/resolve': 1.20.4 - doctrine: 3.0.0 - resolve: 1.22.8 - strip-indent: 4.0.0 - transitivePeerDependencies: - - supports-color - - react-dom@18.3.1(react@18.3.1): - dependencies: - loose-envify: 1.4.0 - react: 18.3.1 - scheduler: 0.23.2 - - react-draggable@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - clsx: 1.2.1 - prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - react-dropzone@14.2.3(react@18.3.1): - dependencies: - attr-accept: 2.2.2 - file-selector: 0.6.0 - prop-types: 15.8.1 - react: 18.3.1 - - react-error-boundary@3.1.4(react@18.3.1): - dependencies: - '@babel/runtime': 7.25.6 - react: 18.3.1 - - react-error-boundary@4.0.13(react@18.3.1): - dependencies: - '@babel/runtime': 7.25.6 - react: 18.3.1 - - react-grid-layout@1.4.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - clsx: 2.1.1 - fast-equals: 4.0.3 - prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-draggable: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-resizable: 3.0.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - resize-observer-polyfill: 1.5.1 - - react-hook-form@7.52.2(react@18.3.1): - dependencies: - react: 18.3.1 - - react-is@16.13.1: {} - - react-is@17.0.2: {} - - react-is@18.3.1: {} - - react-markdown@9.0.1(@types/react@18.3.12)(react@18.3.1): - dependencies: - '@types/hast': 3.0.4 - '@types/react': 18.3.12 - devlop: 1.1.0 - hast-util-to-jsx-runtime: 2.3.2 - html-url-attributes: 3.0.1 - mdast-util-to-hast: 13.2.0 - react: 18.3.1 - remark-parse: 11.0.0 - remark-rehype: 11.1.1 - unified: 11.0.5 - unist-util-visit: 5.0.0 - vfile: 6.0.3 - transitivePeerDependencies: - - supports-color - - react-plotly.js@2.6.0(patch_hash=bpeex744nmt6722etqry6essaq)(plotly.js@2.35.2(mapbox-gl@1.13.3)(webpack@5.96.1(esbuild@0.21.5)))(react@18.3.1): - dependencies: - plotly.js: 2.35.2(mapbox-gl@1.13.3)(webpack@5.96.1(esbuild@0.21.5)) - prop-types: 15.8.1 - react: 18.3.1 - - react-property@2.0.2: {} - - react-refresh@0.14.2: {} - - react-remove-scroll-bar@2.3.4(@types/react@18.3.12)(react@18.3.1): - dependencies: - react: 18.3.1 - react-style-singleton: 2.2.1(@types/react@18.3.12)(react@18.3.1) - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.12 - - react-remove-scroll@2.5.5(@types/react@18.3.12)(react@18.3.1): - dependencies: - react: 18.3.1 - react-remove-scroll-bar: 2.3.4(@types/react@18.3.12)(react@18.3.1) - react-style-singleton: 2.2.1(@types/react@18.3.12)(react@18.3.1) - tslib: 2.8.1 - use-callback-ref: 1.3.0(@types/react@18.3.12)(react@18.3.1) - use-sidecar: 1.1.2(@types/react@18.3.12)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - - react-resizable-panels@2.0.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - react-resizable@3.0.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - prop-types: 15.8.1 - react: 18.3.1 - react-draggable: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - transitivePeerDependencies: - - react-dom - - react-shallow-renderer@16.15.0(react@18.3.1): - dependencies: - object-assign: 4.1.1 - react: 18.3.1 - react-is: 18.3.1 - - react-stately@3.32.2(react@18.3.1): - dependencies: - '@react-stately/calendar': 3.5.4(react@18.3.1) - '@react-stately/checkbox': 3.6.8(react@18.3.1) - '@react-stately/collections': 3.10.9(react@18.3.1) - '@react-stately/combobox': 3.9.2(react@18.3.1) - '@react-stately/data': 3.11.6(react@18.3.1) - '@react-stately/datepicker': 3.10.2(react@18.3.1) - '@react-stately/dnd': 3.4.2(react@18.3.1) - '@react-stately/form': 3.0.5(react@18.3.1) - '@react-stately/list': 3.10.8(react@18.3.1) - '@react-stately/menu': 3.8.2(react@18.3.1) - '@react-stately/numberfield': 3.9.6(react@18.3.1) - '@react-stately/overlays': 3.6.10(react@18.3.1) - '@react-stately/radio': 3.10.7(react@18.3.1) - '@react-stately/searchfield': 3.5.6(react@18.3.1) - '@react-stately/select': 3.6.7(react@18.3.1) - '@react-stately/selection': 3.16.2(react@18.3.1) - '@react-stately/slider': 3.5.7(react@18.3.1) - '@react-stately/table': 3.12.2(react@18.3.1) - '@react-stately/tabs': 3.6.9(react@18.3.1) - '@react-stately/toggle': 3.7.7(react@18.3.1) - '@react-stately/tooltip': 3.4.12(react@18.3.1) - '@react-stately/tree': 3.8.4(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - react: 18.3.1 - - react-style-singleton@2.2.1(@types/react@18.3.12)(react@18.3.1): - dependencies: - get-nonce: 1.0.1 - invariant: 2.2.4 - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.12 - - react-test-renderer@18.3.1(react@18.3.1): - dependencies: - react: 18.3.1 - react-is: 18.3.1 - react-shallow-renderer: 16.15.0(react@18.3.1) - scheduler: 0.23.2 - - react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@babel/runtime': 7.25.6 - dom-helpers: 5.2.1 - loose-envify: 1.4.0 - prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - react-use-event-hook@0.9.6(react@18.3.1): - dependencies: - react: 18.3.1 - - react-vega@7.6.0(react@18.3.1)(vega-lite@5.21.0(vega@5.30.0))(vega@5.30.0): - dependencies: - '@types/react': 18.3.12 - fast-deep-equal: 3.1.3 - prop-types: 15.8.1 - react: 18.3.1 - vega: 5.30.0 - vega-embed: 6.23.0(vega-lite@5.21.0(vega@5.30.0))(vega@5.30.0) - vega-lite: 5.21.0(vega@5.30.0) - - react-virtuoso@4.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - react-window@1.8.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@babel/runtime': 7.25.6 - memoize-one: 5.2.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - react@18.3.1: - dependencies: - loose-envify: 1.4.0 - - reactflow@11.11.4(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@reactflow/background': 11.3.14(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@reactflow/controls': 11.2.14(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@reactflow/core': 11.11.4(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@reactflow/minimap': 11.7.14(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@reactflow/node-resizer': 2.2.14(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@reactflow/node-toolbar': 1.3.14(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - transitivePeerDependencies: - - '@types/react' - - immer - - read-cache@1.0.0: - dependencies: - pify: 2.3.0 - - read-package-json-fast@3.0.2: - dependencies: - json-parse-even-better-errors: 3.0.1 - npm-normalize-package-bin: 3.0.1 - - read-pkg-up@1.0.1: - dependencies: - find-up: 1.1.2 - read-pkg: 1.1.0 - - read-pkg-up@7.0.1: - dependencies: - find-up: 4.1.0 - read-pkg: 5.2.0 - type-fest: 0.8.1 - - read-pkg@1.1.0: - dependencies: - load-json-file: 1.1.0 - normalize-package-data: 2.5.0 - path-type: 1.1.0 - - read-pkg@5.2.0: - dependencies: - '@types/normalize-package-data': 2.4.1 - normalize-package-data: 2.5.0 - parse-json: 5.2.0 - type-fest: 0.6.0 - - readable-stream@1.0.34: - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 0.0.1 - string_decoder: 0.10.31 - - readable-stream@2.3.8: - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 1.0.0 - process-nextick-args: 2.0.1 - safe-buffer: 5.1.2 - string_decoder: 1.1.1 - util-deprecate: 1.0.2 - - readdirp@3.6.0: - dependencies: - picomatch: 2.3.1 - - recast@0.23.6: - dependencies: - ast-types: 0.16.1 - esprima: 4.0.1 - source-map: 0.6.1 - tiny-invariant: 1.3.3 - tslib: 2.8.1 - - redent@3.0.0: - dependencies: - indent-string: 4.0.0 - strip-indent: 3.0.0 - - redux@4.2.1: - dependencies: - '@babel/runtime': 7.25.6 - - redux@5.0.1: {} - - reflect.getprototypeof@1.0.4: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-abstract: 1.23.3 - get-intrinsic: 1.2.4 - globalthis: 1.0.3 - which-builtin-type: 1.1.3 - - regenerator-runtime@0.14.1: {} - - regexp-tree@0.1.27: {} - - regexp.prototype.flags@1.5.2: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-errors: 1.3.0 - set-function-name: 2.0.2 - - regjsparser@0.10.0: - dependencies: - jsesc: 0.5.0 - - regl-error2d@2.0.12: - dependencies: - array-bounds: 1.0.1 - color-normalize: 1.5.0 - flatten-vertex-data: 1.0.2 - object-assign: 4.1.1 - pick-by-alias: 1.2.0 - to-float32: 1.1.0 - update-diff: 1.1.0 - - regl-line2d@3.1.3: - dependencies: - array-bounds: 1.0.1 - array-find-index: 1.0.2 - array-normalize: 1.1.4 - color-normalize: 1.5.0 - earcut: 2.2.4 - es6-weak-map: 2.0.3 - flatten-vertex-data: 1.0.2 - object-assign: 4.1.1 - parse-rect: 1.2.0 - pick-by-alias: 1.2.0 - to-float32: 1.1.0 - - regl-scatter2d@3.3.1: - dependencies: - '@plotly/point-cluster': 3.1.9 - array-range: 1.0.1 - array-rearrange: 2.2.2 - clamp: 1.0.1 - color-id: 1.1.0 - color-normalize: 1.5.0 - color-rgba: 2.1.1 - flatten-vertex-data: 1.0.2 - glslify: 7.1.1 - is-iexplorer: 1.0.0 - object-assign: 4.1.1 - parse-rect: 1.2.0 - pick-by-alias: 1.2.0 - to-float32: 1.1.0 - update-diff: 1.1.0 - - regl-splom@1.0.14: - dependencies: - array-bounds: 1.0.1 - array-range: 1.0.1 - color-alpha: 1.0.4 - flatten-vertex-data: 1.0.2 - parse-rect: 1.2.0 - pick-by-alias: 1.2.0 - raf: 3.4.1 - regl-scatter2d: 3.3.1 - - regl@2.1.1: {} - - remark-parse@11.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2 - micromark-util-types: 2.0.1 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - - remark-rehype@11.1.1: - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - mdast-util-to-hast: 13.2.0 - unified: 11.0.5 - vfile: 6.0.3 - - request@2.88.2: - dependencies: - aws-sign2: 0.7.0 - aws4: 1.12.0 - caseless: 0.12.0 - combined-stream: 1.0.8 - extend: 3.0.2 - forever-agent: 0.6.1 - form-data: 2.3.3 - har-validator: 5.1.5 - http-signature: 1.2.0 - is-typedarray: 1.0.0 - isstream: 0.1.2 - json-stringify-safe: 5.0.1 - mime-types: 2.1.35 - oauth-sign: 0.9.0 - performance-now: 2.1.0 - qs: 6.5.3 - safe-buffer: 5.2.1 - tough-cookie: 2.5.0 - tunnel-agent: 0.6.0 - uuid: 3.4.0 - - require-directory@2.1.1: {} - - require-from-string@2.0.2: {} - - require-main-filename@1.0.1: {} - - require-main-filename@2.0.0: {} - - requires-port@1.0.0: {} - - resize-observer-polyfill@1.5.1: {} - - resolve-from@4.0.0: {} - - resolve-from@5.0.0: {} - - resolve-protobuf-schema@2.1.0: - dependencies: - protocol-buffers-schema: 3.6.0 - - resolve@0.6.3: {} - - resolve@1.22.8: - dependencies: - is-core-module: 2.13.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - resolve@2.0.0-next.5: - dependencies: - is-core-module: 2.13.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - reusify@1.0.4: {} - - right-now@1.0.0: {} - - rimraf@3.0.2: - dependencies: - glob: 7.2.0 - - robust-predicates@3.0.2: {} - - rollup@4.21.3: - dependencies: - '@types/estree': 1.0.5 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.21.3 - '@rollup/rollup-android-arm64': 4.21.3 - '@rollup/rollup-darwin-arm64': 4.21.3 - '@rollup/rollup-darwin-x64': 4.21.3 - '@rollup/rollup-linux-arm-gnueabihf': 4.21.3 - '@rollup/rollup-linux-arm-musleabihf': 4.21.3 - '@rollup/rollup-linux-arm64-gnu': 4.21.3 - '@rollup/rollup-linux-arm64-musl': 4.21.3 - '@rollup/rollup-linux-powerpc64le-gnu': 4.21.3 - '@rollup/rollup-linux-riscv64-gnu': 4.21.3 - '@rollup/rollup-linux-s390x-gnu': 4.21.3 - '@rollup/rollup-linux-x64-gnu': 4.21.3 - '@rollup/rollup-linux-x64-musl': 4.21.3 - '@rollup/rollup-win32-arm64-msvc': 4.21.3 - '@rollup/rollup-win32-ia32-msvc': 4.21.3 - '@rollup/rollup-win32-x64-msvc': 4.21.3 - fsevents: 2.3.3 - - roughjs@4.6.6: - dependencies: - hachure-fill: 0.5.2 - path-data-parser: 0.1.0 - points-on-curve: 0.2.0 - points-on-path: 0.2.1 - - rpc-anywhere@1.7.0: - dependencies: - browser-namespace: 1.4.0 - - rrweb-cssom@0.6.0: {} - - rrweb-cssom@0.7.1: {} - - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - rw@1.3.3: {} - - safe-array-concat@1.1.2: - dependencies: - call-bind: 1.0.7 - get-intrinsic: 1.2.4 - has-symbols: 1.0.3 - isarray: 2.0.5 - - safe-buffer@5.1.2: {} - - safe-buffer@5.2.1: {} - - safe-regex-test@1.0.3: - dependencies: - call-bind: 1.0.7 - es-errors: 1.3.0 - is-regex: 1.1.4 - - safer-buffer@2.1.2: {} - - sax@1.4.1: {} - - saxes@6.0.0: - dependencies: - xmlchars: 2.2.0 - - scheduler@0.23.2: - dependencies: - loose-envify: 1.4.0 - - schema-utils@3.3.0: - dependencies: - '@types/json-schema': 7.0.15 - ajv: 6.12.6 - ajv-keywords: 3.5.2(ajv@6.12.6) - - secure-json-parse@2.7.0: {} - - semver@5.7.2: {} - - semver@6.3.1: {} - - semver@7.6.3: {} - - serialize-javascript@6.0.2: - dependencies: - randombytes: 2.1.0 - - set-blocking@2.0.0: {} - - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.2.4 - gopd: 1.0.1 - has-property-descriptors: 1.0.2 - - set-function-name@2.0.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - functions-have-names: 1.2.3 - has-property-descriptors: 1.0.2 - - shallow-copy@0.0.1: {} - - shapefile@0.3.1: - dependencies: - d3-queue: 1.2.3 - iconv-lite: 0.2.11 - optimist: 0.3.7 - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - shell-quote@1.7.4: {} - - side-channel@1.0.6: - dependencies: - call-bind: 1.0.7 - es-errors: 1.3.0 - get-intrinsic: 1.2.4 - object-inspect: 1.13.1 - - siginfo@2.0.0: {} - - signal-exit@4.1.0: {} - - signum@1.0.0: {} - - slash@3.0.0: {} - - slice-ansi@4.0.0: - dependencies: - ansi-styles: 4.3.0 - astral-regex: 2.0.0 - is-fullwidth-code-point: 3.0.0 - - source-map-js@1.2.1: {} - - source-map-support@0.5.21: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - - source-map@0.5.7: {} - - source-map@0.6.1: {} - - space-separated-tokens@2.0.2: {} - - spawn-sync@1.0.15: - dependencies: - concat-stream: 1.6.2 - os-shim: 0.1.3 - - spdx-correct@3.1.1: - dependencies: - spdx-expression-parse: 3.0.1 - spdx-license-ids: 3.0.12 - - spdx-exceptions@2.3.0: {} - - spdx-expression-parse@3.0.1: - dependencies: - spdx-exceptions: 2.3.0 - spdx-license-ids: 3.0.12 - - spdx-license-ids@3.0.12: {} - - sshpk@1.18.0: - dependencies: - asn1: 0.2.6 - assert-plus: 1.0.0 - bcrypt-pbkdf: 1.0.2 - dashdash: 1.14.1 - ecc-jsbn: 0.1.2 - getpass: 0.1.7 - jsbn: 0.1.1 - safer-buffer: 2.1.2 - tweetnacl: 0.14.5 - - sswr@2.1.0: - dependencies: - swrev: 4.0.0 - - stack-trace@0.0.9: {} - - stackback@0.0.2: {} - - static-eval@2.1.0: - dependencies: - escodegen: 1.14.3 - - static-module@2.2.5: - dependencies: - concat-stream: 1.6.2 - convert-source-map: 1.9.0 - duplexer2: 0.1.4 - escodegen: 1.9.1 - falafel: 2.2.5 - has: 1.0.3 - magic-string: 0.22.5 - merge-source-map: 1.0.4 - object-inspect: 1.4.1 - quote-stream: 1.0.2 - readable-stream: 2.3.8 - shallow-copy: 0.0.1 - static-eval: 2.1.0 - through2: 2.0.5 - - std-env@3.6.0: {} - - stop-iteration-iterator@1.0.0: - dependencies: - internal-slot: 1.0.7 - - storybook@8.4.6: - dependencies: - '@storybook/core': 8.4.6 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - stream-parser@0.3.1: - dependencies: - debug: 2.6.9 - transitivePeerDependencies: - - supports-color - - stream-shift@1.0.1: {} - - strict-event-emitter-types@2.0.0: {} - - string-dedent@3.0.1: {} - - string-split-by@1.0.0: - dependencies: - parenthesis: 3.1.8 - - string-width@1.0.2: - dependencies: - code-point-at: 1.1.0 - is-fullwidth-code-point: 1.0.0 - strip-ansi: 3.0.1 - - string-width@3.1.0: - dependencies: - emoji-regex: 7.0.3 - is-fullwidth-code-point: 2.0.0 - strip-ansi: 5.2.0 - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string.prototype.includes@2.0.0: - dependencies: - define-properties: 1.2.1 - es-abstract: 1.23.3 - - string.prototype.matchall@4.0.11: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-abstract: 1.23.3 - es-errors: 1.3.0 - es-object-atoms: 1.0.0 - get-intrinsic: 1.2.4 - gopd: 1.0.1 - has-symbols: 1.0.3 - internal-slot: 1.0.7 - regexp.prototype.flags: 1.5.2 - set-function-name: 2.0.2 - side-channel: 1.0.6 - - string.prototype.trim@1.2.9: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-abstract: 1.23.3 - es-object-atoms: 1.0.0 - - string.prototype.trimend@1.0.8: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-object-atoms: 1.0.0 - - string.prototype.trimstart@1.0.8: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-object-atoms: 1.0.0 - - string_decoder@0.10.31: {} - - string_decoder@1.1.1: - dependencies: - safe-buffer: 5.1.2 - - stringify-entities@4.0.4: - dependencies: - character-entities-html4: 2.1.0 - character-entities-legacy: 3.0.0 - - strip-ansi@3.0.1: - dependencies: - ansi-regex: 2.1.1 - - strip-ansi@5.2.0: - dependencies: - ansi-regex: 4.1.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-ansi@7.1.0: - dependencies: - ansi-regex: 6.0.1 - - strip-bom@2.0.0: - dependencies: - is-utf8: 0.2.1 - - strip-bom@3.0.0: {} - - strip-final-newline@3.0.0: {} - - strip-indent@3.0.0: - dependencies: - min-indent: 1.0.1 - - strip-indent@4.0.0: - dependencies: - min-indent: 1.0.1 - - strip-json-comments@3.1.1: {} - - strip-literal@2.0.0: - dependencies: - js-tokens: 8.0.3 - - strongly-connected-components@1.0.1: {} - - style-loader@4.0.0(webpack@5.96.1(esbuild@0.21.5)): - dependencies: - webpack: 5.96.1(esbuild@0.21.5) - - style-mod@4.1.2: {} - - style-to-js@1.1.12: - dependencies: - style-to-object: 1.0.6 - - style-to-object@1.0.6: - dependencies: - inline-style-parser: 0.2.3 - - stylehacks@7.0.3(postcss@8.4.49): - dependencies: - browserslist: 4.24.2 - postcss: 8.4.49 - postcss-selector-parser: 6.1.2 - - stylelint-config-recommended@14.0.1(stylelint@16.8.2(typescript@5.5.4)): - dependencies: - stylelint: 16.8.2(typescript@5.5.4) - - stylelint-config-standard@36.0.1(stylelint@16.8.2(typescript@5.5.4)): - dependencies: - stylelint: 16.8.2(typescript@5.5.4) - stylelint-config-recommended: 14.0.1(stylelint@16.8.2(typescript@5.5.4)) - - stylelint@16.8.2(typescript@5.5.4): - dependencies: - '@csstools/css-parser-algorithms': 3.0.2(@csstools/css-tokenizer@3.0.2) - '@csstools/css-tokenizer': 3.0.2 - '@csstools/media-query-list-parser': 3.0.1(@csstools/css-parser-algorithms@3.0.2(@csstools/css-tokenizer@3.0.2))(@csstools/css-tokenizer@3.0.2) - '@csstools/selector-specificity': 4.0.0(postcss-selector-parser@6.1.2) - '@dual-bundle/import-meta-resolve': 4.1.0 - balanced-match: 2.0.0 - colord: 2.9.3 - cosmiconfig: 9.0.0(typescript@5.5.4) - css-functions-list: 3.2.2 - css-tree: 2.3.1 - debug: 4.3.6 - fast-glob: 3.3.2 - fastest-levenshtein: 1.0.16 - file-entry-cache: 9.0.0 - global-modules: 2.0.0 - globby: 11.1.0 - globjoin: 0.1.4 - html-tags: 3.3.1 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-plain-object: 5.0.0 - known-css-properties: 0.34.0 - mathml-tag-names: 2.1.3 - meow: 13.2.0 - micromatch: 4.0.7 - normalize-path: 3.0.0 - picocolors: 1.1.1 - postcss: 8.4.49 - postcss-resolve-nested-selector: 0.1.6 - postcss-safe-parser: 7.0.0(postcss@8.4.49) - postcss-selector-parser: 6.1.2 - postcss-value-parser: 4.2.0 - resolve-from: 5.0.0 - string-width: 4.2.3 - strip-ansi: 7.1.0 - supports-hyperlinks: 3.0.0 - svg-tags: 1.0.0 - table: 6.8.2 - write-file-atomic: 5.0.1 - transitivePeerDependencies: - - supports-color - - typescript - - stylis@4.2.0: {} - - stylis@4.3.4: {} - - sucrase@3.32.0: - dependencies: - '@jridgewell/gen-mapping': 0.3.5 - commander: 4.1.1 - glob: 7.1.6 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.5 - ts-interface-checker: 0.1.13 - - supercluster@7.1.5: - dependencies: - kdbush: 3.0.0 - - supercluster@8.0.1: - dependencies: - kdbush: 4.0.2 - - superscript-text@1.0.0: {} - - supports-color@5.5.0: - dependencies: - has-flag: 3.0.0 - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-color@8.1.1: - dependencies: - has-flag: 4.0.0 - - supports-hyperlinks@3.0.0: - dependencies: - has-flag: 4.0.0 - supports-color: 7.2.0 - - supports-preserve-symlinks-flag@1.0.0: {} - - svg-arc-to-cubic-bezier@3.2.0: {} - - svg-path-bounds@1.0.2: - dependencies: - abs-svg-path: 0.1.1 - is-svg-path: 1.0.2 - normalize-svg-path: 1.1.0 - parse-svg-path: 0.1.2 - - svg-path-sdf@1.1.3: - dependencies: - bitmap-sdf: 1.0.4 - draw-svg-path: 1.0.0 - is-svg-path: 1.0.2 - parse-svg-path: 0.1.2 - svg-path-bounds: 1.0.2 - - svg-tags@1.0.0: {} - - svgo@3.3.2: - dependencies: - '@trysound/sax': 0.2.0 - commander: 7.2.0 - css-select: 5.1.0 - css-tree: 2.3.1 - css-what: 6.1.0 - csso: 5.0.5 - picocolors: 1.1.1 - - swiper@11.1.9: {} - - swr@2.2.5(react@18.3.1): - dependencies: - client-only: 0.0.1 - react: 18.3.1 - use-sync-external-store: 1.2.0(react@18.3.1) - - swrev@4.0.0: {} - - swrv@1.0.4: {} - - symbol-tree@3.2.4: {} - - sync-request@2.2.0: - dependencies: - concat-stream: 1.6.2 - http-response-object: 1.1.0 - spawn-sync: 1.0.15 - then-request: 2.2.0 - - table@6.8.2: - dependencies: - ajv: 8.12.0 - lodash.truncate: 4.4.2 - slice-ansi: 4.0.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - tailwind-merge@2.5.2: {} - - tailwindcss-animate@1.0.7(tailwindcss@3.4.10): - dependencies: - tailwindcss: 3.4.10 - - tailwindcss@3.4.10: - dependencies: - '@alloc/quick-lru': 5.2.0 - arg: 5.0.2 - chokidar: 3.5.3 - didyoumean: 1.2.2 - dlv: 1.1.3 - fast-glob: 3.3.2 - glob-parent: 6.0.2 - is-glob: 4.0.3 - jiti: 1.21.0 - lilconfig: 2.1.0 - micromatch: 4.0.7 - normalize-path: 3.0.0 - object-hash: 3.0.0 - picocolors: 1.1.1 - postcss: 8.4.49 - postcss-import: 15.1.0(postcss@8.4.49) - postcss-js: 4.0.1(postcss@8.4.49) - postcss-load-config: 4.0.1(postcss@8.4.49) - postcss-nested: 6.0.1(postcss@8.4.49) - postcss-selector-parser: 6.1.2 - resolve: 1.22.8 - sucrase: 3.32.0 - transitivePeerDependencies: - - ts-node - - tapable@2.2.1: {} - - terser-webpack-plugin@5.3.10(esbuild@0.21.5)(webpack@5.96.1(esbuild@0.21.5)): - dependencies: - '@jridgewell/trace-mapping': 0.3.25 - jest-worker: 27.5.1 - schema-utils: 3.3.0 - serialize-javascript: 6.0.2 - terser: 5.36.0 - webpack: 5.96.1(esbuild@0.21.5) - optionalDependencies: - esbuild: 0.21.5 - - terser@5.36.0: - dependencies: - '@jridgewell/source-map': 0.3.6 - acorn: 8.14.0 - commander: 2.20.3 - source-map-support: 0.5.21 - - text-table@0.2.0: {} - - thememirror@2.0.1(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3): - dependencies: - '@codemirror/language': 6.10.3 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.34.3 - - then-request@2.2.0: - dependencies: - caseless: 0.11.0 - concat-stream: 1.6.2 - http-basic: 2.5.1 - http-response-object: 1.1.0 - promise: 7.3.1 - qs: 6.11.2 - - thenify-all@1.6.0: - dependencies: - thenify: 3.3.1 - - thenify@3.3.1: - dependencies: - any-promise: 1.3.0 - - through2@0.6.5: - dependencies: - readable-stream: 1.0.34 - xtend: 4.0.2 - - through2@2.0.5: - dependencies: - readable-stream: 2.3.8 - xtend: 4.0.2 - - timestring@7.0.0: {} - - tiny-invariant@1.3.3: {} - - tinybench@2.5.1: {} - - tinycolor2@1.6.0: {} - - tinyexec@0.3.1: {} - - tinypool@0.8.4: {} - - tinyqueue@2.0.3: {} - - tinyqueue@3.0.0: {} - - tinyrainbow@1.2.0: {} - - tinyspy@2.2.0: {} - - tinyspy@3.0.2: {} - - to-float32@1.1.0: {} - - to-px@1.0.1: - dependencies: - parse-unit: 1.0.1 - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - toggle-selection@1.0.6: {} - - topojson-client@3.1.0: - dependencies: - commander: 2.20.3 - - topojson@1.6.27: - dependencies: - d3: 3.5.17 - d3-geo-projection: 0.2.16 - d3-queue: 2.0.3 - optimist: 0.3.7 - rw: 1.3.3 - shapefile: 0.3.1 - - tough-cookie@2.5.0: - dependencies: - psl: 1.9.0 - punycode: 2.3.1 - - tough-cookie@4.1.4: - dependencies: - psl: 1.9.0 - punycode: 2.3.1 - universalify: 0.2.0 - url-parse: 1.5.10 - - tr46@0.0.3: {} - - tr46@5.0.0: - dependencies: - punycode: 2.3.1 - - trim-lines@3.0.1: {} - - trim-right@1.0.1: {} - - trough@2.2.0: {} - - ts-api-utils@1.3.0(typescript@5.5.4): - dependencies: - typescript: 5.5.4 - - ts-dedent@2.2.0: {} - - ts-interface-checker@0.1.13: {} - - tsconfck@3.0.3(typescript@5.5.4): - optionalDependencies: - typescript: 5.5.4 - - tsconfig-paths@4.2.0: - dependencies: - json5: 2.2.3 - minimist: 1.2.8 - strip-bom: 3.0.0 - - tslib@1.10.0: {} - - tslib@1.14.1: {} - - tslib@2.6.3: {} - - tslib@2.8.1: {} - - tunnel-agent@0.6.0: - dependencies: - safe-buffer: 5.2.1 - - turbo-darwin-64@2.0.14: - optional: true - - turbo-darwin-arm64@2.0.14: - optional: true - - turbo-linux-64@2.0.14: - optional: true - - turbo-linux-arm64@2.0.14: - optional: true - - turbo-windows-64@2.0.14: - optional: true - - turbo-windows-arm64@2.0.14: - optional: true - - turbo@2.0.14: - optionalDependencies: - turbo-darwin-64: 2.0.14 - turbo-darwin-arm64: 2.0.14 - turbo-linux-64: 2.0.14 - turbo-linux-arm64: 2.0.14 - turbo-windows-64: 2.0.14 - turbo-windows-arm64: 2.0.14 - - tweetnacl@0.14.5: {} - - type-check@0.3.2: - dependencies: - prelude-ls: 1.1.2 - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - type-detect@4.0.8: {} - - type-fest@0.20.2: {} - - type-fest@0.6.0: {} - - type-fest@0.8.1: {} - - type-fest@2.19.0: {} - - type@1.2.0: {} - - type@2.7.2: {} - - typed-array-buffer@1.0.2: - dependencies: - call-bind: 1.0.7 - es-errors: 1.3.0 - is-typed-array: 1.1.13 - - typed-array-byte-length@1.0.1: - dependencies: - call-bind: 1.0.7 - for-each: 0.3.3 - gopd: 1.0.1 - has-proto: 1.0.3 - is-typed-array: 1.1.13 - - typed-array-byte-offset@1.0.2: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.7 - for-each: 0.3.3 - gopd: 1.0.1 - has-proto: 1.0.3 - is-typed-array: 1.1.13 - - typed-array-length@1.0.6: - dependencies: - call-bind: 1.0.7 - for-each: 0.3.3 - gopd: 1.0.1 - has-proto: 1.0.3 - is-typed-array: 1.1.13 - possible-typed-array-names: 1.0.0 - - typedarray-pool@1.2.0: - dependencies: - bit-twiddle: 1.0.2 - dup: 1.0.0 - - typedarray@0.0.6: {} - - typescript-json-schema@0.9.2: - dependencies: - glob: 7.1.6 - json-stable-stringify: 1.1.1 - typescript: 2.1.6 - yargs: 6.6.0 - - typescript-memoize@1.1.1: {} - - typescript@2.1.6: {} - - typescript@5.5.4: {} - - ufo@1.5.4: {} - - unbox-primitive@1.0.2: - dependencies: - call-bind: 1.0.7 - has-bigints: 1.0.2 - has-symbols: 1.0.3 - which-boxed-primitive: 1.0.2 - - undici-types@6.19.8: {} - - unified@11.0.5: - dependencies: - '@types/unist': 3.0.3 - bail: 2.0.2 - devlop: 1.1.0 - extend: 3.0.2 - is-plain-obj: 4.1.0 - trough: 2.2.0 - vfile: 6.0.3 - - unist-util-is@6.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-position@5.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-stringify-position@4.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-visit-parents@6.0.1: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.0 - - unist-util-visit@5.0.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.0 - unist-util-visit-parents: 6.0.1 - - universalify@0.2.0: {} - - unplugin@1.4.0: - dependencies: - acorn: 8.14.0 - chokidar: 3.5.3 - webpack-sources: 3.2.3 - webpack-virtual-modules: 0.5.0 - - unquote@1.1.1: {} - - update-browserslist-db@1.1.1(browserslist@4.24.2): - dependencies: - browserslist: 4.24.2 - escalade: 3.2.0 - picocolors: 1.1.1 - - update-diff@1.1.0: {} - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - url-parse@1.5.10: - dependencies: - querystringify: 2.2.0 - requires-port: 1.0.0 - - use-callback-ref@1.3.0(@types/react@18.3.12)(react@18.3.1): - dependencies: - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.12 - - use-resize-observer@9.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@juggle/resize-observer': 3.4.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - use-sidecar@1.1.2(@types/react@18.3.12)(react@18.3.1): - dependencies: - detect-node-es: 1.1.0 - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.12 - - use-sync-external-store@1.2.0(react@18.3.1): - dependencies: - react: 18.3.1 - - util-deprecate@1.0.2: {} - - util@0.12.5: - dependencies: - inherits: 2.0.4 - is-arguments: 1.1.1 - is-generator-function: 1.0.10 - is-typed-array: 1.1.13 - which-typed-array: 1.1.15 - - uuid@3.4.0: {} - - uuid@9.0.1: {} - - validate-npm-package-license@3.0.4: - dependencies: - spdx-correct: 3.1.1 - spdx-expression-parse: 3.0.1 - - vega-canvas@1.2.7: {} - - vega-crossfilter@4.1.2: - dependencies: - d3-array: 3.2.4 - vega-dataflow: 5.7.6 - vega-util: 1.17.2 - transitivePeerDependencies: - - encoding - - vega-dataflow@5.7.6: - dependencies: - vega-format: 1.1.2 - vega-loader: 4.5.2 - vega-util: 1.17.2 - transitivePeerDependencies: - - encoding - - vega-embed@6.23.0(vega-lite@5.21.0(vega@5.30.0))(vega@5.30.0): - dependencies: - fast-json-patch: 3.1.1 - json-stringify-pretty-compact: 3.0.0 - semver: 7.6.3 - tslib: 2.8.1 - vega: 5.30.0 - vega-interpreter: 1.0.5 - vega-lite: 5.21.0(vega@5.30.0) - vega-schema-url-parser: 2.2.0 - vega-themes: 2.14.0(vega-lite@5.21.0(vega@5.30.0))(vega@5.30.0) - vega-tooltip: 0.33.0 - - vega-encode@4.10.1: - dependencies: - d3-array: 3.2.4 - d3-interpolate: 3.0.1 - vega-dataflow: 5.7.6 - vega-scale: 7.4.1 - vega-util: 1.17.2 - transitivePeerDependencies: - - encoding - - vega-event-selector@2.0.6: {} - - vega-event-selector@3.0.1: {} - - vega-expression@2.6.6: - dependencies: - vega-util: 1.17.2 - - vega-expression@5.1.1: - dependencies: - '@types/estree': 1.0.6 - vega-util: 1.17.2 - - vega-force@4.2.1: - dependencies: - d3-force: 3.0.0 - vega-dataflow: 5.7.6 - vega-util: 1.17.2 - transitivePeerDependencies: - - encoding - - vega-format@1.1.2: - dependencies: - d3-array: 3.2.4 - d3-format: 3.1.0 - d3-time-format: 4.1.0 - vega-time: 2.1.2 - vega-util: 1.17.2 - - vega-functions@5.15.0: - dependencies: - d3-array: 3.2.4 - d3-color: 3.1.0 - d3-geo: 3.1.1 - vega-dataflow: 5.7.6 - vega-expression: 5.1.1 - vega-scale: 7.4.1 - vega-scenegraph: 4.13.0 - vega-selections: 5.4.2 - vega-statistics: 1.9.0 - vega-time: 2.1.2 - vega-util: 1.17.2 - transitivePeerDependencies: - - encoding - - vega-geo@4.4.2: - dependencies: - d3-array: 3.2.4 - d3-color: 3.1.0 - d3-geo: 3.1.1 - vega-canvas: 1.2.7 - vega-dataflow: 5.7.6 - vega-projection: 1.6.1 - vega-statistics: 1.9.0 - vega-util: 1.17.2 - transitivePeerDependencies: - - encoding - - vega-hierarchy@4.1.2: - dependencies: - d3-hierarchy: 3.1.2 - vega-dataflow: 5.7.6 - vega-util: 1.17.2 - transitivePeerDependencies: - - encoding - - vega-interpreter@1.0.5: {} - - vega-label@1.3.0: - dependencies: - vega-canvas: 1.2.7 - vega-dataflow: 5.7.6 - vega-scenegraph: 4.13.0 - vega-util: 1.17.2 - transitivePeerDependencies: - - encoding - - vega-lite@3.4.0(vega@5.30.0): - dependencies: - '@types/clone': 0.1.30 - '@types/fast-json-stable-stringify': 2.1.0 - clone: 2.1.2 - fast-deep-equal: 2.0.1 - fast-json-stable-stringify: 2.0.0 - json-stringify-pretty-compact: 2.0.0 - tslib: 1.10.0 - vega: 5.30.0 - vega-event-selector: 2.0.6 - vega-expression: 2.6.6 - vega-typings: 0.7.2 - vega-util: 1.10.0 - yargs: 13.3.2 - - vega-lite@5.21.0(vega@5.30.0): - dependencies: - json-stringify-pretty-compact: 3.0.0 - tslib: 2.6.3 - vega: 5.30.0 - vega-event-selector: 3.0.1 - vega-expression: 5.1.1 - vega-util: 1.17.2 - yargs: 17.7.2 - - vega-loader@4.5.2: - dependencies: - d3-dsv: 3.0.1 - node-fetch: 2.6.7 - topojson-client: 3.1.0 - vega-format: 1.1.2 - vega-util: 1.17.2 - transitivePeerDependencies: - - encoding - - vega-parser@6.4.0: - dependencies: - vega-dataflow: 5.7.6 - vega-event-selector: 3.0.1 - vega-functions: 5.15.0 - vega-scale: 7.4.1 - vega-util: 1.17.2 - transitivePeerDependencies: - - encoding - - vega-projection@1.6.1: - dependencies: - d3-geo: 3.1.1 - d3-geo-projection: 4.0.0 - vega-scale: 7.4.1 - - vega-regression@1.3.0: - dependencies: - d3-array: 3.2.4 - vega-dataflow: 5.7.6 - vega-statistics: 1.9.0 - vega-util: 1.17.2 - transitivePeerDependencies: - - encoding - - vega-runtime@6.2.0: - dependencies: - vega-dataflow: 5.7.6 - vega-util: 1.17.2 - transitivePeerDependencies: - - encoding - - vega-scale@7.4.1: - dependencies: - d3-array: 3.2.4 - d3-interpolate: 3.0.1 - d3-scale: 4.0.2 - d3-scale-chromatic: 3.1.0 - vega-time: 2.1.2 - vega-util: 1.17.2 - - vega-scenegraph@4.13.0: - dependencies: - d3-path: 3.1.0 - d3-shape: 3.2.0 - vega-canvas: 1.2.7 - vega-loader: 4.5.2 - vega-scale: 7.4.1 - vega-util: 1.17.2 - transitivePeerDependencies: - - encoding - - vega-schema-url-parser@2.2.0: {} - - vega-selections@5.4.2: - dependencies: - d3-array: 3.2.4 - vega-expression: 5.1.1 - vega-util: 1.17.2 - - vega-statistics@1.9.0: - dependencies: - d3-array: 3.2.4 - - vega-themes@2.14.0(vega-lite@5.21.0(vega@5.30.0))(vega@5.30.0): - dependencies: - vega: 5.30.0 - vega-lite: 5.21.0(vega@5.30.0) - - vega-time@2.1.2: - dependencies: - d3-array: 3.2.4 - d3-time: 3.1.0 - vega-util: 1.17.2 - - vega-tooltip@0.33.0: - dependencies: - vega-util: 1.17.2 - - vega-transforms@4.12.0: - dependencies: - d3-array: 3.2.4 - vega-dataflow: 5.7.6 - vega-statistics: 1.9.0 - vega-time: 2.1.2 - vega-util: 1.17.2 - transitivePeerDependencies: - - encoding - - vega-typings@0.7.2: - dependencies: - vega-util: 1.17.2 - - vega-typings@1.3.1: - dependencies: - '@types/geojson': 7946.0.4 - vega-event-selector: 3.0.1 - vega-expression: 5.1.1 - vega-util: 1.17.2 - - vega-util@1.10.0: {} - - vega-util@1.17.2: {} - - vega-view-transforms@4.6.0: - dependencies: - vega-dataflow: 5.7.6 - vega-scenegraph: 4.13.0 - vega-util: 1.17.2 - transitivePeerDependencies: - - encoding - - vega-view@5.13.0: - dependencies: - d3-array: 3.2.4 - d3-timer: 3.0.1 - vega-dataflow: 5.7.6 - vega-format: 1.1.2 - vega-functions: 5.15.0 - vega-runtime: 6.2.0 - vega-scenegraph: 4.13.0 - vega-util: 1.17.2 - transitivePeerDependencies: - - encoding - - vega-voronoi@4.2.3: - dependencies: - d3-delaunay: 6.0.4 - vega-dataflow: 5.7.6 - vega-util: 1.17.2 - transitivePeerDependencies: - - encoding - - vega-wordcloud@4.1.5: - dependencies: - vega-canvas: 1.2.7 - vega-dataflow: 5.7.6 - vega-scale: 7.4.1 - vega-statistics: 1.9.0 - vega-util: 1.17.2 - transitivePeerDependencies: - - encoding - - vega@5.30.0: - dependencies: - vega-crossfilter: 4.1.2 - vega-dataflow: 5.7.6 - vega-encode: 4.10.1 - vega-event-selector: 3.0.1 - vega-expression: 5.1.1 - vega-force: 4.2.1 - vega-format: 1.1.2 - vega-functions: 5.15.0 - vega-geo: 4.4.2 - vega-hierarchy: 4.1.2 - vega-label: 1.3.0 - vega-loader: 4.5.2 - vega-parser: 6.4.0 - vega-projection: 1.6.1 - vega-regression: 1.3.0 - vega-runtime: 6.2.0 - vega-scale: 7.4.1 - vega-scenegraph: 4.13.0 - vega-statistics: 1.9.0 - vega-time: 2.1.2 - vega-transforms: 4.12.0 - vega-typings: 1.3.1 - vega-util: 1.17.2 - vega-view: 5.13.0 - vega-view-transforms: 4.6.0 - vega-voronoi: 4.2.3 - vega-wordcloud: 4.1.5 - transitivePeerDependencies: - - encoding - - verror@1.10.0: - dependencies: - assert-plus: 1.0.0 - core-util-is: 1.0.2 - extsprintf: 1.3.0 - - vfile-message@4.0.2: - dependencies: - '@types/unist': 3.0.3 - unist-util-stringify-position: 4.0.0 - - vfile@6.0.3: - dependencies: - '@types/unist': 3.0.3 - vfile-message: 4.0.2 - - vite-node@1.6.0(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0): - dependencies: - cac: 6.7.14 - debug: 4.3.6 - pathe: 1.1.2 - picocolors: 1.1.1 - vite: 5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - vite-tsconfig-paths@4.3.2(typescript@5.5.4)(vite@5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0)): - dependencies: - debug: 4.3.6 - globrex: 0.1.2 - tsconfck: 3.0.3(typescript@5.5.4) - optionalDependencies: - vite: 5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0) - transitivePeerDependencies: - - supports-color - - typescript - - vite@5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0): - dependencies: - esbuild: 0.21.5 - postcss: 8.4.49 - rollup: 4.21.3 - optionalDependencies: - '@types/node': 20.17.6 - fsevents: 2.3.3 - less: 4.2.0 - terser: 5.36.0 - - vitest@1.6.0(@types/node@20.17.6)(jsdom@24.1.1)(less@4.2.0)(terser@5.36.0): - dependencies: - '@vitest/expect': 1.6.0 - '@vitest/runner': 1.6.0 - '@vitest/snapshot': 1.6.0 - '@vitest/spy': 1.6.0 - '@vitest/utils': 1.6.0 - acorn-walk: 8.3.2 - chai: 4.4.1 - debug: 4.3.6 - execa: 8.0.1 - local-pkg: 0.5.0 - magic-string: 0.30.11 - pathe: 1.1.2 - picocolors: 1.1.1 - std-env: 3.6.0 - strip-literal: 2.0.0 - tinybench: 2.5.1 - tinypool: 0.8.4 - vite: 5.4.6(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0) - vite-node: 1.6.0(@types/node@20.17.6)(less@4.2.0)(terser@5.36.0) - why-is-node-running: 2.2.2 - optionalDependencies: - '@types/node': 20.17.6 - jsdom: 24.1.1 - transitivePeerDependencies: - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - vlq@0.2.3: {} - - vscode-jsonrpc@8.2.0: {} - - vscode-languageserver-protocol@3.17.5: - dependencies: - vscode-jsonrpc: 8.2.0 - vscode-languageserver-types: 3.17.5 - - vscode-languageserver-textdocument@1.0.12: {} - - vscode-languageserver-types@3.17.5: {} - - vscode-languageserver@9.0.1: - dependencies: - vscode-languageserver-protocol: 3.17.5 - - vscode-uri@3.0.8: {} - - vt-pbf@3.1.3: - dependencies: - '@mapbox/point-geometry': 0.1.0 - '@mapbox/vector-tile': 1.3.1 - pbf: 3.3.0 - - w3c-keyname@2.2.8: {} - - w3c-xmlserializer@5.0.0: - dependencies: - xml-name-validator: 5.0.0 - - watchpack@2.4.2: - dependencies: - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - - weak-map@1.0.8: {} - - web-vitals@4.2.3: {} - - webgl-context@2.2.0: - dependencies: - get-canvas-context: 1.0.2 - - webidl-conversions@3.0.1: {} - - webidl-conversions@7.0.0: {} - - webpack-sources@3.2.3: {} - - webpack-virtual-modules@0.5.0: {} - - webpack@5.96.1(esbuild@0.21.5): - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.6 - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/wasm-edit': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.14.0 - browserslist: 4.24.2 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.17.1 - es-module-lexer: 1.5.4 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.0 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 3.3.0 - tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(esbuild@0.21.5)(webpack@5.96.1(esbuild@0.21.5)) - watchpack: 2.4.2 - webpack-sources: 3.2.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - - whatwg-encoding@3.1.1: - dependencies: - iconv-lite: 0.6.3 - - whatwg-fetch@3.6.17: {} - - whatwg-mimetype@4.0.0: {} - - whatwg-url@14.0.0: - dependencies: - tr46: 5.0.0 - webidl-conversions: 7.0.0 - - whatwg-url@5.0.0: - dependencies: - tr46: 0.0.3 - webidl-conversions: 3.0.1 - - which-boxed-primitive@1.0.2: - dependencies: - is-bigint: 1.0.4 - is-boolean-object: 1.1.2 - is-number-object: 1.0.7 - is-string: 1.0.7 - is-symbol: 1.0.4 - - which-builtin-type@1.1.3: - dependencies: - function.prototype.name: 1.1.6 - has-tostringtag: 1.0.2 - is-async-function: 2.0.0 - is-date-object: 1.0.5 - is-finalizationregistry: 1.0.2 - is-generator-function: 1.0.10 - is-regex: 1.1.4 - is-weakref: 1.0.2 - isarray: 2.0.5 - which-boxed-primitive: 1.0.2 - which-collection: 1.0.1 - which-typed-array: 1.1.15 - - which-collection@1.0.1: - dependencies: - is-map: 2.0.2 - is-set: 2.0.2 - is-weakmap: 2.0.1 - is-weakset: 2.0.2 - - which-module@1.0.0: {} - - which-module@2.0.1: {} - - which-typed-array@1.1.15: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.7 - for-each: 0.3.3 - gopd: 1.0.1 - has-tostringtag: 1.0.2 - - which@1.3.1: - dependencies: - isexe: 2.0.0 - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - which@4.0.0: - dependencies: - isexe: 3.1.1 - - why-is-node-running@2.2.2: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - - word-wrap@1.2.5: {} - - wordwrap@0.0.3: {} - - world-calendars@1.0.3: - dependencies: - object-assign: 4.1.1 - - wrap-ansi@2.1.0: - dependencies: - string-width: 1.0.2 - strip-ansi: 3.0.1 - - wrap-ansi@5.1.0: - dependencies: - ansi-styles: 3.2.1 - string-width: 3.1.0 - strip-ansi: 5.2.0 - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrappy@1.0.2: {} - - write-file-atomic@5.0.1: - dependencies: - imurmurhash: 0.1.4 - signal-exit: 4.1.0 - - ws@7.5.9: {} - - ws@8.18.0: {} - - xml-name-validator@5.0.0: {} - - xmlchars@2.2.0: {} - - xtend@2.2.0: {} - - xtend@4.0.2: {} - - y18n@3.2.2: {} - - y18n@4.0.3: {} - - y18n@5.0.8: {} - - yallist@3.1.1: {} - - yaml@1.10.2: {} - - yaml@2.2.2: {} - - yargs-parser@13.1.2: - dependencies: - camelcase: 5.3.1 - decamelize: 1.2.0 - - yargs-parser@21.1.1: {} - - yargs-parser@4.2.1: - dependencies: - camelcase: 3.0.0 - - yargs@13.3.2: - dependencies: - cliui: 5.0.0 - find-up: 3.0.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - require-main-filename: 2.0.0 - set-blocking: 2.0.0 - string-width: 3.1.0 - which-module: 2.0.1 - y18n: 4.0.3 - yargs-parser: 13.1.2 - - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - yargs@6.6.0: - dependencies: - camelcase: 3.0.0 - cliui: 3.2.0 - decamelize: 1.2.0 - get-caller-file: 1.0.3 - os-locale: 1.4.0 - read-pkg-up: 1.0.1 - require-directory: 2.1.1 - require-main-filename: 1.0.1 - set-blocking: 2.0.0 - string-width: 1.0.2 - which-module: 1.0.0 - y18n: 3.2.2 - yargs-parser: 4.2.1 - - yocto-queue@0.1.0: {} - - yocto-queue@1.0.0: {} - - zod-to-json-schema@3.22.5(zod@3.23.8): - dependencies: - zod: 3.23.8 - - zod-validation-error@2.1.0(zod@3.23.8): - dependencies: - zod: 3.23.8 - - zod-validation-error@3.4.0(zod@3.23.8): - dependencies: - zod: 3.23.8 - - zod@3.23.8: {} - - zustand@4.5.2(@types/react@18.3.12)(react@18.3.1): - dependencies: - use-sync-external-store: 1.2.0(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - react: 18.3.1 - - zwitch@2.0.4: {} diff --git a/frontend/postcss.config.cjs b/frontend/postcss.config.cjs index b016a6a9c2b..21e90dd0929 100644 --- a/frontend/postcss.config.cjs +++ b/frontend/postcss.config.cjs @@ -1,16 +1,34 @@ -/* Copyright 2024 Marimo. All rights reserved. */ +/* Copyright 2026 Marimo. All rights reserved. */ + +/** + * Selectors that should be replaced with the prefix rather than prepended. + * e.g., `:root { --spacing: 0.25rem }` becomes `.marimo { --spacing: 0.25rem }` + * so CSS custom properties are set directly on island container elements. + */ +const GLOBAL_SELECTORS = new Set([":root", ":host", "html", "body"]); + const config = { plugins: [ - require("tailwindcss/nesting"), - require("tailwindcss"), + require("@tailwindcss/postcss"), process.env.VITE_MARIMO_ISLANDS === "true" - ? require("postcss-plugin-namespace")(".marimo", { - ignore: [".marimo", "html", ".marimo:is(.dark *)"], + ? require("postcss-prefix-selector")({ + prefix: ".marimo", + transform(prefix, selector) { + // Global selectors → replace with prefix + if (GLOBAL_SELECTORS.has(selector)) { + return prefix; + } + // Already scoped under .marimo + if (selector.startsWith(".marimo")) { + return selector; + } + // Normal prefixing: .flex → .marimo .flex + return `${prefix} ${selector}`; + }, }) : undefined, process.env.NODE_ENV === "production" ? require("cssnano") : undefined, require("@csstools/postcss-light-dark-function"), - require("autoprefixer"), ], }; diff --git a/frontend/public/files/wasm-intro.py b/frontend/public/export_demos/wasm-intro.py similarity index 96% rename from frontend/public/files/wasm-intro.py rename to frontend/public/export_demos/wasm-intro.py index 9b9ceedf31b..1ab78f819be 100644 --- a/frontend/public/files/wasm-intro.py +++ b/frontend/public/export_demos/wasm-intro.py @@ -1,5 +1,6 @@ -# Copyright 2024 Marimo. All rights reserved. +# Copyright 2026 Marimo. All rights reserved. +# ruff: noqa: ANN202, B018 import marimo __generated_with = "0.2.6" @@ -11,7 +12,7 @@ def __(): import marimo as mo mo.md("# Welcome to [marimo](https://github.com/marimo-team/marimo)! 🌊🍃") - return mo, + return (mo,) @app.cell(hide_code=True) @@ -41,8 +42,10 @@ def __(mo): and models, doing lightweight data analysis, authoring blog posts, tutorials, and educational articles, and even building internal tools. They are not well-suited for notebooks that do heavy - computation, and they don't support multi-threading nor - multiprocessing; for these cases, use a regular marimo notebook. + computation or need true CPU parallelism. WASM notebooks support a + limited cooperative concurrency surface, but not OS threads, + shared-memory multiprocessing, or process isolation. For those + cases, use a regular marimo notebook. """ } ) @@ -75,7 +78,7 @@ def __(mo): @app.cell def __(mo): slider = mo.ui.slider(1, 22) - return slider, + return (slider,) @app.cell @@ -145,7 +148,7 @@ def __(changed, mo): @app.cell def __(): changed = False - return changed, + return (changed,) @app.cell(hide_code=True) @@ -234,13 +237,13 @@ def __(mo): @app.cell def __(mo): icon = mo.ui.dropdown(["🍃", "🌊", "✨"], value="🍃") - return icon, + return (icon,) @app.cell def __(icon, mo): repetitions = mo.ui.slider(1, 16, label=f"number of {icon.value}: ") - return repetitions, + return (repetitions,) @app.cell @@ -486,7 +489,7 @@ def __(): """ ), } - return tips, + return (tips,) if __name__ == "__main__": diff --git a/frontend/src/__mocks__/common.ts b/frontend/src/__mocks__/common.ts new file mode 100644 index 00000000000..8b81f3368c0 --- /dev/null +++ b/frontend/src/__mocks__/common.ts @@ -0,0 +1,195 @@ +/* Copyright 2026 Marimo. All rights reserved. */ +/* oxlint-disable no-console -- for debugging */ +import { type Mock, vi } from "vitest"; +import { invariant } from "@/utils/invariant"; + +interface MockLogger { + debug: Mock; + log: Mock; + warn: Mock; + error: Mock; + trace: Mock; + get: Mock; + disabled: Mock; +} + +// Common mock factories +export const Mocks = { + quietLogger: (): MockLogger => ({ + debug: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + get: vi.fn().mockImplementation(() => Mocks.quietLogger()), + disabled: vi.fn(), + }), + + logger: (): MockLogger => ({ + debug: vi.fn().mockImplementation(console.debug), + log: vi.fn().mockImplementation(console.log), + warn: vi.fn().mockImplementation(console.warn), + error: vi.fn().mockImplementation(console.error), + trace: vi.fn().mockImplementation(console.trace), + get: vi.fn().mockImplementation(() => Mocks.logger()), + disabled: vi.fn(), + }), + + toast: vi.fn(), + + clipboard: () => ({ + write: vi.fn(), + writeText: vi.fn(), + read: vi.fn(), + readText: vi.fn(), + }), + + clipboardItem: (data: Record) => ({ + types: Object.keys(data), + getType: vi + .fn() + .mockImplementation((type: string) => + Promise.resolve(new Blob([data[type]?.toString() || ""], { type })), + ), + supports: vi.fn().mockReturnValue(true), + }), + + blob: (parts?: string[], options?: { type?: string }) => ({ + text: () => Promise.resolve(parts?.[0] || ""), + type: options?.type || "text/plain", + }), + + event: (props: Partial = {}) => ({ + preventDefault: vi.fn(), + continuePropagation: vi.fn(), + target: document.createElement("div"), + currentTarget: document.createElement("div"), + stopPropagation: vi.fn(), + ...props, + }), + + keyboardEvent: ( + props: Partial = {}, + ): React.KeyboardEvent & { + continuePropagation: () => void; + } => + Mocks.event({ + ...props, + }) as unknown as React.KeyboardEvent & { + continuePropagation: () => void; + }, +}; + +// Common mock modules for vi.mock() calls +export const MockModules = { + toast: () => ({ toast: vi.fn() }), + toastWithControls: () => { + const dismiss = vi.fn(); + const update = vi.fn(); + return { + mock: { toast: vi.fn(() => ({ dismiss, update })) }, + dismiss, + update, + }; + }, +}; + +// Global mock setup functions +export const SetupMocks = { + resizeObserver: () => { + const observe = vi.fn(); + const unobserve = vi.fn(); + const disconnect = vi.fn(); + global.ResizeObserver = class MockResizeObserver { + observe = observe; + unobserve = unobserve; + disconnect = disconnect; + } as unknown as typeof ResizeObserver; + return { observe, unobserve, disconnect }; + }, + clipboard: (mockClipboard = Mocks.clipboard()) => { + Object.defineProperty(navigator, "clipboard", { + value: mockClipboard, + writable: true, + }); + + // Mock ClipboardItem + global.ClipboardItem = Object.assign( + vi.fn().mockImplementation((data) => Mocks.clipboardItem(data)), + { supports: vi.fn().mockReturnValue(true) }, + ) as unknown as typeof ClipboardItem; + + // Mock Blob + global.Blob = vi + .fn() + .mockImplementation((parts, options) => Mocks.blob(parts, options)); + + return mockClipboard; + }, + + localStorage: () => { + const store: Record = {}; + + Object.defineProperty(window, "localStorage", { + value: { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + // oxlint-disable-next-line typescript/no-dynamic-delete + delete store[key]; + }), + clear: vi.fn(() => { + for (const key of Object.keys(store)) { + // oxlint-disable-next-line typescript/no-dynamic-delete + delete store[key]; + } + }), + key: vi.fn((index: number) => Object.keys(store)[index] || null), + get length() { + return Object.keys(store).length; + }, + }, + writable: true, + }); + }, + + fetch: (mockImplementation?: Mock) => { + const mockFetch = vi.fn(mockImplementation); + global.fetch = mockFetch; + return mockFetch; + }, + + websocket: () => { + const mockWs = { + close: vi.fn(), + send: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + onopen: null, + onclose: null, + onmessage: null, + onerror: null, + readyState: WebSocket.CONNECTING, + }; + + global.WebSocket = vi.fn(() => mockWs) as unknown as typeof WebSocket; + return mockWs; + }, +}; + +// oxlint-disable-next-line typescript/no-explicit-any +export function asMock unknown>(fn: T): Mock { + invariant( + "mock" in fn, + "fn must be a mock function, use vi.fn() to create one", + ); + return fn as unknown as Mock; +} + +export function partialImplementation( + partial: Partial, +): T { + return partial as unknown as T; +} diff --git a/frontend/src/__mocks__/notebook.ts b/frontend/src/__mocks__/notebook.ts new file mode 100644 index 00000000000..3c3263ebf2d --- /dev/null +++ b/frontend/src/__mocks__/notebook.ts @@ -0,0 +1,193 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import { createRef } from "react"; +import { vi } from "vitest"; +import type { CellActions, NotebookState } from "@/core/cells/cells"; +import { CellId } from "@/core/cells/ids"; +import { + type CellData, + type CellRuntimeState, + createCellRuntimeState, +} from "@/core/cells/types"; +import type { MarimoError } from "@/core/kernel/messages"; +import { MultiColumn } from "@/utils/id-tree"; +import { Objects } from "@/utils/objects"; + +export const MockNotebook = { + *cellIds() { + // Some large number to prevent freezing when this function is misused. + for (let i = 0; i < 10_000; i++) { + yield CellId.create(); + } + }, + + notebookState: (opts?: { + cellData: Record>; + cellRuntime?: Record>; + }): NotebookState => { + const cellData = opts?.cellData ?? {}; + const cellRuntime = opts?.cellRuntime ?? {}; + return { + cellData: Objects.mapValues(cellData, (data, cid) => ({ + id: cid, + code: "", + name: `cell-${cid}`, + config: { + hide_code: false, + disabled: false, + column: null, + ...data.config, + }, + edited: false, + lastCodeRun: null, + lastExecutionTime: null, + serializedEditorState: null, + ...data, + })), + cellIds: MultiColumn.from([Object.keys(cellData) as CellId[]]), + cellRuntime: Objects.mapValues(cellData, (_data, cid) => + createCellRuntimeState({ ...cellRuntime[cid] }), + ), + cellHandles: Objects.mapValues(cellData, (_data) => createRef()), + cellLogs: [], + history: [], + scrollKey: null, + untouchedNewCells: new Set(), + }; + }, + + cellActions: (actions: Partial = {}): CellActions => { + // Create a mock that has vi.fn() for all methods + const mockActions: Record void> = {}; + + // Create vi.fn() for each action + for (const [action, fn] of Object.entries(actions)) { + mockActions[action] = vi.fn().mockImplementation(fn); + } + + // Merge with provided actions + return new Proxy( + {}, + { + get(_target, prop) { + if (prop in mockActions) { + return mockActions[prop as keyof typeof mockActions]; + } + throw new Error( + `Action ${String(prop)} not mocked. Please add it to MockNotebook.cellActions({})`, + ); + }, + }, + ) as unknown as CellActions; + }, + + /** + * Create properly typed MarimoError objects for testing + */ + errors: { + setupRefs: (): MarimoError => ({ + type: "setup-refs", + edges_with_vars: [], + }), + + cycle: (): MarimoError => ({ + type: "cycle", + edges_with_vars: [], + }), + + multipleDefs: (name: string): MarimoError => ({ + type: "multiple-defs", + name, + cells: [], + }), + + importStar: (msg: string): MarimoError => ({ + type: "import-star", + msg, + }), + + exception: (msg: string, exception_type = "RuntimeError"): MarimoError => ({ + type: "exception", + msg, + exception_type, + raising_cell: null, + }), + + strictException: (msg: string, ref: string): MarimoError => ({ + type: "strict-exception", + msg, + ref, + blamed_cell: null, + }), + + interruption: (): MarimoError => ({ + type: "interruption", + }), + + syntax: (msg: string): MarimoError => ({ + type: "syntax", + msg, + }), + + unknown: (msg: string): MarimoError => ({ + type: "unknown", + msg, + error_type: null, + }), + }, + + /** + * Create a notebook state with error outputs for testing ErrorContextProvider + */ + notebookStateWithErrors: ( + errors: { + cellId: CellId; + cellName: string; + errorData: MarimoError[]; + }[], + ): NotebookState => { + const cellData: Record> = {}; + + for (const error of errors) { + cellData[error.cellId] = { + name: error.cellName, + }; + } + + const notebookState = MockNotebook.notebookState({ cellData }); + + // Add error outputs to cell runtime + for (const error of errors) { + notebookState.cellRuntime[error.cellId] = { + ...createCellRuntimeState(), + output: { + channel: "marimo-error" as const, + data: error.errorData, + mimetype: "application/vnd.marimo+error" as const, + timestamp: Date.now(), + }, + }; + } + + return notebookState; + }, + + /** + * Create a single cell with errors for quick testing + */ + cellWithErrors: (cellName: string, errorData: MarimoError[]) => { + const cellId = CellId.create(); + return { + cellId, + cellName, + errorData, + notebookState: MockNotebook.notebookStateWithErrors([ + { + cellId, + cellName, + errorData, + }, + ]), + }; + }, +}; diff --git a/frontend/src/__mocks__/requests.ts b/frontend/src/__mocks__/requests.ts new file mode 100644 index 00000000000..c78981f57c8 --- /dev/null +++ b/frontend/src/__mocks__/requests.ts @@ -0,0 +1,146 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import { type Mock, vi } from "vitest"; +import type { EditRequests, RunRequests } from "@/core/network/types"; + +type RequestClient = { + [K in keyof (EditRequests & RunRequests)]: Mock< + (EditRequests & RunRequests)[K] + >; +}; + +export const MockRequestClient = { + create(overrides?: Partial): RequestClient { + return { + // Edit requests + sendComponentValues: vi.fn().mockResolvedValue({}), + sendModelValue: vi.fn().mockResolvedValue({}), + sendRename: vi.fn().mockResolvedValue({}), + sendRestart: vi.fn().mockResolvedValue({}), + sendDocumentTransaction: vi.fn().mockResolvedValue({}), + sendSave: vi.fn().mockResolvedValue({}), + sendCopy: vi.fn().mockResolvedValue({}), + sendStdin: vi.fn().mockResolvedValue({}), + sendFormat: vi.fn().mockResolvedValue({ codes: {} }), + sendInterrupt: vi.fn().mockResolvedValue({}), + sendShutdown: vi.fn().mockResolvedValue({}), + sendRun: vi.fn().mockResolvedValue({}), + sendRunScratchpad: vi.fn().mockResolvedValue({}), + sendInstantiate: vi.fn().mockResolvedValue({}), + sendDeleteCell: vi.fn().mockResolvedValue({}), + sendCodeCompletionRequest: vi.fn().mockResolvedValue({ items: [] }), + saveUserConfig: vi.fn().mockResolvedValue({}), + saveAppConfig: vi.fn().mockResolvedValue({}), + saveCellConfig: vi.fn().mockResolvedValue({}), + sendFunctionRequest: vi.fn().mockResolvedValue({}), + sendInstallMissingPackages: vi.fn().mockResolvedValue({}), + readCode: vi.fn().mockResolvedValue({ contents: "" }), + readSnippets: vi.fn().mockResolvedValue({ snippets: [] }), + previewDatasetColumn: vi.fn().mockResolvedValue({}), + previewSQLTable: vi.fn().mockResolvedValue({}), + previewSQLTableList: vi.fn().mockResolvedValue({ tables: [] }), + previewSQLSchemaList: vi.fn().mockResolvedValue({ schemas: [] }), + previewDataSourceConnection: vi.fn().mockResolvedValue({}), + discoverDataSources: vi.fn().mockResolvedValue({}), + validateSQL: vi.fn().mockResolvedValue({}), + openFile: vi.fn().mockResolvedValue({}), + getUsageStats: vi.fn().mockResolvedValue({}), + getEnvironmentInfo: vi + .fn() + .mockRejectedValue(new Error("Environment information unavailable")), + sendPdb: vi.fn().mockResolvedValue({}), + sendSetBreakpoints: vi.fn().mockResolvedValue({}), + sendListFiles: vi.fn().mockResolvedValue({ files: [] }), + sendSearchFiles: vi + .fn() + .mockResolvedValue({ files: [], query: "", total_found: 0 }), + sendCreateFileOrFolder: vi.fn().mockResolvedValue({}), + sendDeleteFileOrFolder: vi.fn().mockResolvedValue({}), + sendCopyFileOrFolder: vi.fn().mockResolvedValue({}), + sendRenameFileOrFolder: vi.fn().mockResolvedValue({}), + sendUpdateFile: vi.fn().mockResolvedValue({}), + sendFileDetails: vi.fn().mockResolvedValue({}), + openTutorial: vi.fn().mockResolvedValue({}), + getRecentFiles: vi.fn().mockResolvedValue({ files: [] }), + getWorkspaceFiles: vi.fn().mockResolvedValue({ files: [] }), + getRunningNotebooks: vi.fn().mockResolvedValue({ files: [] }), + shutdownSession: vi.fn().mockResolvedValue({}), + getExportAvailability: vi.fn().mockResolvedValue({ + source: "server", + formats: [ + { + format: "html", + dependenciesAvailable: true, + missingPackages: [], + missingSetup: [], + }, + { + format: "markdown", + dependenciesAvailable: true, + missingPackages: [], + missingSetup: [], + }, + { + format: "ipynb", + dependenciesAvailable: true, + missingPackages: [], + missingSetup: [], + }, + { + format: "pdf", + dependenciesAvailable: true, + missingPackages: [], + missingSetup: [], + }, + { + format: "script", + dependenciesAvailable: true, + missingPackages: [], + missingSetup: [], + }, + ], + }), + exportAsHTML: vi.fn().mockResolvedValue({ + contents: "", + filename: "notebook.html", + mediaType: "text/html; charset=utf-8", + }), + exportAsIPYNB: vi.fn().mockResolvedValue({ + contents: "", + filename: "notebook.ipynb", + mediaType: "text/plain; charset=utf-8", + }), + exportAsMarkdown: vi.fn().mockResolvedValue({ + contents: "", + filename: "notebook.md", + mediaType: "text/plain; charset=utf-8", + }), + exportAsScript: vi.fn().mockResolvedValue({ + contents: "", + filename: "notebook.script.py", + mediaType: "text/plain; charset=utf-8", + }), + exportAsPDF: vi.fn().mockResolvedValue({ + contents: new Blob(), + filename: "notebook.pdf", + mediaType: "application/pdf", + }), + autoExportAsHTML: vi.fn().mockResolvedValue({}), + autoExportAsMarkdown: vi.fn().mockResolvedValue({}), + autoExportAsIPYNB: vi.fn().mockResolvedValue({}), + updateCellOutputs: vi.fn().mockResolvedValue({}), + addPackage: vi.fn().mockResolvedValue({}), + removePackage: vi.fn().mockResolvedValue({}), + getPackageList: vi.fn().mockResolvedValue({ packages: [] }), + getDependencyTree: vi.fn().mockResolvedValue({}), + listSecretKeys: vi.fn().mockResolvedValue({ keys: [] }), + writeSecret: vi.fn().mockResolvedValue({}), + invokeAiTool: vi.fn().mockResolvedValue({}), + clearCache: vi.fn().mockResolvedValue(null), + getCacheInfo: vi.fn().mockResolvedValue(null), + listStorageEntries: vi.fn().mockResolvedValue(null), + downloadStorage: vi.fn().mockResolvedValue(null), + ...overrides, + }; + }, +}; diff --git a/frontend/src/__mocks__/tracebacks.ts b/frontend/src/__mocks__/tracebacks.ts new file mode 100644 index 00000000000..e9681d6035c --- /dev/null +++ b/frontend/src/__mocks__/tracebacks.ts @@ -0,0 +1,32 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +// from: +// def foo(): +// return 1/ 0 +// foo() +const rawTraceback = `
Traceback (most recent call last):
+  File "/lib/python3.12/site-packages/marimo/_runtime/executor.py", line 193, in execute_cell
+    return eval(cell.last_expr, glbls)
+           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
+  File "/tmp/marimo_42/__marimo__cell_Hbol_.py", line 4, in <module>
+    foo()
+  File "/tmp/marimo_42/__marimo__cell_Hbol_.py", line 2, in foo
+    return 1/ 0
+           ~^~~
+ZeroDivisionError: division by zero
+
`; + +// from: +// assert not "File /tmp/marimo_42/__marimo__cell_Hbol_.py" +const assertionTraceback = `
Traceback (most recent call last):
+  File "/lib/python3.12/site-packages/marimo/_runtime/executor.py", line 192, in execute_cell
+    exec(cell.body, glbls)
+  File "/tmp/marimo_42/__marimo__cell_Hbol_.py", line 1, in <module>
+    assert not "File /tmp/marimo_42/__marimo__cell_Hbol_.py"
+AssertionError
+
`; + +export const Tracebacks = { + raw: rawTraceback, + assertion: assertionTraceback, +}; diff --git a/frontend/src/__tests__/CellStatus.test.tsx b/frontend/src/__tests__/CellStatus.test.tsx new file mode 100644 index 00000000000..bdf8e8b8e7f --- /dev/null +++ b/frontend/src/__tests__/CellStatus.test.tsx @@ -0,0 +1,262 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import { render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { formatElapsedTime, type Seconds } from "@/utils/time"; +import { + CellStatusComponent, + ElapsedTime, +} from "../components/editor/cell/CellStatus"; + +// Mock date-fns to have consistent date formatting in tests +vi.mock("date-fns", () => ({ + formatDistanceToNow: () => "5 minutes", +})); + +describe("formatElapsedTime", () => { + test("formats milliseconds correctly", () => { + expect(formatElapsedTime(500)).toBe("500ms"); + expect(formatElapsedTime(50)).toBe("50ms"); + }); + + test("formats seconds correctly", () => { + expect(formatElapsedTime(1500)).toBe("1.50s"); + expect(formatElapsedTime(2340)).toBe("2.34s"); + }); + + test("formats minutes and seconds correctly", () => { + expect(formatElapsedTime(60 * 1000)).toBe("1m0s"); + expect(formatElapsedTime(90 * 1000)).toBe("1m30s"); + expect(formatElapsedTime(89 * 1000)).toBe("1m29s"); + expect(formatElapsedTime(91 * 1000)).toBe("1m31s"); + expect(formatElapsedTime(150 * 1000)).toBe("2m30s"); + expect(formatElapsedTime(151 * 1000)).toBe("2m31s"); + }); + + test("handles null input", () => { + expect(formatElapsedTime(null)).toBe(""); + }); +}); + +describe("ElapsedTime component", () => { + test("renders elapsed time correctly", () => { + const { container } = render( + + + , + ); + expect(container).toMatchSnapshot(); + }); +}); + +describe("CellStatusComponent", () => { + // Mock date for consistent testing + const mockDate = new Date(2024, 0, 1, 12, 0, 0); + const originalDate = global.Date; + + beforeEach(() => { + global.Date = class extends Date { + static override now() { + return mockDate.getTime(); + } + } as typeof Date; + }); + + afterEach(() => { + global.Date = originalDate; + }); + + // Base props that will be modified for different test cases + const baseProps = { + editing: true, + edited: false, + disabled: false, + staleInputs: false, + status: "idle" as const, + interrupted: false, + elapsedTime: null, + runStartTimestamp: null, + lastRunStartTimestamp: null, + uninstantiated: false, + }; + + test("returns null when not editing", () => { + const { container } = render( + + + , + ); + expect(container.firstChild).toBeNull(); + }); + + test("renders disabled and stale state", () => { + const props = { + ...baseProps, + disabled: true, + staleInputs: true, + lastRunStartTimestamp: 1_704_096_000 as Seconds, // Jan 1, 2024 12:00:00 + }; + + const { container } = render( + + + , + ); + expect(container).toMatchSnapshot(); + }); + + test("renders disabled state", () => { + const props = { + ...baseProps, + disabled: true, + lastRunStartTimestamp: 1_704_096_000 as Seconds, + }; + + const { container } = render( + + + , + ); + expect(container).toMatchSnapshot(); + }); + + test("renders disabled transitively state", () => { + const props = { + ...baseProps, + status: "disabled-transitively" as const, + lastRunStartTimestamp: 1_704_096_000 as Seconds, + }; + + const { container } = render( + + + , + ); + expect(container).toMatchSnapshot(); + }); + + test("renders stale and disabled transitively state", () => { + const props = { + ...baseProps, + status: "disabled-transitively" as const, + staleInputs: true, + lastRunStartTimestamp: 1_704_096_000 as Seconds, + }; + + const { container } = render( + + + , + ); + expect(container).toMatchSnapshot(); + }); + + test("renders running state", () => { + const props = { + ...baseProps, + status: "running" as const, + runStartTimestamp: 1_704_096_000 as Seconds, + lastRunStartTimestamp: 1_704_096_000 as Seconds, + }; + + const { container } = render( + + + , + ); + expect(container).toMatchSnapshot(); + }); + + test("renders queued state", () => { + const props = { + ...baseProps, + status: "queued" as const, + lastRunStartTimestamp: 1_704_096_000 as Seconds, + }; + + const { container } = render( + + + , + ); + expect(container).toMatchSnapshot(); + }); + + test("renders uninstantiated state", () => { + const props = { + ...baseProps, + uninstantiated: true, + }; + + const { container } = render( + + + , + ); + expect(container).toMatchSnapshot(); + }); + + test("renders interrupted state", () => { + const props = { + ...baseProps, + interrupted: true, + elapsedTime: 1500, + lastRunStartTimestamp: 1_704_096_000 as Seconds, + }; + + const { container } = render( + + + , + ); + expect(container).toMatchSnapshot(); + }); + + test("renders edited state", () => { + const props = { + ...baseProps, + edited: true, + elapsedTime: 1500, + lastRunStartTimestamp: 1_704_096_000 as Seconds, + }; + + const { container } = render( + + + , + ); + expect(container).toMatchSnapshot(); + }); + + test("renders stale inputs state", () => { + const props = { + ...baseProps, + staleInputs: true, + elapsedTime: 1500, + lastRunStartTimestamp: 1_704_096_000 as Seconds, + }; + + const { container } = render( + + + , + ); + expect(container).toMatchSnapshot(); + }); + + test("renders completed run with elapsed time", () => { + const props = { + ...baseProps, + elapsedTime: 1500, + lastRunStartTimestamp: 1_704_096_000 as Seconds, + }; + + const { container } = render( + + + , + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/frontend/src/__tests__/__snapshots__/CellStatus.test.tsx.snap b/frontend/src/__tests__/__snapshots__/CellStatus.test.tsx.snap new file mode 100644 index 00000000000..da212b7d23a --- /dev/null +++ b/frontend/src/__tests__/__snapshots__/CellStatus.test.tsx.snap @@ -0,0 +1,523 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`CellStatusComponent > renders completed run with elapsed time 1`] = ` +
+
+ + 1.50s + +
+
+`; + +exports[`CellStatusComponent > renders disabled and stale state 1`] = ` +
+
+
+ +
+ +
+
+
+
+`; + +exports[`CellStatusComponent > renders disabled state 1`] = ` +
+
+ +
+
+`; + +exports[`CellStatusComponent > renders disabled transitively state 1`] = ` +
+
+
+ +
+ +
+
+
+
+`; + +exports[`CellStatusComponent > renders edited state 1`] = ` +
+
+
+ +
+
+ + 1.50s + +
+
+
+`; + +exports[`CellStatusComponent > renders interrupted state 1`] = ` +
+
+
+ +
+
+ + 1.50s + +
+
+
+`; + +exports[`CellStatusComponent > renders queued state 1`] = ` +
+
+ +
+
+`; + +exports[`CellStatusComponent > renders running state 1`] = ` +
+
+ + 0ms + +
+
+`; + +exports[`CellStatusComponent > renders stale and disabled transitively state 1`] = ` +
+
+
+ +
+ +
+
+
+
+`; + +exports[`CellStatusComponent > renders stale inputs state 1`] = ` +
+
+
+ +
+
+ + 1.50s + +
+
+
+`; + +exports[`CellStatusComponent > renders uninstantiated state 1`] = ` +
+
+
+ +
+
+
+`; + +exports[`ElapsedTime component > renders elapsed time correctly 1`] = ` +
+ + 1.50s + +
+`; diff --git a/frontend/src/__tests__/branded.ts b/frontend/src/__tests__/branded.ts new file mode 100644 index 00000000000..c5e4c49d7c5 --- /dev/null +++ b/frontend/src/__tests__/branded.ts @@ -0,0 +1,26 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +/** + * Test-only helpers for constructing branded ID types from plain strings. + * + * In production code, branded types flow from the API (via codegen) or + * from designated creation points (e.g. CellId.create()). Tests need to + * construct these from string literals, which requires a cast. These + * helpers centralise that cast so test files don't scatter `as CellId` + * everywhere. + */ + +import type { components } from "@marimo-team/marimo-api"; +import type { CellId, UIElementId } from "@/core/cells/ids"; +import type { RequestId } from "@/core/network/DeferredRequestRegistry"; +import type { VariableName } from "@/core/variables/types"; + +type WidgetModelId = components["schemas"]["WidgetModelId"]; +type Base64String = components["schemas"]["Base64String"]; + +export const cellId = (s: string) => s as CellId; +export const variableName = (s: string) => s as VariableName; +export const requestId = (s: string) => s as RequestId; +export const uiElementId = (s: string) => s as UIElementId; +export const widgetModelId = (s: string) => s as WidgetModelId; +export const base64String = (s: string) => s as Base64String; diff --git a/frontend/src/__tests__/chat-history.test.ts b/frontend/src/__tests__/chat-history.test.ts new file mode 100644 index 00000000000..278620bee5e --- /dev/null +++ b/frontend/src/__tests__/chat-history.test.ts @@ -0,0 +1,123 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { groupChatsByDate } from "../components/chat/chat-history-utils"; +import type { Chat } from "../core/ai/state"; + +// Mock current time for consistent testing +const mockNow = new Date("2024-01-15T12:00:00Z").getTime(); + +// Mock Date.now to return our fixed time +const originalDateNow = Date.now; +beforeAll(() => { + Date.now = () => mockNow; +}); + +afterAll(() => { + Date.now = originalDateNow; +}); + +describe("groupChatsByDate", () => { + const createMockChat = (daysAgo: number, title: string): Chat => ({ + id: `chat-${daysAgo}` as Chat["id"], + title, + messages: [], + createdAt: mockNow - daysAgo * 24 * 60 * 60 * 1000, + updatedAt: mockNow - daysAgo * 24 * 60 * 60 * 1000, + }); + + it("should group chats correctly by date periods", () => { + const chats: Chat[] = [ + createMockChat(0, "Today chat"), + createMockChat(1, "Yesterday chat"), + createMockChat(2, "2 days ago chat"), + createMockChat(3, "3 days ago chat"), + createMockChat(5, "5 days ago chat"), // Should go to "This week" + createMockChat(10, "10 days ago chat"), // Should go to "This month" + createMockChat(40, "40 days ago chat"), // Should go to "Older" + ]; + + const result = groupChatsByDate(chats); + + // Should have 7 groups + expect(result).toHaveLength(chats.length); + + // Check Today group + const todayGroup = result.find((g) => g.label === "Today"); + expect(todayGroup?.chats).toHaveLength(1); + expect(todayGroup?.chats[0].title).toBe("Today chat"); + + // Check Yesterday group + const yesterdayGroup = result.find((g) => g.label === "Yesterday"); + expect(yesterdayGroup?.chats).toHaveLength(1); + expect(yesterdayGroup?.chats[0].title).toBe("Yesterday chat"); + + // Check 2d ago group + const twoDaysGroup = result.find((g) => g.label === "2d ago"); + expect(twoDaysGroup?.chats).toHaveLength(1); + expect(twoDaysGroup?.chats[0].title).toBe("2 days ago chat"); + + // Check 3d ago group + const threeDaysGroup = result.find((g) => g.label === "3d ago"); + expect(threeDaysGroup?.chats).toHaveLength(1); + expect(threeDaysGroup?.chats[0].title).toBe("3 days ago chat"); + + // Check This week group (should include 5) + const thisWeekGroup = result.find((g) => g.label === "This week"); + expect(thisWeekGroup?.chats).toHaveLength(1); + expect(thisWeekGroup?.chats.map((c) => c.title)).toContain( + "5 days ago chat", + ); + + // Check This month group (should include 40 days ago) + const thisMonthGroup = result.find((g) => g.label === "This month"); + expect(thisMonthGroup?.chats).toHaveLength(1); + expect(thisMonthGroup?.chats[0].title).toBe("10 days ago chat"); + + // Check Older group (should include 40 days ago) + const olderGroup = result.find((g) => g.label === "Older"); + expect(olderGroup?.chats).toHaveLength(1); + expect(olderGroup?.chats[0].title).toBe("40 days ago chat"); + }); + + it("should include all chats in some group", () => { + const chats: Chat[] = [ + createMockChat(0, "Today"), + createMockChat(1, "Yesterday"), + createMockChat(2, "2 days ago"), + createMockChat(3, "3 days ago"), + createMockChat(5, "5 days ago"), + createMockChat(10, "10 days ago"), + createMockChat(20, "20 days ago"), + createMockChat(40, "40 days ago"), + createMockChat(100, "100 days ago"), + ]; + + const result = groupChatsByDate(chats); + + // Count total chats across all groups + const totalChatsInGroups = result.reduce( + (sum, group) => sum + group.chats.length, + 0, + ); + expect(totalChatsInGroups).toBe(chats.length); + }); + + it("should handle empty chat list", () => { + const result = groupChatsByDate([]); + expect(result).toHaveLength(0); + }); + + it("should filter out empty groups", () => { + const chats: Chat[] = [ + createMockChat(0, "Today chat"), + createMockChat(40, "Old chat"), + ]; + + const result = groupChatsByDate(chats); + + // Should only have Today and Older groups, not the empty ones in between + expect(result).toHaveLength(2); + expect(result.map((g) => g.label)).toEqual(["Today", "Older"]); + }); +}); diff --git a/frontend/src/__tests__/chat-utils.test.ts b/frontend/src/__tests__/chat-utils.test.ts new file mode 100644 index 00000000000..ab880417469 --- /dev/null +++ b/frontend/src/__tests__/chat-utils.test.ts @@ -0,0 +1,63 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import type { UIMessage } from "ai"; +import { describe, expect, it } from "vitest"; +import { Maps } from "@/utils/maps"; +import { replaceMessagesInChat } from "../core/ai/chat-utils"; +import type { Chat, ChatId, ChatState } from "../core/ai/state"; + +const CHAT_1 = "chat-1" as ChatId; + +function asMap(list: Iterable) { + return Maps.keyBy(list, (c) => c.id); +} +describe("replaceMessagesInChat", () => { + const mockChatState: ChatState = { + chats: asMap([ + { + id: CHAT_1, + title: "Test Chat 1", + messages: [ + { + id: "msg-1", + role: "user", + parts: [{ type: "text", text: "Hello" }], + metadata: { timestamp: 1000 }, + }, + ], + createdAt: 1000, + updatedAt: 2000, + }, + ]), + activeChatId: CHAT_1, + }; + + it("replaces messages in a chat", () => { + const newMessages: UIMessage[] = [ + { + id: "msg-2", + role: "assistant", + parts: [{ type: "text", text: "Hi there!" }], + metadata: { timestamp: 2000 }, + }, + ]; + const result = replaceMessagesInChat({ + chatState: mockChatState, + chatId: CHAT_1, + messages: newMessages, + }); + expect(result.chats.get(CHAT_1)?.messages).toEqual(newMessages); + expect(result.chats.get(CHAT_1)?.updatedAt).toBeGreaterThan( + mockChatState.chats.get(CHAT_1)?.updatedAt ?? 0, + ); + }); + + it("returns unchanged state if chatId is null", () => { + const result = replaceMessagesInChat({ + chatState: mockChatState, + chatId: null, + messages: [], + }); + expect(result).toEqual(mockChatState); + }); +}); diff --git a/frontend/src/__tests__/main.test.tsx b/frontend/src/__tests__/main.test.tsx new file mode 100644 index 00000000000..101c778dead --- /dev/null +++ b/frontend/src/__tests__/main.test.tsx @@ -0,0 +1,174 @@ +/* Copyright 2026 Marimo. All rights reserved. */ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + type AppConfig, + defaultUserConfig, + parseAppConfig, +} from "@/core/config/config-schema"; +import { + appConfigAtom, + configOverridesAtom, + userConfigAtom, +} from "../core/config/config"; +import { marimoVersionAtom, showCodeInRunModeAtom } from "../core/meta/state"; +import { type AppMode, viewStateAtom } from "../core/mode"; +import { codeAtom, filenameAtom } from "../core/saving/file-state"; +import { store } from "../core/state/jotai"; +import { mount, visibleForTesting } from "../mount"; + +vi.mock("../utils/vitals", () => ({ + reportVitals: vi.fn(), +})); + +vi.mock("react-dom/client", () => ({ + createRoot: vi.fn().mockImplementation((_el) => { + return { + render: vi.fn(), + }; + }), +})); + +describe("main", () => { + beforeEach(() => { + visibleForTesting.reset(); + + // Reset store before each test + store.set(viewStateAtom, { mode: "not-set" as AppMode, cellAnchor: null }); + store.set(codeAtom, undefined); + store.set(filenameAtom, null); + store.set(showCodeInRunModeAtom, false); + store.set(marimoVersionAtom, "unknown"); + store.set(appConfigAtom, parseAppConfig({})); + store.set(userConfigAtom, defaultUserConfig()); + store.set(configOverridesAtom, {}); + }); + + it.each(["edit", "read", "home", "run"])( + "should mount with mode %s", + (_mode) => { + const el = document.createElement("div"); + mount({ mode: "edit" }, el); + + expect(store.get(viewStateAtom).mode).toBe("edit"); + expect(store.get(filenameAtom)).toBeDefined(); + expect(store.get(marimoVersionAtom)).toBe("unknown"); + expect(store.get(showCodeInRunModeAtom)).toBe(true); + }, + ); + + it("should not mount with invalid mode", () => { + const el = document.createElement("div"); + const error = mount({ mode: "invalid" }, el); + expect(error).toBeDefined(); + expect(error?.message).toBe("Invalid marimo mount options"); + }); + + it("should mount with null values", () => { + const el = document.createElement("div"); + const error = mount( + { mode: "edit", filename: null, code: null, version: null }, + el, + ); + expect(error).toBeUndefined(); + + mount({ mode: "edit", filename: null, code: null, version: null }, el); + expect(store.get(filenameAtom)).toBeNull(); + expect(store.get(codeAtom)).toBe(""); + expect(store.get(marimoVersionAtom)).toBe("unknown"); + expect(store.get(viewStateAtom).mode).toBe("edit"); + }); + + it("should mount with undefined values", () => { + const el = document.createElement("div"); + const error = mount( + { + mode: "edit", + filename: undefined, + code: undefined, + version: undefined, + }, + el, + ); + expect(error).toBeUndefined(); + + expect(store.get(filenameAtom)).toBeNull(); + expect(store.get(codeAtom)).toBe(""); + expect(store.get(marimoVersionAtom)).toBe("unknown"); + expect(store.get(viewStateAtom).mode).toBe("edit"); + }); + + it("should mount with empty config", () => { + const el = document.createElement("div"); + const error = mount( + { mode: "edit", config: {}, configOverrides: {}, appConfig: {} }, + el, + ); + expect(error).toBeUndefined(); + expect(store.get(userConfigAtom)).toEqual(defaultUserConfig()); + expect(store.get(configOverridesAtom)).toEqual({}); + expect(store.get(appConfigAtom)).toEqual(parseAppConfig({})); + expect(store.get(viewStateAtom).mode).toBe("edit"); + expect(store.get(showCodeInRunModeAtom)).toBe(true); + }); + + it("should mount with all options", () => { + const el = document.createElement("div"); + const options = { + filename: "test.py", + code: "print('hello')", + version: "1.0.0", + mode: "edit" as const, + config: { + display: { + cell_output: "above", + code_editor_font_size: 99, + dataframes: "rich", + default_table_page_size: 10, + default_width: "medium", + theme: "light", + }, + }, + configOverrides: { display: { code_editor_font_size: 100 } }, + appConfig: { app_title: "My App" } as AppConfig, + view: { showAppCode: true }, + }; + + mount(options, el); + + expect(store.get(filenameAtom)).toBe("test.py"); + expect(store.get(codeAtom)).toBe("print('hello')"); + expect(store.get(marimoVersionAtom)).toBe("1.0.0"); + expect(store.get(viewStateAtom).mode).toBe("edit"); + expect(store.get(showCodeInRunModeAtom)).toBe(true); + expect(store.get(userConfigAtom).display).toEqual( + expect.objectContaining({ + code_editor_font_size: 99, + }), + ); + expect(store.get(configOverridesAtom)).toEqual( + expect.objectContaining({ + display: { code_editor_font_size: 100 }, + }), + ); + expect(store.get(appConfigAtom)).toEqual( + expect.objectContaining({ app_title: "My App" }), + ); + }); + + it("should throw on invalid options", () => { + const el = document.createElement("div"); + const error = mount({ invalid: true } as unknown, el); + expect(error).toBeDefined(); + expect(error?.message).toBe("Invalid marimo mount options"); + }); + + it("should not mount twice", () => { + const el = document.createElement("div"); + mount({ mode: "edit" }, el); + const error = mount({ mode: "edit" }, el); + expect(error).toBeDefined(); + expect(error?.message).toBe("marimo app has already been mounted."); + }); +}); diff --git a/frontend/src/__tests__/mocks.ts b/frontend/src/__tests__/mocks.ts new file mode 100644 index 00000000000..42265a5b096 --- /dev/null +++ b/frontend/src/__tests__/mocks.ts @@ -0,0 +1,43 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +// Edge case filenames for testing unicode, spaces, and special characters +export const EDGE_CASE_FILENAMES = [ + // Unicode + "tést.py", + "café.py", + "测试.py", + // Emojis + "🚀notebook.py", + // Spaces + "test file.py", + "file with spaces.py", + // Multiple + "café & 测试.py", + "café notebook.py", + // URL characters + "test-file.py", + "test_file.py", + "test_file.backup.py", + "file&with&ersands.py", + "file=with=equals.py", + "file?with?questions.py", +]; + +// Cell names with unicode and special characters for frontend tests +export const EDGE_CASE_CELL_NAMES = [ + "tést_cell", + "café_notebook", + "测试_cell", + "🚀_my_cell", + "cell with spaces", + "café notebook cell", +]; + +// URL test cases with special characters that could break query parameters +export const URL_SPECIAL_CHAR_FILENAMES = [ + "file with spaces.py", + "file&with&ersands.py", + "file=with=equals.py", + "file?with?questions.py", + "café & 测试.py", +]; diff --git a/frontend/src/__tests__/mount.test.ts b/frontend/src/__tests__/mount.test.ts new file mode 100644 index 00000000000..764ebf9f766 --- /dev/null +++ b/frontend/src/__tests__/mount.test.ts @@ -0,0 +1,128 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { connectionAtom } from "@/core/network/connection"; +import { store } from "@/core/state/jotai"; +import { WebSocketState } from "@/core/websocket/types"; +import { mount, visibleForTesting } from "../mount"; + +// Mock React DOM +vi.mock("react-dom/client", () => ({ + createRoot: vi.fn(() => ({ + render: vi.fn(), + })), +})); + +// Mock static state +vi.mock("@/core/static/static-state", () => ({ + isStaticNotebook: vi.fn(() => false), +})); + +// Mock other side-effect modules +vi.mock("@/core/vscode/vscode-bindings", () => ({ + maybeRegisterVSCodeBindings: vi.fn(), +})); + +vi.mock("@/plugins/plugins", () => ({ + initializePlugins: vi.fn(), +})); + +vi.mock("@/core/network/auth", () => ({ + cleanupAuthQueryParams: vi.fn(), +})); + +vi.mock("@/utils/vitals", () => ({ + reportVitals: vi.fn(), +})); + +// Mock preloadPage +vi.mock("@/core/MarimoApp", () => ({ + MarimoApp: () => null, + preloadPage: vi.fn(), +})); + +describe("mount", () => { + const mockElement = document.createElement("div"); + + beforeEach(() => { + visibleForTesting.reset(); + // Reset connection atom to initial state + store.set(connectionAtom, { state: WebSocketState.NOT_STARTED }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + const baseOptions = { + filename: "test.py", + code: "", + version: "0.0.1", + mode: "edit" as const, + config: {}, + configOverrides: {}, + appConfig: {}, + view: { showAppCode: true }, + serverToken: "", + }; + + describe("connection state initialization", () => { + it("should set connection to CONNECTING when runtimeConfig has lazy=false", () => { + mount( + { + ...baseOptions, + runtimeConfig: [{ url: "http://localhost:8080", lazy: false }], + }, + mockElement, + ); + + const connection = store.get(connectionAtom); + expect(connection.state).toBe(WebSocketState.CONNECTING); + }); + + it("should keep connection as NOT_STARTED when runtimeConfig has lazy=true", () => { + mount( + { + ...baseOptions, + runtimeConfig: [{ url: "http://localhost:8080", lazy: true }], + }, + mockElement, + ); + + const connection = store.get(connectionAtom); + expect(connection.state).toBe(WebSocketState.NOT_STARTED); + }); + + it("should keep connection as NOT_STARTED when no runtimeConfig is provided", () => { + mount( + { + ...baseOptions, + runtimeConfig: [], + }, + mockElement, + ); + + const connection = store.get(connectionAtom); + expect(connection.state).toBe(WebSocketState.NOT_STARTED); + }); + + it("should keep connection as NOT_STARTED for static notebooks even with lazy=false", async () => { + const { isStaticNotebook } = await import("@/core/static/static-state"); + vi.mocked(isStaticNotebook).mockReturnValue(true); + + // Reset mount state to allow another mount + visibleForTesting.reset(); + + mount( + { + ...baseOptions, + runtimeConfig: [{ url: "http://localhost:8080", lazy: false }], + }, + mockElement, + ); + + const connection = store.get(connectionAtom); + expect(connection.state).toBe(WebSocketState.NOT_STARTED); + }); + }); +}); diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts index 4b3c5e88b2d..4dce4f9ece4 100644 --- a/frontend/src/__tests__/setup.ts +++ b/frontend/src/__tests__/setup.ts @@ -1,4 +1,48 @@ -/* Copyright 2024 Marimo. All rights reserved. */ +/* Copyright 2026 Marimo. All rights reserved. */ -// Required for testing ser/deser of blobs +import { cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, vi } from "vitest"; +import "@testing-library/jest-dom/vitest"; import "blob-polyfill"; + +// mock implementation because jsdom doesn't support ResizeObserver +// if we need to test ResizeObserver functionality +// we can use a library like "resize-observer-polyfill" +globalThis.ResizeObserver ??= class { + observe(_target: Element) { + /* noop */ + } + unobserve(_target: Element) { + /* noop */ + } + disconnect() { + /* noop */ + } +} as never; + +// mock implementation because jsdom doesn't support IntersectionObserver +globalThis.IntersectionObserver ??= class { + observe(_target: Element) { + /* noop */ + } + unobserve(_target: Element) { + /* noop */ + } + disconnect() { + /* noop */ + } + takeRecords() { + return []; + } +} as never; + +// Global setup for all tests +beforeEach(() => { + // Reset all mocks before each test + vi.clearAllMocks(); +}); + +// Cleanup after each test case (e.g., clearing jsdom) +afterEach(() => { + cleanup(); +}); diff --git a/frontend/src/__tests__/test-helpers.ts b/frontend/src/__tests__/test-helpers.ts new file mode 100644 index 00000000000..22b1a0e37c0 --- /dev/null +++ b/frontend/src/__tests__/test-helpers.ts @@ -0,0 +1,24 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +/** + * Common test patterns + */ +export const TestUtils = { + /** + * Create a promise that resolves after a tick + */ + nextTick: () => new Promise((resolve) => setTimeout(resolve, 0)), + + /** + * Wait for a specific condition to be true + */ + waitFor: async (condition: () => boolean, timeout = 1000) => { + const start = Date.now(); + while (!condition() && Date.now() - start < timeout) { + await TestUtils.nextTick(); + } + if (!condition()) { + throw new Error(`Condition not met within ${timeout}ms`); + } + }, +}; diff --git a/frontend/public/circle-check.ico b/frontend/src/assets/circle-check.ico similarity index 100% rename from frontend/public/circle-check.ico rename to frontend/src/assets/circle-check.ico diff --git a/frontend/public/circle-play.ico b/frontend/src/assets/circle-play.ico similarity index 100% rename from frontend/public/circle-play.ico rename to frontend/src/assets/circle-play.ico diff --git a/frontend/public/circle-x.ico b/frontend/src/assets/circle-x.ico similarity index 100% rename from frontend/public/circle-x.ico rename to frontend/src/assets/circle-x.ico diff --git a/frontend/src/assets/icon-16x16.png b/frontend/src/assets/icon-16x16.png new file mode 100644 index 00000000000..10de32630d9 Binary files /dev/null and b/frontend/src/assets/icon-16x16.png differ diff --git a/frontend/src/assets/icon-32x32.png b/frontend/src/assets/icon-32x32.png new file mode 100644 index 00000000000..21d6f7a9ce9 Binary files /dev/null and b/frontend/src/assets/icon-32x32.png differ diff --git a/frontend/src/components/ai/__tests__/ai-utils.test.ts b/frontend/src/components/ai/__tests__/ai-utils.test.ts new file mode 100644 index 00000000000..217f9347ea1 --- /dev/null +++ b/frontend/src/components/ai/__tests__/ai-utils.test.ts @@ -0,0 +1,281 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import type { AiModel } from "@marimo-team/llm-info"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { UserConfig } from "@/core/config/config-schema"; + +vi.mock("@marimo-team/llm-info/models.json", () => { + const make = ( + overrides: Partial & Pick, + ): AiModel => ({ + description: "", + roles: ["chat", "edit"], + capabilities: [], + input_types: [], + output_types: [], + release_date: "1970-01-01", + ...overrides, + }); + + const models: Record = { + openai: [ + make({ + name: "GPT-4", + model: "gpt-4", + description: "OpenAI GPT-4 model", + }), + ], + anthropic: [ + make({ + name: "Claude 3", + model: "claude-3-sonnet", + description: "Anthropic Claude 3 Sonnet", + }), + ], + google: [ + make({ + name: "Gemini Pro", + model: "gemini-pro", + description: "Google Gemini Pro model", + }), + ], + ollama: [ + make({ + name: "Ollama Model", + model: "llama2", + description: "Ollama Llama 2 model", + }), + ], + }; + + return { models }; +}); + +// Must import after mock +import { + autoPopulateModels, + getConfiguredProvider, + getRecommendedModel, +} from "../ai-utils"; + +describe("ai-utils", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getConfiguredProvider", () => { + it("should return undefined when no AI config", () => { + const config: UserConfig = {} as UserConfig; + expect(getConfiguredProvider(config.ai)).toBeUndefined(); + }); + + it("should return undefined when AI config has no credentials", () => { + const config: UserConfig = { + ai: {}, + } as UserConfig; + expect(getConfiguredProvider(config.ai)).toBeUndefined(); + }); + + it("should return openai when OpenAI API key is set", () => { + const config: UserConfig = { + ai: { + open_ai: { api_key: "sk-test" }, + }, + } as UserConfig; + expect(getConfiguredProvider(config.ai)).toBe("openai"); + }); + + it("should return anthropic when Anthropic API key is set", () => { + const config: UserConfig = { + ai: { + anthropic: { api_key: "sk-ant-test" }, + }, + } as UserConfig; + expect(getConfiguredProvider(config.ai)).toBe("anthropic"); + }); + + it("should return google when Google API key is set", () => { + const config: UserConfig = { + ai: { + google: { api_key: "google-key" }, + }, + } as UserConfig; + expect(getConfiguredProvider(config.ai)).toBe("google"); + }); + + it("should return ollama when Ollama base URL is set", () => { + const config: UserConfig = { + ai: { + ollama: { base_url: "http://localhost:11434" }, + }, + } as UserConfig; + expect(getConfiguredProvider(config.ai)).toBe("ollama"); + }); + + it("should return azure only when both API key and base URL are set", () => { + const config: UserConfig = { + ai: { + azure: { api_key: "azure-key", base_url: "https://azure.com" }, + }, + } as UserConfig; + expect(getConfiguredProvider(config.ai)).toBe("azure"); + }); + + it("should return undefined for azure with only API key", () => { + const config: UserConfig = { + ai: { + azure: { api_key: "azure-key" }, + }, + } as UserConfig; + expect(getConfiguredProvider(config.ai)).toBeUndefined(); + }); + + it("should return custom provider when configured", () => { + const config = { + ai: { + custom_providers: { + my_provider: { base_url: "https://my-api.com" }, + }, + }, + } as unknown as UserConfig; + expect(getConfiguredProvider(config.ai)).toBe("my_provider"); + }); + }); + + describe("getRecommendedModel", () => { + it("should return undefined when no provider is configured", () => { + const config: UserConfig = {} as UserConfig; + expect(getRecommendedModel(config.ai)).toBeUndefined(); + }); + + it("should return openai model when OpenAI is configured", () => { + const config: UserConfig = { + ai: { + open_ai: { api_key: "sk-test" }, + }, + } as UserConfig; + expect(getRecommendedModel(config.ai)).toBe("openai/gpt-4"); + }); + + it("should return anthropic model when Anthropic is configured", () => { + const config: UserConfig = { + ai: { + anthropic: { api_key: "sk-ant-test" }, + }, + } as UserConfig; + expect(getRecommendedModel(config.ai)).toBe("anthropic/claude-3-sonnet"); + }); + + it("should return google model when Google is configured", () => { + const config: UserConfig = { + ai: { + google: { api_key: "google-key" }, + }, + } as UserConfig; + expect(getRecommendedModel(config.ai)).toBe("google/gemini-pro"); + }); + + it("should return ollama model when Ollama is configured", () => { + const config: UserConfig = { + ai: { + ollama: { base_url: "http://localhost:11434" }, + }, + } as UserConfig; + expect(getRecommendedModel(config.ai)).toBe("ollama/llama2"); + }); + }); + + describe("autoPopulateModels", () => { + it("should return empty result when both models are already set", () => { + const values = { + ai: { + open_ai: { api_key: "sk-test" }, + models: { + chat_model: "openai/gpt-4", + edit_model: "openai/gpt-4", + custom_models: [], + displayed_models: [], + }, + }, + } as unknown as UserConfig; + + const result = autoPopulateModels(values.ai); + + expect(result.chatModel).toBeUndefined(); + expect(result.editModel).toBeUndefined(); + }); + + it("should return empty result when no credentials are configured", () => { + const values: UserConfig = { + ai: {}, + } as UserConfig; + + const result = autoPopulateModels(values.ai); + + expect(result.chatModel).toBeUndefined(); + expect(result.editModel).toBeUndefined(); + }); + + it("should auto-populate both models when neither is set", () => { + const values: UserConfig = { + ai: { + open_ai: { api_key: "sk-test" }, + }, + } as UserConfig; + + const result = autoPopulateModels(values.ai); + + expect(result.chatModel).toBe("openai/gpt-4"); + expect(result.editModel).toBe("openai/gpt-4"); + }); + + it("should only auto-populate chat_model when edit_model is set", () => { + const values = { + ai: { + open_ai: { api_key: "sk-test" }, + models: { + edit_model: "openai/gpt-3.5-turbo", + custom_models: [], + displayed_models: [], + }, + }, + } as unknown as UserConfig; + + const result = autoPopulateModels(values.ai); + + expect(result.chatModel).toBe("openai/gpt-4"); + expect(result.editModel).toBeUndefined(); + }); + + it("should only auto-populate edit_model when chat_model is set", () => { + const values = { + ai: { + open_ai: { api_key: "sk-test" }, + models: { + chat_model: "openai/gpt-3.5-turbo", + custom_models: [], + displayed_models: [], + }, + }, + } as unknown as UserConfig; + + const result = autoPopulateModels(values.ai); + + expect(result.chatModel).toBeUndefined(); + expect(result.editModel).toBe("openai/gpt-4"); + }); + + it("should return recommended model for anthropic provider", () => { + const values: UserConfig = { + ai: { + anthropic: { api_key: "sk-ant-test" }, + }, + } as UserConfig; + + const result = autoPopulateModels(values.ai); + + expect(result.chatModel).toBe("anthropic/claude-3-sonnet"); + expect(result.editModel).toBe("anthropic/claude-3-sonnet"); + }); + }); +}); diff --git a/frontend/src/components/ai/ai-model-dropdown.tsx b/frontend/src/components/ai/ai-model-dropdown.tsx new file mode 100644 index 00000000000..e4c7dcc50ea --- /dev/null +++ b/frontend/src/components/ai/ai-model-dropdown.tsx @@ -0,0 +1,396 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import type { Role } from "@marimo-team/llm-info"; +import { useAtomValue } from "jotai"; +import { + BotIcon, + BrainIcon, + ChevronDownIcon, + CircleHelpIcon, +} from "lucide-react"; +import React from "react"; +import { type SupportedRole, useModelChange } from "@/core/ai/config"; +import { + AiModelId, + isKnownAIProvider, + type ProviderId, + type QualifiedModelId, +} from "@/core/ai/ids/ids"; +import { type AiModel, AiModelRegistry } from "@/core/ai/model-registry"; +import { aiAtom, completionAtom } from "@/core/config/config"; +import { capitalize } from "@/utils/strings"; +import { useOpenSettingsToTab } from "../app-config/state"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuPortal, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { Tooltip } from "../ui/tooltip"; +import { AiProviderIcon } from "./ai-provider-icon"; +import { getCurrentRoleTooltip, getTagColour } from "./display-helpers"; + +interface AIModelDropdownProps { + value?: string; + placeholder?: string; + onSelect?: (modelId: QualifiedModelId) => void; + triggerClassName?: string; + customDropdownContent?: React.ReactNode; + iconSize?: "medium" | "small"; + showAddCustomModelDocs?: boolean; + displayIconOnly?: boolean; + forRole: SupportedRole; + disabled?: boolean; +} + +export const AIModelDropdown = ({ + value, + placeholder, + onSelect, + triggerClassName, + customDropdownContent, + iconSize = "medium", + showAddCustomModelDocs = false, + forRole, + displayIconOnly = false, + disabled = false, +}: AIModelDropdownProps) => { + const [isOpen, setIsOpen] = React.useState(false); + + const ai = useAtomValue(aiAtom); + const completion = useAtomValue(completionAtom); + const { saveModelChange } = useModelChange(); + const { handleClick } = useOpenSettingsToTab(); + + // Only include autocompleteModel if copilot is set to "custom" + const autocompleteModel = + completion.copilot === "custom" + ? ai?.models?.autocomplete_model + : undefined; + + const aiModelRegistry = AiModelRegistry.create({ + // We add all the custom models and the models used in the editor. + // If they among the known models, they won't overwrite them. + customModels: [ + ...(ai?.models?.custom_models ?? []), + ai?.models?.chat_model, + autocompleteModel, + ai?.models?.edit_model, + ].filter(Boolean), + displayedModels: ai?.models?.displayed_models, + }); + const modelsByProvider = aiModelRegistry.getListModelsByProvider(); + + const activeModel = + forRole === "autocomplete" + ? ai?.models?.autocomplete_model + : forRole === "chat" + ? ai?.models?.chat_model + : forRole === "edit" + ? ai?.models?.edit_model + : undefined; + + // If value is provided, use it, otherwise use the active model + const currentValue = value + ? AiModelId.parse(value) + : activeModel + ? AiModelId.parse(activeModel) + : undefined; + + const iconSizeClass = iconSize === "medium" ? "h-4 w-4" : "h-3 w-3"; + + const renderModelWithRole = (modelId: AiModelId, role: Role) => { + const maybeModelMatch = aiModelRegistry.getModel(modelId.id); + + return ( +
+ +
+ {maybeModelMatch?.name || modelId.shortModelId} + {modelId.id} +
+ +
+ + + {role} + + +
+
+ ); + }; + + const handleSelect = (modelId: QualifiedModelId) => { + if (disabled) { + return; + } + if (onSelect) { + onSelect(modelId); + } else { + saveModelChange(modelId, forRole); + } + setIsOpen(false); + }; + + return ( + + +
+ {currentValue ? ( + <> + + {displayIconOnly ? null : ( + + {isKnownAIProvider(currentValue.providerId) + ? currentValue.shortModelId + : currentValue.id} + + )} + + ) : ( + + {placeholder} + + )} +
+ +
+ + + {activeModel && + forRole && + renderModelWithRole(AiModelId.parse(activeModel), forRole)} + {activeModel && forRole && } + + {modelsByProvider.map(([provider, models]) => ( + + ))} + + {customDropdownContent} + + {showAddCustomModelDocs && ( + <> + + handleClick("ai", "ai-models")} + > + + Add custom model + + + )} + +
+ ); +}; + +const ProviderDropdownContent = ({ + provider, + onSelect, + models, + iconSizeClass, +}: { + provider: ProviderId; + onSelect: (modelId: QualifiedModelId) => void; + models: AiModel[]; + iconSizeClass: string; +}) => { + const iconProvider = isKnownAIProvider(provider) + ? provider + : "openai-compatible"; + + const maybeProviderInfo = AiModelRegistry.getProviderInfo(provider); + + if (models.length === 0) { + return null; + } + + return ( + + +

+ + {getProviderLabel(provider)} +

+
+ + + {maybeProviderInfo && ( + <> +

+ {maybeProviderInfo.description} +
+

+ +

+ For more information, see the{" "} + + provider details + + . +

+ + + )} + {models.map((model) => { + const qualifiedModelId: QualifiedModelId = `${provider}/${model.model}`; + return ( + + +
{ + onSelect(qualifiedModelId); + }} + > + +
+
+ + + +
+ ); + })} +
+
+
+ ); +}; + +const AiModelDropdownItem = ({ + model, + provider, +}: { + model: AiModel; + provider: ProviderId; +}) => { + const iconProvider = isKnownAIProvider(provider) + ? provider + : "openai-compatible"; + + return ( + <> + +
+ {model.name} +
+ {model.capabilities.includes("thinking") && ( + + + + )} +
+
+ {model.custom && ( + + + + )} + + ); +}; + +export const AiModelInfoDisplay = ({ + model, + provider, +}: { + model: AiModel; + provider: ProviderId; +}) => { + return ( +
+
+

+ {model.name} +

+

{model.model}

+
+ +

+ {model.description} +

+ + {model.roles.length > 0 && ( +
+

+ Capabilities: +

+
+ {model.roles.map((role) => ( + + {role} + + ))} +
+
+ )} + + {model.capabilities.includes("thinking") && ( +
+
+ + Supports thinking mode + +
+ )} + +
+ + + {getProviderLabel(provider)} + +
+
+ ); +}; + +export function getProviderLabel(provider: ProviderId): string { + const providerInfo = AiModelRegistry.getProviderInfo(provider); + if (providerInfo) { + return providerInfo.name; + } + return capitalize(provider); +} diff --git a/frontend/src/components/ai/ai-provider-icon.tsx b/frontend/src/components/ai/ai-provider-icon.tsx new file mode 100644 index 00000000000..00ec3a257b4 --- /dev/null +++ b/frontend/src/components/ai/ai-provider-icon.tsx @@ -0,0 +1,66 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import AnthropicIcon from "@marimo-team/llm-info/icons/anthropic.svg?inline"; +import BedrockIcon from "@marimo-team/llm-info/icons/aws.svg?inline"; +import AzureIcon from "@marimo-team/llm-info/icons/azure.svg?inline"; +import CursorIcon from "@marimo-team/llm-info/icons/cursor.svg?inline"; +import DeepseekIcon from "@marimo-team/llm-info/icons/deepseek.svg?inline"; +import GitHubIcon from "@marimo-team/llm-info/icons/github.svg?inline"; +import GeminiIcon from "@marimo-team/llm-info/icons/googlegemini.svg?inline"; +import OllamaIcon from "@marimo-team/llm-info/icons/ollama.svg?inline"; +import OpenAIIcon from "@marimo-team/llm-info/icons/openai.svg?inline"; +import OpencodeIcon from "@marimo-team/llm-info/icons/opencode-logo-light.svg?inline"; +import OpenRouterIcon from "@marimo-team/llm-info/icons/openrouter.svg?inline"; +import WandbIcon from "@marimo-team/llm-info/icons/weightsandbiases.svg?inline"; +import { BotIcon } from "lucide-react"; +import * as React from "react"; +import type { ProviderId } from "@/core/ai/ids/ids"; +import { cn } from "@/utils/cn"; +import marimoIcon from "../../assets/icon-32x32.png?inline"; +import type { ExternalAgentId } from "../chat/acp/state"; + +const icons: Record = { + openai: OpenAIIcon, + anthropic: AnthropicIcon, + claude: AnthropicIcon, + gemini: GeminiIcon, + google: GeminiIcon, + codex: OpenAIIcon, + ollama: OllamaIcon, + azure: AzureIcon, + bedrock: BedrockIcon, + deepseek: DeepseekIcon, + github: GitHubIcon, + openrouter: OpenRouterIcon, + wandb: WandbIcon, + "opencode-go": OpencodeIcon, + marimo: marimoIcon, + opencode: OpencodeIcon, + cursor: CursorIcon, +}; + +export interface AiProviderIconProps extends React.HTMLAttributes { + provider: keyof typeof icons | "openai-compatible"; + className?: string; +} + +export const AiProviderIcon: React.FC = ({ + provider, + className = "", + ...props +}) => { + if (provider === "openai-compatible" || !(provider in icons)) { + return ; + } + + const icon = icons[provider]; + + return ( + {provider} + ); +}; diff --git a/frontend/src/components/ai/ai-utils.ts b/frontend/src/components/ai/ai-utils.ts new file mode 100644 index 00000000000..4f65efea071 --- /dev/null +++ b/frontend/src/components/ai/ai-utils.ts @@ -0,0 +1,104 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import { + KNOWN_PROVIDERS, + type KnownProviderId, + type ProviderId, +} from "@/core/ai/ids/ids"; +import { getKnownModelMaps } from "@/core/ai/model-registry"; +import type { AiConfig, UserConfig } from "@/core/config/config-schema"; + +type CredentialChecker = (ai: AiConfig | undefined) => boolean; + +/** + * Credential checkers for each known provider. + */ +const CREDENTIAL_CHECKERS: Record = { + openai: (ai) => Boolean(ai?.open_ai?.api_key), + anthropic: (ai) => Boolean(ai?.anthropic?.api_key), + google: (ai) => Boolean(ai?.google?.api_key), + github: (ai) => Boolean(ai?.github?.api_key), + openrouter: (ai) => Boolean(ai?.openrouter?.api_key), + azure: (ai) => Boolean(ai?.azure?.api_key && ai?.azure?.base_url), + wandb: (ai) => Boolean(ai?.wandb?.api_key), + "opencode-go": (ai) => Boolean(ai?.opencode_go?.api_key), + bedrock: (ai) => Boolean(ai?.bedrock?.region_name), + ollama: (ai) => Boolean(ai?.ollama?.base_url), + // These providers don't have user-configurable credentials in the UI + deepseek: () => false, + marimo: () => false, +}; + +/** + * Returns the first configured provider based on credentials. + */ +export function getConfiguredProvider( + config: UserConfig["ai"], +): ProviderId | undefined { + for (const provider of KNOWN_PROVIDERS) { + if (CREDENTIAL_CHECKERS[provider](config)) { + return provider; + } + } + + // Check custom providers + const customProviders = config?.custom_providers; + if (customProviders) { + const firstCustomProvider = Object.entries(customProviders).find( + ([_, providerConfig]) => providerConfig?.base_url, + ); + if (firstCustomProvider) { + return firstCustomProvider[0]; + } + } +} + +export function getRecommendedModel( + config: UserConfig["ai"], +): string | undefined { + const provider = getConfiguredProvider(config); + if (!provider) { + return undefined; + } + return getKnownModelMaps().defaultModelByProvider.get(provider); +} + +export interface AutoPopulateResult { + chatModel: string | undefined; + editModel: string | undefined; +} + +/** + * Determines which models to auto-populate based on configured credentials. + * Returns the recommended model for chat/edit if credentials are configured but models aren't set. + * + * @param values - The full form values + */ +export function autoPopulateModels( + values: UserConfig["ai"], +): AutoPopulateResult { + const result: AutoPopulateResult = { + chatModel: undefined, + editModel: undefined, + }; + + const needsChatModel = !values?.models?.chat_model; + const needsEditModel = !values?.models?.edit_model; + + if (!needsChatModel && !needsEditModel) { + return result; + } + + const recommendedModel = getRecommendedModel(values); + if (!recommendedModel) { + return result; + } + + if (needsChatModel) { + result.chatModel = recommendedModel; + } + if (needsEditModel) { + result.editModel = recommendedModel; + } + return result; +} diff --git a/frontend/src/components/ai/display-helpers.tsx b/frontend/src/components/ai/display-helpers.tsx new file mode 100644 index 00000000000..559e973f443 --- /dev/null +++ b/frontend/src/components/ai/display-helpers.tsx @@ -0,0 +1,32 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import type { Role } from "@marimo-team/llm-info"; + +export function getTagColour(role: Role | "thinking"): string { + switch (role) { + case "chat": + return "bg-(--purple-3) text-(--purple-11)"; + case "autocomplete": + return "bg-(--green-3) text-(--green-11)"; + case "edit": + return "bg-(--blue-3) text-(--blue-11)"; + case "thinking": + return "bg-(--purple-4) text-(--purple-12)"; + } + return "bg-(--mauve-3) text-(--mauve-11)"; +} + +export function getCurrentRoleTooltip(role: Role): string { + switch (role) { + case "chat": + return "Current model used for chat conversations"; + case "autocomplete": + return "Current model used for autocomplete autocomplete"; + case "edit": + return "Current model used for code edits"; + case "rerank": + return "Current model used for reranking completions"; + case "embed": + return "Current model used for embedding"; + } +} diff --git a/frontend/src/components/app-config/__tests__/get-dirty-values.test.ts b/frontend/src/components/app-config/__tests__/get-dirty-values.test.ts new file mode 100644 index 00000000000..ac8a0f5a132 --- /dev/null +++ b/frontend/src/components/app-config/__tests__/get-dirty-values.test.ts @@ -0,0 +1,120 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import { describe, expect, test } from "vitest"; +import type { UserConfig } from "@/core/config/config-schema"; +import { applyManualInjections, getDirtyValues } from "../get-dirty-values"; + +describe("getDirtyValues", () => { + test("extracts only dirty fields", () => { + const values = { + display: { theme: "dark", fontSize: 14 }, + runtime: { dotenv: [".env.dev"], auto_reload: "lazy" }, + }; + const dirtyFields = { + display: { theme: true }, + }; + + const result = getDirtyValues(values, dirtyFields); + + expect(result).toEqual({ display: { theme: "dark" } }); + expect(result).not.toHaveProperty("runtime"); + }); + + test("returns empty object when nothing is dirty", () => { + const values = { display: { theme: "dark" } }; + const dirtyFields = {}; + + const result = getDirtyValues(values, dirtyFields); + + expect(result).toEqual({}); + }); + + test("handles nested dirty fields", () => { + const values = { + runtime: { auto_reload: "lazy", dotenv: [".env"] }, + display: { theme: "dark" }, + }; + const dirtyFields = { + runtime: { auto_reload: true }, + }; + + const result = getDirtyValues(values, dirtyFields); + + expect(result).toEqual({ runtime: { auto_reload: "lazy" } }); + expect(result.runtime).not.toHaveProperty("dotenv"); + }); + + test("handles multiple dirty fields at same level", () => { + const values = { + display: { theme: "dark", fontSize: 16, width: "full" }, + }; + const dirtyFields = { + display: { theme: true, fontSize: true }, + }; + + const result = getDirtyValues(values, dirtyFields); + + expect(result).toEqual({ display: { theme: "dark", fontSize: 16 } }); + expect(result.display).not.toHaveProperty("width"); + }); + + test("preserves unmodified runtime settings like dotenv", () => { + const values = { + display: { theme: "dark" }, + runtime: { dotenv: [".env.local"], pythonpath: ["/custom/path"] }, + }; + const dirtyFields = { + display: { theme: true }, + }; + + const result = getDirtyValues(values, dirtyFields); + + expect(result).toEqual({ display: { theme: "dark" } }); + expect(result).not.toHaveProperty("runtime"); + }); + + test("applyManualInjections injects touched ai model fields", () => { + const values = { + ai: { + models: { + displayed_models: ["openai/gpt-4"], + custom_models: ["openai/custom-model"], + }, + }, + } as UserConfig; + const dirtyValues: Partial = {}; + const touchedFields = { + ai: { models: { displayed_models: true } }, + }; + + applyManualInjections({ values, dirtyValues, touchedFields }); + + expect(dirtyValues).toEqual({ + ai: { + models: { + displayed_models: ["openai/gpt-4"], + custom_models: ["openai/custom-model"], + }, + }, + }); + }); + + test("applyManualInjections skips when field not touched", () => { + const values = { + ai: { + models: { + displayed_models: ["openai/gpt-4"], + custom_models: ["openai/custom-model"], + }, + }, + } as UserConfig; + const dirtyValues: Partial = {}; + const touchedFields = { + ai: { models: { displayed_models: false } }, + }; + + applyManualInjections({ values, dirtyValues, touchedFields }); + + expect(dirtyValues).toEqual({}); + }); +}); diff --git a/frontend/src/components/app-config/ai-config.tsx b/frontend/src/components/app-config/ai-config.tsx new file mode 100644 index 00000000000..765eee26595 --- /dev/null +++ b/frontend/src/components/app-config/ai-config.tsx @@ -0,0 +1,1972 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import { useAtom } from "jotai"; +import { + BotIcon, + BrainIcon, + ChevronRightIcon, + InfoIcon, + PlusIcon, + Trash2Icon, +} from "lucide-react"; +import React, { useId, useMemo, useState } from "react"; +import { + Button as AriaButton, + Tree, + TreeItem, + TreeItemContent, +} from "react-aria-components"; +import type { FieldPath, UseFormReturn } from "react-hook-form"; +import { useWatch } from "react-hook-form"; +import useEvent from "react-use-event-hook"; +import { + FormControl, + FormDescription, + FormErrorsBanner, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Kbd } from "@/components/ui/kbd"; +import { NativeSelect } from "@/components/ui/native-select"; +import { Textarea } from "@/components/ui/textarea"; +import type { SupportedRole } from "@/core/ai/config"; +import { + AiModelId, + KNOWN_PROVIDERS, + type KnownProviderId, + type ProviderId, + type QualifiedModelId, + type ShortModelId, +} from "@/core/ai/ids/ids"; +import { type AiModel, AiModelRegistry } from "@/core/ai/model-registry"; +import { CopilotConfig } from "@/core/codemirror/copilot/copilot-config"; +import { DEFAULT_AI_MODEL, type UserConfig } from "@/core/config/config-schema"; +import { isWasm } from "@/core/wasm/utils"; +import { cn } from "@/utils/cn"; +import { Events } from "@/utils/events"; +import { Strings } from "@/utils/strings"; +import { AIModelDropdown, getProviderLabel } from "../ai/ai-model-dropdown"; +import { + AiProviderIcon, + type AiProviderIconProps, +} from "../ai/ai-provider-icon"; +import { getTagColour } from "../ai/display-helpers"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "../ui/accordion"; +import { Alert, AlertDescription } from "../ui/alert"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { DropdownMenuSeparator } from "../ui/dropdown-menu"; +import { Label } from "../ui/label"; +import { ExternalLink } from "../ui/links"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, +} from "../ui/select"; +import { Switch } from "../ui/switch"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; +import { Tooltip } from "../ui/tooltip"; +import { formItemClasses, SettingSubtitle } from "./common"; +import { AWS_REGIONS } from "./constants"; +import { IncorrectModelId } from "./incorrect-model-id"; +import { + IsOverridden, + OverriddenFormField, + useConfigOverride, +} from "./is-overridden"; +import { MCPConfig } from "./mcp-config"; +import { aiSettingsSubTabAtom } from "./state"; + +interface AiConfigProps { + form: UseFormReturn; + config: UserConfig; + onSubmit: (values: UserConfig) => void; +} + +interface AiProviderTitleProps { + provider?: AiProviderIconProps["provider"]; + children: React.ReactNode; +} + +interface CustomProviderConfig { + api_key?: string; + base_url?: string; +} + +const SUPPORTED_PROVIDERS_UI = new Set(KNOWN_PROVIDERS); +// We use deepseek as an example of a custom provider in our docs, so we don't show it in the UI. +// Else, we would need to wire up the UI and config for it. Consider if many users request. +SUPPORTED_PROVIDERS_UI.delete("deepseek"); + +export const AiProviderTitle: React.FC = ({ + provider, + children, +}) => { + return ( +
+ {provider && } + {children} +
+ ); +}; + +interface ApiKeyProps { + form: UseFormReturn; + config: UserConfig; + name: FieldPath; + placeholder: string; + testId: string; + description?: React.ReactNode; + onChange?: (value: string) => void; +} + +export const ApiKey: React.FC = ({ + form, + name, + placeholder, + testId, + description, + onChange, +}) => { + return ( + ( +
+ + API Key + + { + const value = e.target.value; + if (!value.includes("*")) { + field.onChange(value); + onChange?.(value); + } + }} + /> + + + + + {description && {description}} +
+ )} + /> + ); +}; + +interface BaseUrlProps { + form: UseFormReturn; + config: UserConfig; + name: FieldPath; + placeholder: string; + testId: string; + description?: React.ReactNode; + disabled?: boolean; + onChange?: (value: string) => void; +} + +function asStringOrEmpty(value: T): string { + if (value == null) { + return ""; + } + + if (typeof value === "string") { + return value; + } + + return String(value); +} + +export const BaseUrl: React.FC = ({ + form, + name, + placeholder, + testId, + description, + disabled = false, + onChange, +}) => { + return ( + ( +
+ + Base URL + + { + field.onChange(e.target.value); + onChange?.(e.target.value); + }} + /> + + + + + {description && {description}} +
+ )} + /> + ); +}; + +interface ModelSelectorProps { + form: UseFormReturn; + config: UserConfig; + name: FieldPath; + placeholder: string; + description?: React.ReactNode; + label: string; + forRole: SupportedRole; + onSubmit: (values: UserConfig) => void; +} + +export const ModelSelector: React.FC = ({ + form, + name, + placeholder, + description, + label, + forRole, + onSubmit, +}) => { + return ( + { + const value = asStringOrEmpty(override.value); + const overridden = override.isOverridden; + + const selectModel = (modelId: QualifiedModelId) => { + field.onChange(modelId); + // Usually not needed, but a hack to force form values to be updated + onSubmit(form.getValues()); + }; + + const renderFormItem = () => ( + + {label} + + + +

+ Enter a custom model + + + +

+
+ + {value && ( + + )} +
+ + } + forRole={forRole} + /> +
+ +
+ ); + + return ( +
+ {renderFormItem()} + + + {description && {description}} +
+ ); + }} + /> + ); +}; + +interface ProviderSelectProps { + form: UseFormReturn; + config: UserConfig; + name: FieldPath; + options: string[]; + testId: string; + disabled?: boolean; +} + +export const ProviderSelect: React.FC = ({ + form, + name, + options, + testId, + disabled = false, +}) => { + return ( + ( +
+ + Provider + + { + if (e.target.value === "none") { + field.onChange(false); + } else { + field.onChange(e.target.value); + } + }} + value={asStringOrEmpty( + override.value === true + ? "github" + : override.value === false + ? "none" + : override.value, + )} + disabled={disabled || override.isOverridden} + className="inline-flex mr-2" + > + {options.map((option) => ( + + ))} + + + + + +
+ )} + /> + ); +}; + +const renderCopilotProvider = ({ + form, + config, + onSubmit, + copilot, +}: { + form: UseFormReturn; + config: UserConfig; + onSubmit: (values: UserConfig) => void; + copilot: UserConfig["completion"]["copilot"]; +}) => { + if (copilot === false) { + return null; + } + + if (copilot === "codeium") { + return ( + <> +

+ To get a Windsurf API key, follow{" "} + + these instructions + + . +

+ + + ); + } + + if (copilot === "github") { + return ; + } + + if (copilot === "custom") { + return ( + + ); + } +}; + +const SettingGroup = ({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) => { + return ( +
{children}
+ ); +}; + +interface ModelListItemProps { + qualifiedId: QualifiedModelId; + model: AiModel; + isEnabled: boolean; + onToggle: (modelId: QualifiedModelId) => void; + onDelete: (modelId: QualifiedModelId) => void; +} + +const ModelListItem: React.FC = ({ + qualifiedId, + model, + isEnabled, + onToggle, + onDelete, +}) => { + const handleToggle = () => { + onToggle(qualifiedId); + }; + + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + onDelete(qualifiedId); + }; + + return ( + + +
+ + {model.custom && ( + + )} + +
+
+
+ ); +}; + +const ModelInfoCard = ({ model }: { model: AiModel }) => { + return ( +
+
+

{model.name}

+ + {model.custom && } + + {model.capabilities.includes("thinking") && ( +
+ + Reasoning +
+ )} +
+ {model.description && !model.custom && ( +

+ {model.description} +

+ )} +
+ ); +}; + +export const AiCodeCompletionConfig: React.FC = ({ + form, + config, + onSubmit, +}) => { + const getOverride = useConfigOverride(); + // Watch (not `getValues`) so the sub-form re-renders when the provider + // changes, then resolve the effective value so an overridden + // `completion.copilot` shows the matching sub-form (not the user's saved one). + const watchedCopilot = useWatch({ + control: form.control, + name: "completion.copilot", + }); + const { value: copilot } = getOverride("completion.copilot", watchedCopilot); + return ( + + Code Completion +

+ Choose GitHub Copilot, Codeium, or a custom provider (such as Ollama) to + enable AI-powered code completion. +

+ + + + {renderCopilotProvider({ form, config, onSubmit, copilot })} +
+ ); +}; + +const AccordionFormItem = ({ + title, + triggerClassName, + provider, + children, + isConfigured, + value, +}: { + title: string; + triggerClassName?: string; + provider: AiProviderIconProps["provider"]; + children: React.ReactNode; + isConfigured: boolean; + /** Custom value for the accordion item. Defaults to provider. */ + value?: string; +}) => { + return ( + + + + {title} + {isConfigured && ( + + Configured + + )} + + + + {children} + + + ); +}; + +export const CustomProvidersConfig: React.FC = ({ + form, + config, + onSubmit, +}) => { + const [isAddingProvider, setIsAddingProvider] = useState(false); + const [newProviderName, setNewProviderName] = useState(""); + const [newProviderApiKey, setNewProviderApiKey] = useState(""); + const [newProviderBaseUrl, setNewProviderBaseUrl] = useState(""); + + const providerNameInputId = useId(); + const apiKeyInputId = useId(); + const baseUrlInputId = useId(); + + const normalizedName = newProviderName.toLowerCase().replaceAll(/\s+/g, "_"); + const customProviders = form.watch("ai.custom_providers"); + const isDuplicate = + SUPPORTED_PROVIDERS_UI.has(normalizedName as KnownProviderId) || + (customProviders && Object.keys(customProviders).includes(normalizedName)); + const hasInvalidChars = normalizedName.includes("."); + + const hasValidValues = + normalizedName.trim() && + newProviderBaseUrl.trim() && + !isDuplicate && + !hasInvalidChars; + + const resetForm = () => { + setNewProviderName(""); + setNewProviderApiKey(""); + setNewProviderBaseUrl(""); + setIsAddingProvider(false); + }; + + return ( + { + const customProviders = (field.value || {}) as Record< + string, + CustomProviderConfig + >; + const customProviderEntries = Object.entries(customProviders); + + const addProvider = () => { + if (!hasValidValues) { + return; + } + field.onChange({ + ...customProviders, + [normalizedName]: { + api_key: newProviderApiKey || undefined, + base_url: newProviderBaseUrl, + }, + }); + onSubmit(form.getValues()); + resetForm(); + }; + + const removeProvider = (providerName: string) => { + const { [providerName]: _, ...rest } = customProviders; + // Reset to clear nested dirty state, then set new value + form.resetField("ai.custom_providers"); + form.setValue("ai.custom_providers", rest, { shouldDirty: true }); + onSubmit(form.getValues()); + }; + + const providerForm = ( +
+
+ + setNewProviderName(e.target.value)} + /> + {isDuplicate && ( +

+ A provider with this name already exists. +

+ )} + {hasInvalidChars && ( +

+ Provider names cannot contain '.' characters. +

+ )} + {newProviderName && !hasInvalidChars && ( +

+ Use models with prefix:{" "} + {normalizedName}/ +

+ )} +
+ +
+ + setNewProviderBaseUrl(e.target.value)} + /> +
+ +
+ + setNewProviderApiKey(e.target.value)} + /> +
+ +
+ + +
+
+ ); + + // Update a provider field by updating the entire custom_providers object. + // As this config will be replaced, it needs to be sent in its entirety. + const updateProviderField = (opts: { + providerName: string; + fieldName: keyof CustomProviderConfig; + value: string; + }) => { + field.onChange({ + ...customProviders, + [opts.providerName]: { + ...customProviders[opts.providerName], + [opts.fieldName]: opts.value || undefined, + }, + }); + }; + + const renderAccordionItem = ({ + providerName, + providerConfig, + onRemove, + }: { + providerName: string; + providerConfig: CustomProviderConfig; + onRemove: (name: string) => void; + }) => { + const displayName = Strings.startCase(providerName); + const isConfigured = + !!providerConfig.api_key || !!providerConfig.base_url; + + return ( + + + } + placeholder="sk-..." + testId={`custom-provider-${providerName}-api-key`} + onChange={(value) => + updateProviderField({ + providerName, + fieldName: "api_key", + value, + }) + } + /> + + } + placeholder="https://api.example.com/v1" + testId={`custom-provider-${providerName}-base-url`} + onChange={(value) => + updateProviderField({ + providerName, + fieldName: "base_url", + value, + }) + } + /> + + + ); + }; + + return ( + + Custom Providers +

+ Add your own OpenAI-compatible provider. Once added, you can + configure models in the AI Models tab. +

+ + {customProviderEntries.length > 0 && ( + + {customProviderEntries.map(([name, providerConfig]) => + renderAccordionItem({ + providerName: name, + providerConfig, + onRemove: removeProvider, + }), + )} + + )} + + {isAddingProvider ? ( + providerForm + ) : ( + + )} +
+ ); + }} + /> + ); +}; + +export const AiProvidersConfig: React.FC = ({ + form, + config, + onSubmit, +}) => { + const isWasmRuntime = isWasm(); + + const hasValue = (name: FieldPath) => { + return !!form.getValues(name); + }; + + return ( + +

+ Add your API keys below or to marimo.toml{" "} + to set up a provider for the Code Completion and Assistant features; see{" "} + + docs + {" "} + for more info. +

+ + + + Your OpenAI API key from{" "} + + platform.openai.com + + . + + } + /> + + + + + + Your Anthropic API key from{" "} + + console.anthropic.com + + . + + } + /> + + + + + Your Google AI API key from{" "} + + aistudio.google.com + + . + + } + /> + + + + + + + + + + Free tier models have low token limits which can cause errors with + larger prompts.{" "} + + Learn more + + + + + Your GitHub API token from{" "} + gh auth token. + + } + /> + + + + + + Your OpenRouter API key from {""} + + openrouter.ai + + . + + } + /> + + + + + + Your Weights & Biases API key from{" "} + + wandb.ai + + . + + } + /> + + + + + + Your OpenCode API key from{" "} + + opencode.ai + + . OpenCode Go is a low-cost subscription for open coding models. + + } + /> + + + + + + Your Azure API key from{" "} + + portal.azure.com + + . + + } + /> + + + + +

+ To use AWS Bedrock, you need to configure AWS credentials and + region. See the{" "} + + documentation + {" "} + for more details. +

+ + ( +
+ + AWS Region + + field.onChange(e.target.value)} + value={ + typeof override.value === "string" + ? override.value + : "us-east-1" + } + disabled={field.disabled || override.isOverridden} + className="inline-flex mr-2" + > + {AWS_REGIONS.map((option) => ( + + ))} + + + + + + + The AWS region where Bedrock service is available. + +
+ )} + /> + + ( +
+ + AWS Profile Name (Optional) + + + + + + + + The AWS profile name from your ~/.aws/credentials file. Leave + blank to use your default AWS credentials. + +
+ )} + /> +
+ + +

+ Consider using Custom Providers instead, which allows you to add + multiple providers with distinct names. +

+ + API key for any OpenAI-compatible provider (e.g., Together, + Groq, Mistral, Perplexity, etc). + + } + /> + Base URL for your OpenAI-compatible provider.} + /> +
+
+ + +
+ ); +}; + +export const AiAssistConfig: React.FC = ({ + form, + config, + onSubmit, +}) => { + // Tracked locally rather than derived from the field value so that clearing + // the input (a transient empty value, which commits `null`) does not disable + // the input mid-edit and force the user to re-tick the Override checkbox. + const [maxTokensEnabled, setMaxTokensEnabled] = useState( + config.ai?.max_tokens != null, + ); + + return ( + + AI Assistant + + ( +
+ + AI Edit Tooltip + + + + + + Enable "Edit with AI" tooltip when selecting code. + +
+ )} + /> + + { + return ( +
+
+ + + Max output tokens + + + { + const n = Number.parseInt(e.target.value, 10); + field.onChange(Number.isFinite(n) && n > 0 ? n : null); + }} + /> + + + + { + const isChecked = checked === true; + setMaxTokensEnabled(isChecked); + // null signals delete to the server; cast because + // UserConfig (OpenAPI-derived) types max_tokens as + // `number | undefined`, but zod accepts `null`. + const next = ( + isChecked ? (field.value ?? 32768) : null + ) as number | undefined; + // shouldDirty: true forces RHF to keep this in + // dirtyFields even when `next` happens to equal the + // form's defaultValue (e.g. untick → tick when disk + // started with 32768). Otherwise getDirtyValues + // would skip it and the save body would be empty. + form.setValue("ai.max_tokens", next, { + shouldDirty: true, + shouldTouch: true, + }); + onSubmit(form.getValues()); + }} + /> + Override + +
+ + + Each provider sets its own max output tokens (Anthropic uses a + recommended default). Adjust to control costs or enable more + output. + +
+ ); + }} + /> + + + Model to use for chat conversations in the Chat panel. + } + forRole="chat" + onSubmit={onSubmit} + /> + + Model to use for code editing with the{" "} + Generate with AI button. + + } + forRole="edit" + onSubmit={onSubmit} + /> + + ( +
+ + Custom Rules + + ", + "", +] + + +def _attr_value(html: str, name: str) -> str: + import html as _html_mod + + match = re.search(f"data-{name}='(.*?)'", html) + assert match is not None + return _html_mod.unescape(match.groups()[0]) + + +def test_build_attr_escapes_angle_brackets() -> None: + for raw in SAFE_FOR_XML_TRIGGERS: + html = build_ui_plugin( + "tag-name", initial_value=raw, label=None, args={"data": raw} + ) + for attr_name in ("initial-value", "data"): + parsed = _attr_value(html, attr_name) + assert ">" not in parsed, (raw, attr_name, parsed) + assert "<" not in parsed, (raw, attr_name, parsed) + + +def test_initial_value_roundtrip_with_angle_brackets() -> None: + for raw in SAFE_FOR_XML_TRIGGERS: + html = build_ui_plugin( + "tag-name", initial_value=raw, label=None, args={} + ) + assert parse_initial_value(html) == raw + + +def test_build_attr_nested_data_escapes_angle_brackets() -> None: + nested = {"rows": [{"x": raw} for raw in SAFE_FOR_XML_TRIGGERS]} + html = build_ui_plugin( + "tag-name", initial_value=None, label=None, args={"data": nested} + ) + parsed = _attr_value(html, "data") + assert ">" not in parsed + assert "<" not in parsed diff --git a/tests/_plugins/stateless/status/test_progress.py b/tests/_plugins/stateless/status/test_progress.py index 4954f45f677..81240621dff 100644 --- a/tests/_plugins/stateless/status/test_progress.py +++ b/tests/_plugins/stateless/status/test_progress.py @@ -1,4 +1,4 @@ -# Copyright 2024 Marimo. All rights reserved. +# Copyright 2026 Marimo. All rights reserved. from __future__ import annotations import time @@ -67,6 +67,36 @@ def test_update_progress(mock_flush: Any) -> None: mock_flush.assert_called_once() +# Test update_progress method slow +@patch("marimo._runtime.output._output.flush") +def test_update_progress_slowly(mock_flush: Any) -> None: + progress = _Progress( + title="Test", + subtitle="Running Slowly", + total=5, + show_rate=True, + show_eta=True, + ) + + # Mock sleep 120 seconds + with patch("time.time", return_value=progress.start_time + 120): + progress.update_progress( + increment=1, title="Updated", subtitle="Still Running Slowly" + ) + + rate = progress._get_rate() + eta = progress._get_eta() + + assert progress.current == 1 + assert progress.title == "Updated" + assert progress.subtitle == "Still Running Slowly" + assert rate is not None + assert rate > 0.0 + assert eta is not None + assert eta > 0.0 + mock_flush.assert_called_once() + + # Test update_progress without arguments @patch("marimo._runtime.output._output.flush") def test_update_progress_no_args(mock_flush: Any) -> None: @@ -128,3 +158,108 @@ def test_progress_without_context(): for _ in range(10): assert bar bar.update() + + # iterator (no len()) with total + assert progress_bar(iter(range(1000)), total=1000) + + with pytest.raises(RuntimeError): + for _ in progress_bar(total=10): + pass + + +def test_progress_with_stepped_range_without_total() -> None: + assert runtime_context_installed() is False + + progress = progress_bar(range(0, 10, 2)) + assert list(progress) == [0, 2, 4, 6, 8] + assert (progress.progress.current, progress.progress.total) == (5, 5) + + +def test_progress_with_stepped_range_and_total() -> None: + assert runtime_context_installed() is False + + progress = progress_bar(range(0, 10, 2), total=10) + assert list(progress) == [0, 2, 4, 6, 8] + assert (progress.progress.current, progress.progress.total) == (10, 10) + + +async def sleep(seconds): + import asyncio + + tasks = [asyncio.create_task(asyncio.sleep(s, s)) for s in seconds] + for future in asyncio.as_completed(tasks): + yield await future + + +async def test_progress_async(): + assert runtime_context_installed() is False + + ait = sleep([0.01, 0.003, 0.001]) + result = [s async for s in progress_bar(ait, total=3)] + assert result == [0.001, 0.003, 0.01] + + +def test_progress_no_total_error(): + assert runtime_context_installed() is False + + def sync_generator(): + yield 1 + yield 2 + yield 3 + + with pytest.raises( + TypeError, + match="Cannot determine the length of a collection. A `total` must be provided.", + ): + progress_bar(sync_generator()) + + +def test_progress_async_no_total_error(): + assert runtime_context_installed() is False + + async def async_generator(): + yield 1 + yield 2 + yield 3 + + with pytest.raises( + TypeError, + match="Cannot determine the length of a collection. A `total` must be provided.", + ): + progress_bar(async_generator()) + + +def test_progress_for_loop_error(): + assert runtime_context_installed() is False + + async def async_generator(): + yield 1 + + with pytest.raises( + RuntimeError, + match="Cannot iterate over an async collection with `for`. Use `async for` instead.", + ): + for _ in progress_bar(async_generator(), total=1): + pass + + +async def test_progress_async_for_loop_error(): + assert runtime_context_installed() is False + + with pytest.raises( + RuntimeError, + match="Cannot iterate over a sync collection with `async for`. Use `for` instead.", + ): + async for _ in progress_bar([1, 2, 3]): + pass + + +async def test_progress_async_for_loop_without_collection_error(): + assert runtime_context_installed() is False + + with pytest.raises( + RuntimeError, + match="progress_bar can only be iterated over if a collection is provided", + ): + async for _ in progress_bar(total=1): + pass diff --git a/tests/_plugins/stateless/test_accordion.py b/tests/_plugins/stateless/test_accordion.py new file mode 100644 index 00000000000..717e189aec9 --- /dev/null +++ b/tests/_plugins/stateless/test_accordion.py @@ -0,0 +1,60 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import gc +import weakref + +import marimo as mo +from marimo._output.hypertext import Html +from marimo._plugins.stateless.accordion import accordion + + +def test_accordion_retains_strong_references() -> None: + # Regression test: accordion() must keep a strong reference to its children + # so that wrapped UI elements are not garbage collected (the UI registry + # holds children only weakly). + tabs = {f"label {n}": Html(f"tab {n}") for n in range(3)} + tab_refs = {label: weakref.ref(tab) for label, tab in tabs.items()} + + result = accordion(tabs) + assert isinstance(result, Html) + assert all(ref() is not None for ref in tab_refs.values()) + + del tabs + gc.collect() + + assert all(ref() is not None for ref in tab_refs.values()), ( + "accordion() did not retain a reference to all its children; " + "at least one child was garbage collected" + ) + + +def test_lazy_accordion_retains_strong_references() -> None: + tabs = {f"label {n}": Html(f"tab {n}") for n in range(3)} + factories = {label: (lambda tab=tab: tab) for label, tab in tabs.items()} + tab_refs = {label: weakref.ref(tab) for label, tab in tabs.items()} + + result = accordion(factories, lazy=True) + assert isinstance(result, Html) + assert all(ref() is not None for ref in tab_refs.values()) + + del tabs + del factories + gc.collect() + + assert all(ref() is not None for ref in tab_refs.values()), ( + "accordion() did not retain a reference to all its (lazily created) children; " + "at least one child was garbage collected" + ) + + +def test_accordion_child_updates_live() -> None: + # A mutable child (e.g. mo.status.spinner) re-renders on each access rather + # than being frozen at construction time. + with mo.status.spinner(title="Loading") as spinner: + result = accordion({"label": spinner}) + assert "Loading" in result.text + + spinner.update(title="Done") + assert "Done" in result.text + assert "Loading" not in result.text diff --git a/tests/_plugins/stateless/test_audio.py b/tests/_plugins/stateless/test_audio.py new file mode 100644 index 00000000000..fd078855303 --- /dev/null +++ b/tests/_plugins/stateless/test_audio.py @@ -0,0 +1,156 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import sys + +import pytest + +from marimo._dependencies.dependencies import DependencyManager +from marimo._plugins.stateless.audio import audio +from marimo._runtime.context import get_context +from marimo._runtime.runtime import Kernel +from tests.conftest import ExecReqProvider + +HAS_NUMPY = DependencyManager.numpy.has() + + +async def test_audio_url() -> None: + result = audio("https://example.com/test.wav") + assert ( + result.text + == "" + ) + + +async def test_audio_filename(k: Kernel, exec_req: ExecReqProvider) -> None: + await k.run( + [ + exec_req.get( + """ + import marimo as mo + import os + with open("test_audio.wav", "wb") as f: + f.write(b"hello") + audio = mo.audio("test_audio.wav") + # Delete the file + os.remove("test_audio.wav") + """ + ), + ] + ) + assert len(get_context().virtual_file_registry.registry) == 1 + for fname in get_context().virtual_file_registry.registry: + assert fname.endswith(".wav") + + +async def test_audio_bytes_io(k: Kernel, exec_req: ExecReqProvider) -> None: + await k.run( + [ + exec_req.get( + """ + import io + import marimo as mo + bytestream = io.BytesIO(b"hello") + audio = mo.audio(bytestream) + """ + ), + ] + ) + assert len(get_context().virtual_file_registry.registry) == 1 + for fname in get_context().virtual_file_registry.registry: + assert fname.endswith(".wav") + + +async def test_audio_bytes(k: Kernel, exec_req: ExecReqProvider) -> None: + await k.run( + [ + exec_req.get( + """ + import marimo as mo + audio = mo.audio(b"hello") + """ + ), + ] + ) + assert len(get_context().virtual_file_registry.registry) == 1 + for fname in get_context().virtual_file_registry.registry: + assert fname.endswith(".wav") + + +@pytest.mark.skipif(not HAS_NUMPY, reason="numpy not installed") +async def test_audio_numpy_mono(k: Kernel, exec_req: ExecReqProvider) -> None: + await k.run( + [ + exec_req.get( + """ + import marimo as mo + import numpy as np + data = np.random.rand(1000) * 2 - 1 # Random values between -1 and 1 + audio = mo.audio(data, rate=44100) + """ + ), + ] + ) + assert len(get_context().virtual_file_registry.registry) == 1 + for fname in get_context().virtual_file_registry.registry: + assert fname.endswith(".wav") + + +@pytest.mark.skipif(not HAS_NUMPY, reason="numpy not installed") +async def test_audio_numpy_normalize( + k: Kernel, exec_req: ExecReqProvider +) -> None: + await k.run( + [ + exec_req.get( + """ + import marimo as mo + import numpy as np + data = np.random.rand(1000) * 10 # Values > 1 + audio = mo.audio(data, rate=44100, normalize=True) + """ + ), + ] + ) + assert len(get_context().virtual_file_registry.registry) == 1 + for fname in get_context().virtual_file_registry.registry: + assert fname.endswith(".wav") + + +@pytest.mark.skipif(not HAS_NUMPY, reason="numpy not installed") +async def test_audio_numpy_constructor() -> None: + import numpy as np + + # Rate + data = np.random.rand(1000) * 2 - 1 # Random values between -1 and 1 + res = audio(data, rate=44100, normalize=False) + assert res.text.startswith("
- + - + diff --git a/tests/_runtime/snapshots/offline_collection_inside_marimo_package.txt b/tests/_runtime/snapshots/offline_collection_inside_marimo_package.txt new file mode 100644 index 00000000000..a70265a0e20 --- /dev/null +++ b/tests/_runtime/snapshots/offline_collection_inside_marimo_package.txt @@ -0,0 +1,5 @@ +marimo/_smoke_tests/_offline_collect_probe.py::MarimoTestBlock_0::test_param[1] +marimo/_smoke_tests/_offline_collect_probe.py::MarimoTestBlock_0::test_param[2] +marimo/_smoke_tests/_offline_collect_probe.py::MarimoTestBlock_0::test_uses_fixture + +3 tests collected in Ns diff --git a/tests/_runtime/test_agent.py b/tests/_runtime/test_agent.py new file mode 100644 index 00000000000..135f61f617b --- /dev/null +++ b/tests/_runtime/test_agent.py @@ -0,0 +1,92 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from marimo._ast.cell import CellConfig +from marimo._messaging.notebook.changes import SetCode, Transaction +from marimo._messaging.notebook.document import NotebookCell, NotebookDocument +from marimo._runtime.agent import Agent, AgentReadTracker +from marimo._types.ids import CellId_t + + +def _cell( + name: str, *, version: int = 0, code: str | None = None +) -> NotebookCell: + # Default to non-empty code so the cell counts as stale-eligible; tests + # that exercise the empty-cell exemption pass code="" explicitly. + return NotebookCell( + id=CellId_t(name), + code=f"# {name}" if code is None else code, + name="__", + config=CellConfig(), + version=version, + ) + + +def _doc(*cells: NotebookCell) -> NotebookDocument: + return NotebookDocument(list(cells)) + + +class TestAgentReadTracker: + def test_record_and_has_read(self) -> None: + t = AgentReadTracker() + assert not t.has_read(CellId_t("a"), 0) + t.record_read(CellId_t("a"), 0) + assert t.has_read(CellId_t("a"), 0) + assert not t.has_read(CellId_t("a"), 1) + + def test_record_read_max_merges(self) -> None: + t = AgentReadTracker() + t.record_read(CellId_t("a"), 5) + t.record_read(CellId_t("a"), 2) + assert t.has_read(CellId_t("a"), 5) + assert not t.has_read(CellId_t("a"), 6) + + def test_get_stale_cells_never_read(self) -> None: + t = AgentReadTracker() + doc = _doc(_cell("a"), _cell("b")) + assert t.get_stale_cells(doc) == frozenset( + {CellId_t("a"), CellId_t("b")} + ) + + def test_get_stale_cells_bumped_since_read(self) -> None: + t = AgentReadTracker() + doc = _doc(_cell("a", version=0), _cell("b", version=0)) + t.record_read(CellId_t("a"), 0) + t.record_read(CellId_t("b"), 0) + assert t.get_stale_cells(doc) == frozenset() + + doc.apply( + Transaction( + changes=(SetCode(cell_id=CellId_t("a"), code="x"),), + source="frontend", + ) + ) + assert doc.get_cell_version(CellId_t("a")) == 1 + assert t.get_stale_cells(doc) == frozenset({CellId_t("a")}) + + def test_get_stale_cells_ignores_deleted(self) -> None: + t = AgentReadTracker() + doc = _doc(_cell("a")) + t.record_read(CellId_t("ghost"), 7) + assert t.get_stale_cells(doc) == frozenset({CellId_t("a")}) + + def test_get_stale_cells_ignores_empty_cells(self) -> None: + t = AgentReadTracker() + doc = _doc( + _cell("empty", code=""), + _cell("whitespace", code=" \n "), + _cell("real", code="a = 1"), + ) + assert t.get_stale_cells(doc) == frozenset({CellId_t("real")}) + + +class TestAgent: + def test_default_factory_initializes_tracker(self) -> None: + a = Agent() + assert isinstance(a.read_tracker, AgentReadTracker) + + def test_independent_instances(self) -> None: + a1, a2 = Agent(), Agent() + a1.read_tracker.record_read(CellId_t("a"), 1) + assert a1.read_tracker.has_read(CellId_t("a"), 1) + assert not a2.read_tracker.has_read(CellId_t("a"), 1) diff --git a/tests/_runtime/test_app_mode.py b/tests/_runtime/test_app_mode.py index aae5cc2b725..7e996f70ecf 100644 --- a/tests/_runtime/test_app_mode.py +++ b/tests/_runtime/test_app_mode.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from unittest.mock import Mock, patch from marimo._runtime.context import ( @@ -8,7 +9,7 @@ from marimo._runtime.context.kernel_context import KernelRuntimeContext from marimo._runtime.context.script_context import ScriptRuntimeContext from marimo._runtime.context.utils import get_mode -from marimo._server.model import SessionMode +from marimo._session.model import SessionMode def test_get_mode_kernel_run(): @@ -43,10 +44,21 @@ def test_get_mode_script(): assert get_mode() == "script" +def test_get_mode_test(): + """Test get_mode() returns 'test' when testing with no context""" + with patch( + "marimo._runtime.context.utils.get_context", + side_effect=ContextNotInitializedError, + ): + assert get_mode() == "test" + + def test_get_mode_no_context(): - """Test get_mode() returns None when context is not initialized""" + """Test get_mode() returns 'test' when testing with no context""" + previous = os.environ.pop("PYTEST_CURRENT_TEST", None) with patch( "marimo._runtime.context.utils.get_context", side_effect=ContextNotInitializedError, ): assert get_mode() is None + os.environ["PYTEST_CURRENT_TEST"] = previous diff --git a/tests/_runtime/test_cached_stage.py b/tests/_runtime/test_cached_stage.py new file mode 100644 index 00000000000..bdcb69a9928 --- /dev/null +++ b/tests/_runtime/test_cached_stage.py @@ -0,0 +1,456 @@ +# Copyright 2026 Marimo. All rights reserved. +"""Tests for CachedLifecycle — cell-level caching as a per-cell lifecycle. + +Ported from the original cell-caching test suite and adapted to the +integrated `executor/lifecycles` framework (the source branch's +`CachedStage`/`wrappers` names were abandoned intermediate renames). +UnhashableStub tripwire assertions follow the shipped design: `__call__` +is the only tripwire; other accesses fall through to Python defaults. +""" + +from __future__ import annotations + +import copy +import dataclasses +from typing import TYPE_CHECKING + +import pytest + +from marimo._runtime.exceptions import ( + MarimoRescheduleError, +) +from marimo._runtime.executor.lifecycles.cached import CachedLifecycle +from marimo._save.loaders.lazy import LazyLoader + +try: + # Ships with the stub serialization toolkit; the lifecycle detects + # stubs through the __marimo_unhashable__ protocol attribute and has + # no hard dependency on the class. + from marimo._save.stubs.lazy_stub import UnhashableStub +except ImportError: # pragma: no cover + UnhashableStub = None # type: ignore[assignment] + +# The end-to-end tripwire tests additionally need the lazy loader that +# *produces* UnhashableStub on serialization failure. +try: + from marimo._save.loaders.lazy import LazyStore as _LazyStore +except ImportError: # pragma: no cover + _LazyStore = None # type: ignore[assignment] + +requires_stub_loader = pytest.mark.skipif( + UnhashableStub is None or _LazyStore is None, + reason="needs the stub toolkit + per-def lazy store", +) + + +@dataclasses.dataclass +class _MarkerStub: + """Minimal stand-in carrying the unhashable-stub protocol marker.""" + + # Class-level protocol marker (no annotation → not a dataclass field). + __marimo_unhashable__ = True + + var_name: str + + +if TYPE_CHECKING: + from pathlib import Path + + from tests.conftest import ExecReqProvider, MockedKernel + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def cache_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Redirect FileStore's default save path to a tmp dir for the test.""" + cache_path = tmp_path / "cache" + cache_path.mkdir() + + def _default_save_path(_self: object) -> Path: + return cache_path + + monkeypatch.setattr( + "marimo._save.stores.file.FileStore._default_save_path", + _default_save_path, + ) + return cache_path + + +@pytest.fixture +def tracked_loaders(monkeypatch: pytest.MonkeyPatch) -> list[LazyLoader]: + """Capture every LazyLoader instance constructed during the test. + + Lifecycles live on each Runner instance and are GC'd between runs, so + there's no kernel-level handle on the loader. Tracking via __init__ + lets tests call .flush() on every loader to drain background save + threads deterministically (instead of sleeping). + """ + instances: list[LazyLoader] = [] + original_init = LazyLoader.__init__ + + def _tracking_init( + self: LazyLoader, + *args: object, + **kwargs: object, + ) -> None: + original_init(self, *args, **kwargs) # type: ignore[arg-type] + instances.append(self) + + monkeypatch.setattr(LazyLoader, "__init__", _tracking_init) + return instances + + +@pytest.fixture +def caching_kernel( + mocked_kernel: MockedKernel, + cache_dir: Path, # noqa: ARG001 — needed for side effect + tracked_loaders: list[LazyLoader], # noqa: ARG001 — needed for side effect +) -> MockedKernel: + """A kernel with cache_cells enabled and a tmp cache dir.""" + # Deep-copy so we don't mutate the shared DEFAULT_CONFIG dict. + mocked_kernel.k.user_config = copy.deepcopy(mocked_kernel.k.user_config) + mocked_kernel.k.user_config["runtime"]["cache_cells"] = True + return mocked_kernel + + +# --------------------------------------------------------------------------- +# CachedLifecycle._preflight_refs — stub-ref detection routes to requeue +# --------------------------------------------------------------------------- + + +class TestCachedLifecyclePreflight: + def test_stub_ref_invalidates_producer_and_raises( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """When a consumer's transitive ref resolves to an UnhashableStub in + scope, pre-flight marks the producer stale (so it re-runs live rather + than re-hitting the same unusable value) and raises + MarimoRescheduleError with cells_to_rerun populated, so run_all can + requeue the producer (plus this cell). + + Invalidation marks the manifest stale but must NOT destroy it: the + producer's entry is not corrupt, it merely restored as a stub in this + environment (e.g. an optional dep is absent). Destroying it would lose a + recoverable hit if the producer's live rerun also fails. + """ + from unittest.mock import MagicMock + + graph = MagicMock() + graph.get_defining_cells.return_value = {"producer"} + + life = CachedLifecycle(graph) + producer_manifest = "lazy/E_producer.jsonl" + life._restored_keys["producer"] = producer_manifest + + stale_calls: list[str] = [] + clear_calls: list[str] = [] + monkeypatch.setattr( + life._loader, "mark_stale", lambda key: stale_calls.append(key) + ) + monkeypatch.setattr( + life._loader.store, + "clear", + lambda key: bool(clear_calls.append(key)), + ) + + cell = _FakeCell("consumer", refs={"f"}) + glbls = {"f": _MarkerStub("f")} + + with pytest.raises(MarimoRescheduleError) as ei: + life._preflight_refs(cell, glbls) # type: ignore[arg-type] + + assert stale_calls == [producer_manifest] + # The producer's cache entry is preserved (not cleared) so a rerun that + # itself fails leaves the recoverable hit intact. + assert clear_calls == [] + assert {"producer", "consumer"} <= ei.value.cells_to_rerun + + def test_persistent_stub_producer_not_requeued_twice(self) -> None: + """A producer already invalidated once that still hands back a stub + is not requeued again: pre-flight returns cleanly so the body runs + and the tripwire raises on access, rather than looping forever. + """ + from unittest.mock import MagicMock + + graph = MagicMock() + graph.get_defining_cells.return_value = {"producer"} + + life = CachedLifecycle(graph) + # Producer already had its rerun and still yields a stub. + life._invalidated.add("producer") + + cell = _FakeCell("consumer", refs={"f"}) + glbls = {"f": _MarkerStub("f")} + # No MarimoRescheduleError — falls through to run the body. + life._preflight_refs(cell, glbls) # type: ignore[arg-type] + + def test_invalidated_guard_resets_on_clean_rerun(self) -> None: + """A producer guarded after invalidation is released once it runs + cleanly, so a later pre-flight can requeue it again. Only a cell that + keeps erroring stays guarded — that is the loop the bound stops. + """ + from unittest.mock import MagicMock + + from marimo._runtime.runner.result import RunResult + + life = CachedLifecycle(MagicMock()) + producer = _FakeCell("producer") + producer.defs = {"g"} + + # A clean run that replaced the stub releases the guard. + life._invalidated.add("producer") + life.teardown( + producer, # type: ignore[arg-type] + {"g": 123}, + RunResult(output=None, exception=None), + ) + assert "producer" not in life._invalidated + + # A clean run that still PROPAGATES a stub stays guarded (e.g. `g = f` + # where f is a stub) — otherwise the consumer requeues it forever. + life._invalidated.add("producer") + life.teardown( + producer, # type: ignore[arg-type] + {"g": _MarkerStub("g")}, + RunResult(output=None, exception=None), + ) + assert "producer" in life._invalidated + + # An erroring run keeps it guarded (so it can't loop). + life._invalidated.add("producer") + life.teardown( + producer, # type: ignore[arg-type] + {}, + RunResult(output=None, exception=ValueError("boom")), + ) + assert "producer" in life._invalidated + + def test_no_stub_refs_is_noop(self) -> None: + """Pre-flight returns cleanly when no ref is an UnhashableStub.""" + from unittest.mock import MagicMock + + life = CachedLifecycle(MagicMock()) + cell = _FakeCell("consumer", refs={"x"}) + # No exception expected. + life._preflight_refs(cell, {"x": 123}) # type: ignore[arg-type] + + def test_restore_cache_exception_falls_through_to_miss( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """`Cache.restore` can raise `CacheException`, which subclasses + `BaseException` (not `Exception`). Setup must catch it explicitly + so a corrupt-cache hit falls through to miss-path execution rather + than escaping as a hard cell error. + """ + from unittest.mock import MagicMock + + import marimo._runtime.executor.lifecycles.cached as cached_mod + from marimo._save.cache import CacheException + + life = CachedLifecycle(MagicMock()) + + attempt = MagicMock() + attempt.hit = True + attempt.restore.side_effect = CacheException("corrupt blob") + monkeypatch.setattr( + cached_mod, "cache_attempt_from_hash", lambda *_a, **_k: attempt + ) + + cell = _FakeCell("consumer", refs=set()) + # Must not raise; returns None (miss path) and drops the attempt. + assert life.setup(cell, {}) is None # type: ignore[arg-type] + assert "consumer" not in life._attempts + attempt.restore.assert_called_once() + + +# --------------------------------------------------------------------------- +# Integration tests — full kernel with cache_cells enabled +# --------------------------------------------------------------------------- + + +class TestCachedLifecycleIntegration: + async def test_basic_hit_miss_cycle( + self, + caching_kernel: MockedKernel, + exec_req: ExecReqProvider, + tracked_loaders: list[LazyLoader], + ) -> None: + """First run misses + executes; second run with same code hits.""" + k = caching_kernel.k + er = exec_req.get(code="x = 1 + 2") + + await k.run([er]) + assert k.globals["x"] == 3 + + for loader in tracked_loaders: + loader.flush() + + loaders_before_second = list(tracked_loaders) + await k.run([er]) + assert k.globals["x"] == 3 + + new_loaders = [ + ld for ld in tracked_loaders if ld not in loaders_before_second + ] + assert new_loaders, "Expected a fresh LazyLoader for the second run" + assert any(ld.hits > 0 for ld in new_loaders), ( + "Expected the second run's LazyLoader to record a cache hit" + ) + + @requires_stub_loader + async def test_unhashable_own_def_does_not_auto_rerun( + self, + caching_kernel: MockedKernel, + exec_req: ExecReqProvider, + tracked_loaders: list[LazyLoader], + ) -> None: + """Cell whose own def is a lambda: cache hit on next session, + body skipped, marker in scope. Not auto-rerun (that would defeat + caching for cells where downstream never needs the real value). + """ + k = caching_kernel.k + er = exec_req.get(code="f = lambda x: x + 1") + + await k.run([er]) + assert callable(k.globals["f"]) + assert k.globals["f"](2) == 3 + + for loader in tracked_loaders: + loader.flush() + + # Simulate fresh session. + k.globals.pop("f", None) + + loaders_before_second = list(tracked_loaders) + await k.run([er]) + new_loaders = [ + ld for ld in tracked_loaders if ld not in loaders_before_second + ] + + # Body skipped — `f` in scope is the UnhashableStub marker. + assert isinstance(k.globals.get("f"), UnhashableStub) + assert any(ld.hits > 0 for ld in new_loaders) + + async def test_failed_run_not_cached( + self, + caching_kernel: MockedKernel, + exec_req: ExecReqProvider, + tracked_loaders: list[LazyLoader], + ) -> None: + k = caching_kernel.k + er = exec_req.get(code="raise RuntimeError('boom')") + + await k.run([er]) + + for loader in tracked_loaders: + loader.flush() + + loaders_before_second = list(tracked_loaders) + await k.run([er]) + + new_loaders = [ + ld for ld in tracked_loaders if ld not in loaders_before_second + ] + assert new_loaders + assert all(ld.hits == 0 for ld in new_loaders) + + @requires_stub_loader + async def test_consumer_calling_lambda_recovers( + self, + caching_kernel: MockedKernel, + exec_req: ExecReqProvider, + tracked_loaders: list[LazyLoader], + ) -> None: + """Producer A defines a lambda; consumer B references it directly. + After fresh-kernel reset, A hits cache (stub in scope), B's hash + differs and misses, B's pre-flight sees the stub in its refs → + invalidates A and requeues. A re-runs with the real lambda; B + retries; `g == 15`. + + A bare lambda is `__main__`/`builtins.function` — not a marimo-owned + stub — so the consumer routes it through the execution path and cannot + reproduce the producer's key from the stub alone. Reschedule-recovery + is the correct outcome here (the lambda is reconstructable by re-running + A). The marimo-owned unpicklable case (a `persistent_cache` wrapper) is + instead dequeued to zero contribution in `hash.py`, so its consumers + stay content-addressed and hit without a reschedule. + """ + k = caching_kernel.k + producer = exec_req.get(code="f = lambda x: x + 10") + consumer = exec_req.get(code="g = f(5)") + + await k.run([producer, consumer]) + assert k.globals["g"] == 15 + + for loader in tracked_loaders: + loader.flush() + + # Simulate fresh session. + k.globals.pop("f", None) + k.globals.pop("g", None) + + await k.run([producer, consumer]) + assert k.globals["g"] == 15 + assert callable(k.globals["f"]) + assert not isinstance(k.globals["f"], UnhashableStub) + assert not isinstance(k.globals["g"], UnhashableStub) + + @requires_stub_loader + async def test_serializable_sibling_ref_does_not_drag_producer_live( + self, + caching_kernel: MockedKernel, + exec_req: ExecReqProvider, + tracked_loaders: list[LazyLoader], + ) -> None: + """A producer defines a serializable value alongside an unpicklable one; + a consumer that references ONLY the serializable value must not force the + producer to re-run live. + + This is the invariant behind the KANNS export fix: split the UI labels + (serializable strings) out from the callables (lambdas) so the UI cell + references the labels. On restore the labels come back as a real value — + no stub in the consumer's refs — so pre-flight never reschedules the + producer, and its unpicklable def stays an inert stub. Proof that the + producer was NOT dragged live: `fns` remains an UnhashableStub. + """ + k = caching_kernel.k + producer = exec_req.get( + code="labels = ['a', 'b']; fns = [lambda: 1, lambda: 2]" + ) + consumer = exec_req.get(code="n = len(labels)") + + await k.run([producer, consumer]) + assert k.globals["n"] == 2 + + for loader in tracked_loaders: + loader.flush() + + # Simulate fresh session. + k.globals.pop("labels", None) + k.globals.pop("fns", None) + k.globals.pop("n", None) + + await k.run([producer, consumer]) + # Consumer still resolves from the serializable sibling. + assert k.globals["n"] == 2 + # Producer was skipped (hit) and NOT rescheduled live: its unpicklable + # def is still the inert stub, and its serializable def is a real list. + assert isinstance(k.globals.get("fns"), UnhashableStub) + assert k.globals.get("labels") == ["a", "b"] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class _FakeCell: + def __init__(self, cell_id: str, refs: set[str] | None = None) -> None: + self.cell_id = cell_id + self.refs = refs or set() + self.defs: set[str] = set() + self.mod = None diff --git a/tests/_runtime/test_capture.py b/tests/_runtime/test_capture.py index 70ef79bf2b4..f0236aabb59 100644 --- a/tests/_runtime/test_capture.py +++ b/tests/_runtime/test_capture.py @@ -1,18 +1,18 @@ -# Copyright 2024 Marimo. All rights reserved. +# Copyright 2026 Marimo. All rights reserved. from __future__ import annotations +import json import re -from typing import Any, Dict, Tuple +from marimo._messaging.types import KernelMessage from tests.conftest import ExecReqProvider, MockedKernel -def _has_output( - messages: list[Tuple[str, Dict[Any, Any]]], pattern: str -) -> bool: - for op, data in messages: +def _has_output(messages: list[KernelMessage], pattern: str) -> bool: + for data in messages: + data = json.loads(data) if ( - op == "cell-op" + data["op"] == "cell-op" and data["output"] is not None and re.match(pattern, data["output"]["data"]) ): diff --git a/tests/_runtime/test_cell_output_list.py b/tests/_runtime/test_cell_output_list.py new file mode 100644 index 00000000000..6c898e9294c --- /dev/null +++ b/tests/_runtime/test_cell_output_list.py @@ -0,0 +1,71 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from marimo._runtime.cell_output_list import CellOutputList + + +def _html(text: str) -> MagicMock: + m = MagicMock() + m.__repr__ = lambda _: text + return m + + +class TestCellOutputList: + def test_append_and_len(self) -> None: + col = CellOutputList() + assert len(col) == 0 + col.append(_html("a")) + col.append(_html("b")) + assert len(col) == 2 + + def test_bool(self) -> None: + col = CellOutputList() + assert not col + col.append(_html("a")) + assert col + + def test_clear(self) -> None: + col = CellOutputList() + col.append(_html("a")) + col.clear() + assert len(col) == 0 + + def test_replace_at_index(self) -> None: + col = CellOutputList() + col.append(_html("a")) + col.append(_html("b")) + replacement = _html("c") + col.replace_at_index(replacement, 0) + assert col._items[0] is replacement + assert len(col) == 2 + + def test_replace_at_index_appends_at_end(self) -> None: + col = CellOutputList() + col.append(_html("a")) + new = _html("b") + col.replace_at_index(new, 1) + assert len(col) == 2 + assert col._items[1] is new + + def test_replace_at_index_out_of_range(self) -> None: + col = CellOutputList() + with pytest.raises(IndexError): + col.replace_at_index(_html("a"), 5) + + def test_remove_by_identity(self) -> None: + col = CellOutputList() + a = _html("a") + b = _html("b") + col.append(a) + col.append(b) + col.remove(a) + assert len(col) == 1 + assert col._items[0] is b + + def test_stack_empty_returns_none(self) -> None: + col = CellOutputList() + assert col.stack() is None diff --git a/tests/_runtime/test_commands.py b/tests/_runtime/test_commands.py new file mode 100644 index 00000000000..b553dc9e3b9 --- /dev/null +++ b/tests/_runtime/test_commands.py @@ -0,0 +1,10 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from marimo._runtime.commands import kebab_case + + +def test_kebab_case() -> None: + assert kebab_case("SomeSQLCommand") == "some-sql" + assert kebab_case("SomeSQL") == "some-sql" + assert kebab_case("MyNotificationCommand") == "my-notification" diff --git a/tests/_runtime/test_complete.py b/tests/_runtime/test_complete.py index d5ff588cdf0..e0118189f3c 100644 --- a/tests/_runtime/test_complete.py +++ b/tests/_runtime/test_complete.py @@ -1,30 +1,63 @@ -from marimo._runtime.complete import _build_docstring_cached +from __future__ import annotations + +import random +import threading +import time +from collections.abc import Mapping +from inspect import signature +from types import ModuleType +from typing import Any +from unittest import mock + +import jedi +import pytest + +import marimo +from marimo._dependencies.dependencies import DependencyManager +from marimo._messaging.notification import CompletionResultNotification +from marimo._messaging.serde import deserialize_kernel_message +from marimo._messaging.types import KernelMessage, Stream +from marimo._runtime.commands import CodeCompletionCommand +from marimo._runtime.complete import ( + _build_docstring_cached, + _get_completion_info, + _get_completion_option, + _get_completion_options, + _get_completions, + _get_docstring, + _maybe_get_key_options, + _resolve_chained_key_path, + complete, +) +from marimo._runtime.patches import patch_jedi_parameter_completion +from marimo._types.ids import CellId_t from tests.mocks import snapshotter snapshot = snapshotter(__file__) +HAS_PANDAS = DependencyManager.pandas.has() +HAS_POLARS = DependencyManager.polars.has() def test_build_docstring_function_no_init(): result = _build_docstring_cached( completion_type="function", completion_name="my_func", - module_name="marimo.my_module", signature_strings=("my_func(arg1, arg2)",), raw_body="This is a simple docstring for a function.", init_docstring=None, ) assert "my_func" in result assert "This is a simple docstring for a function." in result - assert '
' in result - snapshot("docstrings_function.txt", result) + assert '
' in result + if DependencyManager.docstring_to_markdown.has(): + snapshot("docstrings_function.txt", result) def test_docstring_function_with_google_style(): result = _build_docstring_cached( completion_type="function", completion_name="my_func", - module_name="marimo.my_module", - signature_strings=("my_func(arg1, arg2)",), + signature_strings=("my_func(arg1: str, arg2: int)",), raw_body=""" Args: arg1: Description of arg1. @@ -34,33 +67,231 @@ def test_docstring_function_with_google_style(): HTML: A description of the return value. """, init_docstring=None, + param_types=(("arg1", "str"), ("arg2", "int")), ) assert "Description of arg1" in result assert "Description of arg2" in result assert "A description of the return value" in result + assert "str" in result + assert "int" in result snapshot("docstrings_function_google.txt", result) -def test_docstring_function_from_external_module(): +def test_docstring_function_with_google_style_infers_types_from_jedi() -> None: + patch_jedi_parameter_completion() + + code = '''def func(arg: int) -> None: + """Do something + + Args: + arg: An integer argument + """ + return + +func''' + script = jedi.Script(code) + completions = script.complete(line=9, column=4) + func_completion = next(c for c in completions if c.name == "func") + result = _get_docstring(func_completion) + + assert "int" in result + assert "An integer argument" in result + + +def test_docstring_function_infers_varargs_types_from_jedi() -> None: + patch_jedi_parameter_completion() + + code = '''def func(*args: str, **kwargs: float) -> None: + """Do something + + Args: + *args: extra positionals + **kwargs: extra keywords + """ + return + +func''' + script = jedi.Script(code) + completions = script.complete(line=10, column=4) + func_completion = next(c for c in completions if c.name == "func") + result = _get_docstring(func_completion) + + assert "extra positionals" in result + assert "extra keywords" in result + # Jedi reports varargs as container types (`args`/`kwargs` without stars); + # they should still populate the `*args`/`**kwargs` rows. + assert "Tuple[str]" in result + assert "Dict[str, float]" in result + + +def test_docstring_math_directive_is_normalized(): result = _build_docstring_cached( completion_type="function", completion_name="my_func", - module_name="os", - signature_strings=("my_func(arg1, arg2)",), - raw_body="This is a simple docstring for a function.", + signature_strings=("my_func(arg1)",), + raw_body=r""" + For :math:`t > 0`, we have: + + .. math:: + + m_t = \beta_1 \cdot m_{t-1} + """, + init_docstring=None, + ) + + assert ".. math::" not in result + assert ":math:`" not in result + assert "= 3 + + +def test_docstring_math_normalization_skips_fenced_code_blocks(): + raw_docstring = """ +Before + +```python +formula = ":math:`x`" +directive = ''' +.. math:: + + x^2 +''' +``` + +After :math:`y` +""" + result = _build_docstring_cached( + completion_type="function", + completion_name="my_func", + signature_strings=("my_func(arg1)",), + raw_body=raw_docstring, init_docstring=None, ) - assert "This is a simple docstring for a function." in result - assert "
" in result - snapshot("docstrings_function_external.txt", result) + + if DependencyManager.docstring_to_markdown.has(): + # docstring_to_markdown may normalize fenced code content to $$. + assert "$$" in result + else: + assert ".. math::" in result + assert ":math:`y`" not in result + assert result.count(" str: + return "MyClass()" + + class _FakeInit: + name = "__init__" + + def docstring(self, raw: bool = False) -> str: + assert raw + return r""" + .. math:: + + m_t = \beta_1 \cdot g_t + """ + + class _FakeDefinition: + def defined_names(self) -> list[_FakeInit]: + return [_FakeInit()] + + class _FakeCompletion: + type = "class" + name = "MyClass" + + def docstring(self, raw: bool = False) -> str: + assert raw + return "Class docs." + + def get_signatures(self) -> list[_FakeSignature]: + return [_FakeSignature()] + + def goto(self) -> list[_FakeDefinition]: + return [_FakeDefinition()] + + with mock.patch( + "marimo._runtime.complete.jedi.api.classes.Name", _FakeDefinition + ): + result = _get_docstring(_FakeCompletion()) + + assert "__init__ docstring:" in result + assert ".. math::" not in result + assert " 1 + objects_to_check = set() + for module in modules_to_check: + for attribute in dir(module): + if attribute.startswith("_"): + continue + obj = getattr(module, attribute) + if not callable(obj): + continue + objects_to_check.add(obj) + assert len(objects_to_check) > 1 + return sorted( + objects_to_check, + key=lambda obj: f"{obj.__module__}.{obj.__qualname__}", + ) + + +def dummy_func(arg1: str, arg2: str) -> None: + """ + Parameters + ---------- + arg1 + polars often uses this format + arg2 : str, required + while other libraries prefer this format (which polars uses too) + """ + del arg1, arg2 + + +@pytest.mark.skipif( + not DependencyManager.docstring_to_markdown.has(), + reason="docstring_to_markdown is not installed", +) +@pytest.mark.parametrize( + ("obj", "runtime_inference"), + [[obj, False] for obj in collect_functions_to_check()] + + [ + # Test runtime inference for a subset of values + [marimo.accordion, True], + [dummy_func, False], + ], + ids=lambda obj: ( + f"{obj}" + if isinstance(obj, bool) + else f"{obj.__module__}.{obj.__qualname__}" + ), +) +def test_parameter_descriptions(obj: Any, runtime_inference: bool): + patch_jedi_parameter_completion() + import_name = obj.__module__ + marimo_export = obj.__name__ + path = f"{import_name}.{marimo_export}" + if path == "marimo._output.hypertext.Html": + pytest.skip("Known issue with `Html` being a quasi-dataclass") + if path.startswith("marimo._save.save."): + pytest.skip( + "Cache functions use overloads to distinguish calls and context managers" + " this can be fixed by splitting docstring on per-overload basis, but that" + " is not yet supported by mkdocstrings for documentation rendering, see" + " https://github.com/mkdocstrings/python/issues/135" + ) + if path.endswith("dummy_func"): + pytest.skip("Not picking up parameters for dummy_func") + if path.endswith("ChatMessage"): + pytest.skip( + "ChatMessage is a msgspec struct which does not support Jedi dict completions" + ) + call = f"{path}(" + code = f"import {import_name};{call}" + jedi.settings.auto_import_modules = ["marimo"] if runtime_inference else [] + script = jedi.Script(code=code) + completions: list[Any] = script.complete(line=1, column=len(code)) + param_completions: dict[str, Any] = { + completion.name[:-1]: completion + for completion in completions + if completion.name.endswith("=") + } + for param_name, param in signature(obj).parameters.items(): + if param_name.startswith("_"): + continue + if param.kind in {param.VAR_KEYWORD, param.VAR_POSITIONAL}: + continue + assert param_name in param_completions, ( + f"Jedi did not suggest {param_name} in {call}. It suggested {param_completions.keys()}" + ) + jedi_param = param_completions[param_name] + # raw=True: Jedi otherwise prepends inferred signature strings for + # annotated unions like str|None / int|None (builtin ctor docs + NoneType), + # which is noise for dataclass field comments served by py__doc__. + docstring = jedi_param.docstring(raw=True) + assert docstring != "", f"Empty docstring result: {call}{param_name}" + assert "NoneType" not in docstring, ( + f"NoneType found in docstring: {call}{param_name}" + ) + + +DOCUMENT_AND_EXPECTS_COMPLETIONS: tuple[tuple[str, bool], ...] = ( + ("obj['", True), + ('obj["', True), + ("assigned = obj['", True), + ("multiline = 'foo'\nobj['", True), + ("for i in iterator:\n\tobj['", True), + # shouldn't trigger on the following notations + ("obj.", False), + ("obj", False), +) + + +def cases_objects_supporting_key_completion() -> tuple[ + tuple[Any, list[str]], ... +]: + """Values stored in `globals` when key completion is triggered.""" + + class IPythonImplemented: + def __init__(self): + self._table = { + "foo": [0, 1], + "bar": [1.0, 3.0], + } + + @property + def a_property(self) -> str: + """This is a property""" + return "prop value" + + def __getitem__(self, key: str) -> list: + """Returns a mock column""" + return self._table[key] + + def _ipython_key_completions_(self) -> list[str]: + return list(self._table.keys()) + + class CustomMapping(Mapping): + def __init__(self): + self._data = { + "foo": [0, 1], + "bar": [1.0, 3.0], + } + + def __iter__(self): + return iter(self._data.keys()) + + def __getitem__(self, key): + raise NotImplementedError + + def __len__(self): + raise NotImplementedError + + static_key_dict = ({"foo": [0, 1], "bar": [1.0, 3.0]}, ["foo", "bar"]) + # use different ranges to prevent key collisions + dynamic_key_1 = str(random.randint(0, 9)) + dynamic_key_2 = str(random.randint(10, 19)) + dynamic_key_dict = ( + {dynamic_key_1: "val1", dynamic_key_2: "val2"}, + [dynamic_key_1, dynamic_key_2], + ) + mixed_keys_dict = ( + {"foo": [0, 1], dynamic_key_1: "val2"}, + ["foo", dynamic_key_1], + ) + ipython_case = (IPythonImplemented(), ["foo", "bar"]) + custom_mapping_case = (CustomMapping(), ["foo", "bar"]) + + return ( + static_key_dict, + dynamic_key_dict, + mixed_keys_dict, + ipython_case, + custom_mapping_case, + ) + + +@pytest.mark.parametrize( + "document_and_expects_completions", DOCUMENT_AND_EXPECTS_COMPLETIONS +) +@pytest.mark.parametrize( + "obj_and_expected_completions", cases_objects_supporting_key_completion() +) +def test_maybe_get_key_options( + document_and_expects_completions: tuple[str, bool], + obj_and_expected_completions: tuple[Any, list[str]], +): + """Low-level test for `_maybe_get_key_options()`""" + document, expects_completions = document_and_expects_completions + obj, expected_completions = obj_and_expected_completions + glbls = {"obj": obj, "other": 10} + lock = threading.RLock() + script = jedi.Script(code=document) + + completions = _maybe_get_key_options( + document=document, script=script, glbls=glbls, glbls_lock=lock + ) + + if expects_completions is True: + assert [c.name for c in completions] == expected_completions + else: + assert completions == [] + + +# TODO case could be added to `cases_objects_supporting_key_completions()` +# the test has the same logic of `test_maybe_get_key_options()` +@pytest.mark.skipif(not HAS_PANDAS, reason="pandas not installed.") +@pytest.mark.parametrize( + "document_and_expects_completions", DOCUMENT_AND_EXPECTS_COMPLETIONS +) +def test_maybe_get_key_options_pandas_dataframe( + document_and_expects_completions: tuple[str, bool], +) -> None: + import pandas as pd + + document, expects_completions = document_and_expects_completions + expected_completions = ["foo", "bar"] + glbls = { + "obj": pd.DataFrame({"foo": [0, 1], "bar": [9.0, 2.0]}), + "other": 10, + } + lock = threading.RLock() + script = jedi.Script(code=document) + + completions = _maybe_get_key_options( + document=document, + script=script, + glbls=glbls, + glbls_lock=lock, + ) + + if expects_completions is True: + assert [c.name for c in completions] == expected_completions + else: + assert completions == [] + + +class CaptureStream(Stream): + def __init__(self): + self.messages: list[KernelMessage] = [] + + def write(self, data: KernelMessage) -> None: + self.messages.append(data) + # Attempt to deserialize the message to ensure it is valid + deserialize_kernel_message(data) + + @property + def operations(self) -> list[dict[str, Any]]: + import json + + return [json.loads(op_data) for op_data in self.messages] + + +# TODO add test cases for all other completion modalities +# TODO improve coupling between variable name, source code, and assertions +@pytest.mark.parametrize( + "document_and_expects_completions", DOCUMENT_AND_EXPECTS_COMPLETIONS +) +@pytest.mark.parametrize( + "object_name", ["static_key", "dynamic_key", "mixed_keys", "ipython_data"] +) +@pytest.mark.parametrize("chained_completion", [True, False]) +def test_key_completion_main_entrypoint( + document_and_expects_completions: tuple[str, bool], + object_name: str, + chained_completion: bool, +) -> None: + """Test key completion using the main entrypoint `marimo._runtime.complete()` + + Params: + document_and_expects_completions: contains (`document`, `expects_completion`) + `document` is the source code up to the cursor when triggering autocompletion. + `expects_completions` is a boolean whether we expect key completion; if False, + it could still trigger other completion mechanisms + object_name: the name of the object in the source code that we'll assign to `obj`. + All tests parametrization trigger completion on `obj`, but we change what + `obj` points. + chained_completion: if False, `obj = object_name`. If True, `obj = {top_level_key: object_name}` + This allows to nested chained autocompletion. + """ + top_level_key = "depth0" + document, expects_key_completion = document_and_expects_completions + if chained_completion: + document = document.replace("obj[", f"obj['{top_level_key}'][") + + other_cells_code = '''\ +import random + +class CustomData: + def __init__(self): + self._table = { + "foo": [0, 1], + "bar": [1., 3.], + "baz": [True, True], + } + + @property + def a_property(self) -> str: + """This is a property""" + return "prop value" + + def __getitem__(self, key: str) -> list: + """Returns a mock column""" + return self._table[key] + + def _ipython_key_completions_(self) -> list[str]: + return list(self._table.keys()) + +ipython_data = CustomData() +static_key = {"static_key": "foo"} +dynamic_key = {str(random.randint(0, 10)): "foo"} +mixed_keys = {"static_key": "foo", str(random.randint(0, 10)): "bar"} +''' + if chained_completion: + other_cells_code += f"obj = dict({top_level_key}={object_name})" + else: + other_cells_code += f"obj = {object_name}" + + mock_other_cell = mock.MagicMock() + mock_other_cell.code = other_cells_code + + mock_current_cell = mock.MagicMock() + mock_current_cell.code = document + current_cell_id = CellId_t("my-request-id") + + mock_graph = mock.MagicMock() + mock_graph.cells = { + "other-cell-id": mock_other_cell, + current_cell_id: mock_current_cell, + } + + glbls = {} + exec(other_cells_code, {}, glbls) + # check existence of variables in globals and their type + assert isinstance( + glbls.get("ipython_data"), glbls.get("CustomData", Exception) + ) + assert isinstance(glbls.get("static_key"), dict) + assert isinstance(glbls.get("dynamic_key"), dict) + assert isinstance(glbls.get("mixed_keys"), dict) + + lock = threading.RLock() + local_stream = CaptureStream() + + completion_request = CodeCompletionCommand( + id="request_id", + document=document, + cell_id=current_cell_id, + ) + + complete( + request=completion_request, + graph=mock_graph, + glbls=glbls, + glbls_lock=lock, + stream=local_stream, + ) + + message_name = local_stream.operations[0]["op"] + content = local_stream.operations[0] + prefix_length = content["prefix_length"] + options = content["options"] + options_values = [option["name"] for option in options] + + assert len(local_stream.messages) == 1 + assert message_name == CompletionResultNotification.name + # TODO if `expects_completions=False`, something else than `_maybe_get_key_options()` + # could be returning values + if expects_key_completion is False: + return + + assert prefix_length == 0 + assert all(option["type"] == "property" for option in options) + assert all(option["completion_info"] == "key" for option in options) + + expected_keys: list[str] = [] + if object_name == "static_key": + # from source code in variable `other_cells_code` + expected_keys = ["static_key"] + elif object_name == "dynamic_key": + expected_keys = list(glbls["dynamic_key"].keys()) + elif object_name == "mixed_keys": + expected_keys = list(glbls["mixed_keys"].keys()) + elif object_name == "ipython_data": + # from source code in variable `other_cells_code` + expected_keys = ["foo", "bar", "baz"] + else: + raise RuntimeError( + f"Make sure you defined `expected_keys` for `{object_name}`" + " Currently, the test is improperly defined." + ) + + # check `len()` to ensure `set()` operation doesn't deduplicate keys + assert len(options_values) == len(expected_keys) + assert set(options_values) == set(expected_keys) + + +def _run_complete(document: str, other_code: str = "") -> dict[str, Any]: + """Run the `complete()` entrypoint and return the emitted notification.""" + current_cell_id = CellId_t("current-cell") + + mock_other_cell = mock.MagicMock() + mock_other_cell.code = other_code + mock_current_cell = mock.MagicMock() + mock_current_cell.code = document + + mock_graph = mock.MagicMock() + mock_graph.cells = { + "other-cell": mock_other_cell, + current_cell_id: mock_current_cell, + } + + glbls: dict[str, Any] = {} + if other_code: + exec(other_code, {}, glbls) + + stream = CaptureStream() + complete( + request=CodeCompletionCommand( + id="request-id", document=document, cell_id=current_cell_id + ), + graph=mock_graph, + glbls=glbls, + glbls_lock=threading.RLock(), + stream=stream, + ) + assert len(stream.operations) == 1 + return stream.operations[0] + + +@pytest.mark.parametrize("document", ["1,", "foo(", "foo(1,", "[1, "]) +def test_no_completions_after_comma_or_paren_without_signature( + document: str, +) -> None: + """An empty prefix after `,` or `(` must not dump the whole namespace. + + Regression test: previously `,` and `(` were treated as completion trigger + characters, so typing e.g. `1,` opened a popup listing every builtin. + """ + content = _run_complete(document) + assert content["op"] == CompletionResultNotification.name + assert content["options"] == [] + + +@pytest.mark.parametrize("document", ["my_func(", "my_func(1,"]) +def test_signature_shown_after_comma_or_paren_in_call(document: str) -> None: + """Inside a known call, an empty prefix falls through to signature help.""" + content = _run_complete( + document, other_code="def my_func(a, b): return a + b" + ) + assert content["op"] == CompletionResultNotification.name + assert len(content["options"]) == 1 + option = content["options"][0] + assert option["type"] == "tooltip" + assert option["name"] == "my_func" + + +@pytest.mark.parametrize("document", ["1 / ", "x = 10 /", "a = b / "]) +def test_no_completions_for_division_operator(document: str) -> None: + """`/` triggers path completion inside strings, but as a division operator + it must not dump the whole namespace. + """ + content = _run_complete(document) + assert content["op"] == CompletionResultNotification.name + assert content["options"] == [] + + +def test_path_completion_still_works(tmp_path: Any) -> None: + """`/` still triggers file-path completion inside a string literal.""" + (tmp_path / "marimo_data.csv").write_text("x\n") + content = _run_complete(f'open("{tmp_path}/') + assert content["options"] + assert all(option["type"] == "path" for option in content["options"]) + assert any( + "marimo_data.csv" in option["name"] for option in content["options"] + ) + + +def test_parameter_completion_omits_full_function_docstring() -> None: + """Completing a parameter must not dump the whole function docstring. + + Regression test: `param` completions used to be swapped to the enclosing + signature, so every parameter's info box showed the entire function + docstring. Now we surface only the parameter's own description (empty when + it can't be extracted, e.g. without `docstring_to_markdown`), never the + function summary or a sibling parameter's text. + """ + other_code = ( + "def my_func(alpha, beta):\n" + ' """SUMMARY_MARKER.\n\n' + " Args:\n" + " alpha: ALPHA_MARKER.\n" + " beta: BETA_MARKER.\n" + ' """\n' + " return alpha\n" + ) + content = _run_complete("my_func(al", other_code=other_code) + options = {o["name"]: o for o in content["options"]} + assert "alpha=" in options + info = options["alpha="]["completion_info"] + assert "SUMMARY_MARKER" not in info + assert "BETA_MARKER" not in info + + +@pytest.mark.parametrize( + ("trigger_code", "expected_key_path"), + # NOTE trigger code produce by marimo must end with `['` or `["` + [ + ("obj['", []), + ("obj['foo']['", [["foo"]]), + ("obj['foo', 'bar']['", [["foo", "bar"]]), + ("obj['foo']['bar']['", [["foo"], ["bar"]]), + ], +) +def test_resolve_chained_key_path( + trigger_code: str, expected_key_path: list[str] +) -> None: + key_path = _resolve_chained_key_path("obj", trigger_code) + assert key_path == expected_key_path + + +class _FakeCompletion: + """Stand-in for jedi.api.classes.Completion. + + Tracks whether `type` was accessed and how often `infer()` ran so we can + assert we didn't pay the (expensive) jedi inference cost in the fast path. + """ + + def __init__( + self, + name: str, + completion_type: str = "function", + raise_on_type: bool = False, + inferred: list[Any] | None = None, + ) -> None: + self.name = name + self._type = completion_type + self._raise_on_type = raise_on_type + self._inferred = inferred or [] + self.type_access_count = 0 + self.docstring_called = False + self.infer_count = 0 + + @property + def type(self) -> str: + self.type_access_count += 1 + if self._raise_on_type: + raise AssertionError( + "completion.type accessed when it should have been skipped" + ) + return self._type + + def docstring(self, *_args: Any, **_kwargs: Any) -> str: + self.docstring_called = True + return "" + + def get_signatures(self) -> list[Any]: + return [] + + def get_type_hint(self) -> str: + return "int" + + def infer(self) -> list[Any]: + self.infer_count += 1 + return self._inferred + + +def test_get_completion_option_skips_type_when_compute_type_false() -> None: + completion = _FakeCompletion("foo", raise_on_type=True) + + option = _get_completion_option( + completion, + compute_completion_info=False, + compute_type=False, + ) + + assert option.name == "foo" + assert option.type == "" + assert option.completion_info == "" + assert completion.type_access_count == 0 + + +def test_get_completion_option_computes_type_by_default() -> None: + completion = _FakeCompletion("foo", completion_type="class") + + option = _get_completion_option( + completion, + compute_completion_info=False, + ) + + assert option.type == "class" + assert completion.type_access_count == 1 + + +def test_get_completion_option_skips_all_inference_when_type_skipped() -> None: + """When `compute_type=False`, we also skip docstrings and signatures. + The whole point of `compute_type=False` is "we're out of budget", so + further jedi inference (docstring, signature) would defeat the purpose. + """ + completion = _FakeCompletion("foo", raise_on_type=True) + + option = _get_completion_option( + completion, + compute_completion_info=True, + compute_type=False, + ) + + assert option.name == "foo" + assert option.type == "" + assert option.completion_info == "" + assert completion.type_access_count == 0 + assert not completion.docstring_called + + +def test_get_completion_options_skips_docstrings_past_limit() -> None: + completions = [_FakeCompletion(f"attr_{i}") for i in range(10)] + + options = _get_completion_options( + completions, prefix="", limit=5, timeout=5.0 + ) + + assert len(options) == 10 + assert all(opt.completion_info == "" for opt in options) + # Types are still computed since we're well under the timeout + assert all(c.type_access_count == 1 for c in completions) + + +def test_get_completion_options_keeps_docstrings_under_limit() -> None: + completions = [_FakeCompletion(f"attr_{i}") for i in range(3)] + + _get_completion_options(completions, prefix="", limit=10, timeout=5.0) + + # All three completions should have had docstring() invoked + assert all(c.docstring_called for c in completions) + + +def test_get_completion_options_bails_out_when_timeout_elapsed() -> None: + """Once the time budget is blown, subsequent completions skip both type + inference and docstring lookup — this is the key knob that keeps cold + completions from taking 10+ seconds on heavy libraries. + """ + completions = [_FakeCompletion(f"attr_{i}") for i in range(4)] + + # Burn time on the first call so the rest see an expired budget. + original_monotonic = time.monotonic + times = iter([0.0, 0.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0]) + + with mock.patch( + "marimo._runtime.complete.time.monotonic", + side_effect=lambda: next(times, original_monotonic()), + ): + options = _get_completion_options( + completions, prefix="", limit=100, timeout=1.0 + ) + + # First one completes normally, the rest should have no info or type + assert options[0].completion_info != "" or completions[0].docstring_called + assert options[0].type == "function" + for opt in options[1:]: + assert opt.type == "" + assert opt.completion_info == "" + + +def test_get_completion_options_respects_prefix_filter() -> None: + """Underscore names are filtered out by `_should_include_name`.""" + completions = [ + _FakeCompletion("public"), + _FakeCompletion("_private"), + _FakeCompletion("__dunder__"), + ] + + options = _get_completion_options( + completions, prefix="", limit=100, timeout=5.0 + ) + + assert [opt.name for opt in options] == ["public"] + + +def _completion_for(code: str, name: str) -> Any: + """Return the Jedi completion named `name` at the end of `code`.""" + script = jedi.Script(code=code) + lines = code.split("\n") + completions = script.complete(line=len(lines), column=len(lines[-1])) + matches = [c for c in completions if c.name == name] + assert matches, ( + f"no completion named {name!r}; got {[c.name for c in completions]}" + ) + return matches[0] + + +def test_completion_info_resolves_aliased_function() -> None: + """Aliases to a function show the underlying docstring + signature. + + Regression test for #9822: `alias = func` is reported by Jedi as a + `statement`, which previously fell through to a bare type hint, dropping + the docstring and signature highlighting in live docs / hover. + """ + code = ( + "def my_documented_func(arg: int) -> None:\n" + ' """Docstring for the aliased function."""\n' + " print(arg)\n" + "\n" + "alias = my_documented_func\n" + "alias" + ) + completion = _completion_for(code, "alias") + assert completion.type == "statement" + + info = _get_completion_info(completion) + assert "Docstring for the aliased function." in info + # Signature is rendered as a highlighted python code block. + assert "codehilite" in info + assert "my_documented_func" in info + + +def test_completion_info_resolves_aliased_class() -> None: + code = ( + "class MyDocumentedClass:\n" + ' """Docstring for the aliased class."""\n' + "\n" + "Alias = MyDocumentedClass\n" + "Alias" + ) + completion = _completion_for(code, "Alias") + assert completion.type == "statement" + + info = _get_completion_info(completion) + assert "Docstring for the aliased class." in info + + +def test_completion_info_plain_value_statement_uses_type_hint() -> None: + """Statements resolving to plain values keep the type-hint fallback rather + than surfacing a builtin's docstring.""" + code = "answer = 42\nanswer" + completion = _completion_for(code, "answer") + assert completion.type == "statement" + + info = _get_completion_info(completion) + assert info == "answer: int" + assert "codehilite" not in info + + +def test_completion_info_ambiguous_alias_falls_back_to_type_hint() -> None: + """When `infer()` resolves to multiple definitions (e.g. a conditional + assignment), don't guess a docstring — defer to the type hint.""" + code = ( + "import random\n" + "\n" + "def foo(a: int) -> int:\n" + ' """Foo docstring."""\n' + " return a\n" + "\n" + "def bar(b: str) -> str:\n" + ' """Bar docstring."""\n' + " return b\n" + "\n" + "chosen = foo if random.random() > 0.5 else bar\n" + "chosen" + ) + completion = _completion_for(code, "chosen") + assert completion.type == "statement" + + info = _get_completion_info(completion) + assert info.startswith("chosen: ") + assert "Foo docstring." not in info + assert "Bar docstring." not in info + assert "codehilite" not in info + + +def test_infer_skipped_for_statements_past_limit() -> None: + """The docstring limit must prevent `.infer()` fan-out. + + Aliased-statement resolution calls `jedi`'s `infer()`, which "follows all + results" and is very slow across large completion sets (e.g. `np.`). The + `len(completions) <= limit` gate has to keep us out of that path entirely. + """ + completions = [ + _FakeCompletion(f"v{i}", completion_type="statement") + for i in range(10) + ] + + _get_completion_options(completions, prefix="", limit=5, timeout=5.0) + + assert all(c.infer_count == 0 for c in completions) + + +def test_infer_only_runs_for_statements_under_budget() -> None: + """Within budget, only statement completions infer (once each); other + types go straight to `_get_docstring` and never touch `infer()`.""" + statements = [ + _FakeCompletion(f"s{i}", completion_type="statement") for i in range(3) + ] + functions = [ + _FakeCompletion(f"f{i}", completion_type="function") for i in range(3) + ] + + _get_completion_options( + statements + functions, + prefix="", + limit=100, + timeout=5.0, + ) + + assert all(c.infer_count == 1 for c in statements) + assert all(c.infer_count == 0 for c in functions) + + +def test_infer_skipped_once_timeout_elapsed() -> None: + """Once the time budget is blown, remaining statements skip `.infer()` + just like they skip type/docstring inference.""" + completions = [ + _FakeCompletion(f"v{i}", completion_type="statement") for i in range(4) + ] + + original_monotonic = time.monotonic + times = iter([0.0, 0.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0]) + + with mock.patch( + "marimo._runtime.complete.time.monotonic", + side_effect=lambda: next(times, original_monotonic()), + ): + _get_completion_options(completions, prefix="", limit=100, timeout=1.0) + + # First completion is under budget and infers; the rest are skipped. + assert completions[0].infer_count == 1 + assert all(c.infer_count == 0 for c in completions[1:]) + + +def test_falls_back_to_interpreter_when_static_analysis_raises() -> None: + """A jedi static-analysis crash should not kill completion. + + jedi's static analysis can raise while inferring some code (e.g. + resolving the generic return type of `polars.concat` crashes with + an AttributeError, https://github.com/davidhalter/jedi/issues/1990). + The interpreter-based fallback should still get a chance to run. + + https://github.com/marimo-team/marimo/issues/10055 + """ + + class MyData: + def with_columns(self) -> None: ... + + def with_row_index(self) -> None: ... + + glbls = {"my_obj": MyData()} + + with mock.patch( + "marimo._runtime.complete._get_completions_with_script", + side_effect=AttributeError( + "'TreeInstance' object has no attribute 'with_generics'" + ), + ): + _script, completions = _get_completions( + ["my_obj = MyData()"], "my_obj.wi", glbls, threading.RLock() + ) + + names = [completion.name for completion in completions] + assert "with_columns" in names + assert "with_row_index" in names + + +@pytest.mark.skipif(not HAS_POLARS, reason="polars not installed") +def test_polars_concat_attribute_completion() -> None: + """Attribute completion works for variables assigned from `pl.concat`. + + Regression test for https://github.com/marimo-team/marimo/issues/10055: + jedi's static analysis crashes on `pl.concat(...)` (jedi#1990), which + used to skip the interpreter fallback and return no completions. + """ + code = ( + "import polars as pl\n" + 'df_a = pl.DataFrame({"a": [1]})\n' + 'df_b = pl.DataFrame({"a": [2]})\n' + "df_x = pl.concat([df_a, df_b])" + ) + glbls: dict[str, Any] = {} + exec(code, glbls) + + _script, completions = _get_completions( + [code], "df_x.wi", glbls, threading.RLock() + ) + + names = [completion.name for completion in completions] + assert "with_columns" in names diff --git a/tests/_runtime/test_context.py b/tests/_runtime/test_context.py index 7944b938f45..1ae009f589f 100644 --- a/tests/_runtime/test_context.py +++ b/tests/_runtime/test_context.py @@ -30,3 +30,60 @@ def test_not_running_in_notebook() -> None: from marimo._runtime.context.utils import running_in_notebook assert not running_in_notebook() + + +async def test_is_embedded( + execution_kernel: Kernel, exec_req: ExecReqProvider +) -> None: + """Test that is_embedded() returns True when parent context exists.""" + k = execution_kernel + await k.run( + [ + exec_req.get( + """ + import marimo as mo + from marimo._runtime.context.types import get_context + + # In the main kernel, there's no parent, so is_embedded is False + ctx = get_context() + is_embedded_main = ctx.is_embedded() + """ + ) + ] + ) + assert not k.errors + assert not k.globals["is_embedded_main"] + + +async def test_is_embedded_in_app_embed( + execution_kernel: Kernel, exec_req: ExecReqProvider +) -> None: + """Test that is_embedded() returns True inside an embedded app.""" + k = execution_kernel + await k.run( + [ + exec_req.get( + """ + from marimo import App + + app = App() + + @app.cell + def _(): + from marimo._runtime.context.types import get_context + ctx = get_context() + is_embedded_inner = ctx.is_embedded() + return (is_embedded_inner,) + """ + ), + exec_req.get( + """ + result = await app.embed() + is_embedded_in_app = result.defs["is_embedded_inner"] + """ + ), + ] + ) + assert not k.errors + # Inside the embedded app, is_embedded should return True + assert k.globals["is_embedded_in_app"] diff --git a/tests/_runtime/test_control_flow.py b/tests/_runtime/test_control_flow.py index 783c00b30ea..c9f4e7260a3 100644 --- a/tests/_runtime/test_control_flow.py +++ b/tests/_runtime/test_control_flow.py @@ -1,9 +1,10 @@ -# Copyright 2024 Marimo. All rights reserved. +# Copyright 2026 Marimo. All rights reserved. from __future__ import annotations from marimo._runtime import control_flow -from marimo._runtime.requests import ExecutionRequest +from marimo._runtime.commands import ExecuteCellCommand from marimo._runtime.runner import cell_runner +from marimo._runtime.runner.hooks import NotebookCellHooks from marimo._runtime.runtime import Kernel @@ -11,11 +12,11 @@ async def test_stop_false(execution_kernel: Kernel) -> None: k = execution_kernel await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="0", code="import marimo as mo; x = 0; mo.stop(False); y = 1", ), - ExecutionRequest(cell_id="1", code="z = y + 1"), + ExecuteCellCommand(cell_id="1", code="z = y + 1"), ] ) assert k.globals["x"] == 0 @@ -28,8 +29,8 @@ async def test_stop_true(execution_kernel: Kernel) -> None: # Populate the kernel and its globals await k.run( [ - ExecutionRequest(cell_id="0", code="x = 0; y = 1"), - ExecutionRequest(cell_id="1", code="z = y + 1"), + ExecuteCellCommand(cell_id="0", code="x = 0; y = 1"), + ExecuteCellCommand(cell_id="1", code="z = y + 1"), ] ) assert k.globals["x"] == 0 @@ -39,7 +40,7 @@ async def test_stop_true(execution_kernel: Kernel) -> None: # Force cell 0 to stop await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="0", code="import marimo as mo; x = 0; mo.stop(True); y = 1", ), @@ -58,15 +59,19 @@ async def test_stop_output(execution_kernel: Kernel) -> None: # Run a cell through the kernel to populate graph await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="0", - code="import marimo as mo; x = 0; mo.stop(True, 'stopped!'); y = 1", # noqa: E501 + code="import marimo as mo; x = 0; mo.stop(True, 'stopped!'); y = 1", ), ] ) # Run the cell through the runner to get the output runner = cell_runner.Runner( - set(["0"]), graph=k.graph, glbls=k.globals, debugger=k.debugger + {"0"}, + graph=k.graph, + glbls=k.globals, + debugger=k.debugger, + hooks=NotebookCellHooks(), ) run_result = await runner.run("0") # Check that the cell was stopped and its output is the stop output diff --git a/tests/_runtime/test_copy.py b/tests/_runtime/test_copy.py index 3697da98ec7..307ee2ad720 100644 --- a/tests/_runtime/test_copy.py +++ b/tests/_runtime/test_copy.py @@ -1,3 +1,5 @@ +import sys + import pytest from marimo._runtime.copy import ( @@ -25,6 +27,10 @@ def test_shadow_wrap() -> None: assert 1 + shadow == 2 +@pytest.mark.xfail( + sys.version_info >= (3, 13), + reason="__slots__ conflicts with class variable in Python 3.13", +) def test_shadow_wrap_ro_attr() -> None: class namespace: ... @@ -51,6 +57,10 @@ def test_shadow_wrap_ro_get() -> None: shadow[0] = 2 +@pytest.mark.xfail( + sys.version_info >= (3, 13), + reason="__slots__ conflicts with class variable in Python 3.13", +) def test_shadow_wrap_mutable_ref() -> None: class namespace: ... diff --git a/tests/_runtime/test_dataflow.py b/tests/_runtime/test_dataflow.py index fe305b62004..7ddb0501870 100644 --- a/tests/_runtime/test_dataflow.py +++ b/tests/_runtime/test_dataflow.py @@ -1,13 +1,18 @@ -# Copyright 2024 Marimo. All rights reserved. +# Copyright 2026 Marimo. All rights reserved. from __future__ import annotations from functools import partial +from unittest.mock import patch import pytest from marimo._ast import compiler +from marimo._ast.visitor import Name, VariableData from marimo._dependencies.dependencies import DependencyManager from marimo._runtime import dataflow +from marimo._runtime.runner import by_refs +from marimo._runtime.runner.by_refs import _get_ancestors +from marimo._types.ids import CellId_t parse_cell = partial(compiler.compile_cell, cell_id="0") @@ -40,10 +45,10 @@ def test_graph_two_chains() -> None: # 0 --> 1 --> 2, 0 --> 2 assert graph.cells == {"0": first_cell, "1": second_cell, "2": third_cell} - assert graph.parents == {"0": set(), "1": set(["0"]), "2": set(["0", "1"])} + assert graph.parents == {"0": set(), "1": {"0"}, "2": {"0", "1"}} assert graph.children == { - "0": set(["1", "2"]), - "1": set(["2"]), + "0": {"1", "2"}, + "1": {"2"}, "2": set(), } @@ -76,11 +81,9 @@ def test_graph_closure() -> None: # 0 --> 1 assert graph.cells == {"0": first_cell, "1": second_cell} - assert graph.parents == {"0": set(), "1": set(["0"])} - assert graph.children == {"0": set(["1"]), "1": set()} - assert dataflow.transitive_closure(graph, cell_ids=set(["0"])) == set( - ["0", "1"] - ) + assert graph.parents == {"0": set(), "1": {"0"}} + assert graph.children == {"0": {"1"}, "1": set()} + assert dataflow.transitive_closure(graph, cell_ids={"0"}) == {"0", "1"} def test_graph_closure_predicate() -> None: @@ -92,7 +95,7 @@ def test_graph_closure_predicate() -> None: code = "def foo():\n return x" second_cell = parse_cell(code) graph.register_cell("1", second_cell) - graph.set_stale(set(["1"])) + graph.set_stale({"1"}) code = "x" third_cell = parse_cell(code) @@ -100,12 +103,111 @@ def test_graph_closure_predicate() -> None: # 0 --> 1 assert graph.cells == {"0": first_cell, "1": second_cell, "2": third_cell} - assert graph.parents == {"0": set(), "1": set(["0"]), "2": set(["0"])} - assert graph.children == {"0": set(["1", "2"]), "1": set(), "2": set()} + assert graph.parents == {"0": set(), "1": {"0"}, "2": {"0"}} + assert graph.children == {"0": {"1", "2"}, "1": set(), "2": set()} assert dataflow.transitive_closure( - graph, cell_ids=set(["0"]), predicate=lambda cell: not cell.stale - ) == set(["0", "2"]) + graph, cell_ids={"0"}, predicate=lambda cell: not cell.stale + ) == {"0", "2"} + + +def test_graph_closure_inclusive() -> None: + graph = dataflow.DirectedGraph() + code = "x = 0" + first_cell = parse_cell(code) + graph.register_cell("0", first_cell) + + code = "y = x" + second_cell = parse_cell(code) + graph.register_cell("1", second_cell) + + code = "z = y" + third_cell = parse_cell(code) + graph.register_cell("2", third_cell) + + # 0 --> 1 --> 2 + assert graph.cells == {"0": first_cell, "1": second_cell, "2": third_cell} + assert graph.parents == {"0": set(), "1": {"0"}, "2": {"1"}} + assert graph.children == {"0": {"1"}, "1": {"2"}, "2": set()} + + # Test inclusive=True (default) + assert dataflow.transitive_closure(graph, cell_ids={"1"}) == {"1", "2"} + + # Test inclusive=False + assert dataflow.transitive_closure( + graph, cell_ids={"1"}, inclusive=False + ) == {"2"} + + # Test with multiple starting cells + assert dataflow.transitive_closure(graph, cell_ids={"0", "1"}) == { + "0", + "1", + "2", + } + + # Test ancestors (children=False) + assert dataflow.transitive_closure( + graph, cell_ids={"2"}, children=False + ) == {"0", "1", "2"} + + # Test ancestors with inclusive=False + assert dataflow.transitive_closure( + graph, cell_ids={"2"}, children=False, inclusive=False + ) == {"0", "1"} + + +def test_graph_closure_predicate_with_inclusive_false() -> None: + graph = dataflow.DirectedGraph() + code = "x = 0" + first_cell = parse_cell(code) + graph.register_cell("0", first_cell) + + code = "y = x" + second_cell = parse_cell(code) + graph.register_cell("1", second_cell) + + code = "z = y" + third_cell = parse_cell(code) + graph.register_cell("2", third_cell) + + result = dataflow.transitive_closure( + graph, cell_ids={"0"}, inclusive=False + ) + assert result == {"1", "2"} + + result = dataflow.transitive_closure( + graph, cell_ids={"1"}, inclusive=False + ) + assert result == {"2"} + + result = dataflow.transitive_closure( + graph, cell_ids={"0", "1"}, inclusive=False + ) + assert result == {"2"} + + result = dataflow.transitive_closure( + graph, cell_ids={"0", "1", "2"}, inclusive=False + ) + assert result == set() + + +def test_graph_closure_empty() -> None: + graph = dataflow.DirectedGraph() + + # Test with empty graph + assert dataflow.transitive_closure(graph, cell_ids=set()) == set() + + # Add a single cell with no connections + code = "x = 0" + first_cell = parse_cell(code) + graph.register_cell("0", first_cell) + + # Test with single cell, no connections + assert dataflow.transitive_closure(graph, cell_ids={"0"}) == {"0"} + assert ( + dataflow.transitive_closure(graph, cell_ids={"0"}, inclusive=False) + == set() + ) def test_graph_redefine() -> None: @@ -147,8 +249,8 @@ def test_set_stale() -> None: assert not graph.get_stale() # 0 and its children are stale - graph.set_stale(set(["0"])) - assert graph.get_stale() == set(["0", "1", "2"]) + graph.set_stale({"0"}) + assert graph.get_stale() == {"0", "1", "2"} graph.cells["0"].set_stale(stale=False) graph.cells["1"].set_stale(stale=False) @@ -176,8 +278,8 @@ def test_ancestors() -> None: graph.register_cell("3", fourth_cell) assert not graph.ancestors("0") - assert graph.ancestors("1") == set(["0"]) - assert graph.ancestors("2") == set(["0", "1"]) + assert graph.ancestors("1") == {"0"} + assert graph.ancestors("2") == {"0", "1"} assert not graph.ancestors("3") @@ -200,8 +302,8 @@ def test_descendants() -> None: fourth_cell = parse_cell(code) graph.register_cell("3", fourth_cell) - assert graph.descendants("0") == set(["1", "2"]) - assert graph.descendants("1") == set(["2"]) + assert graph.descendants("0") == {"1", "2"} + assert graph.descendants("1") == {"2"} assert not graph.descendants("2") assert not graph.ancestors("3") @@ -213,18 +315,18 @@ def test_register_with_stale_ancestor() -> None: code = "x = 0" first_cell = parse_cell(code) graph.register_cell("0", first_cell) - graph.set_stale(set(["0"])) + graph.set_stale({"0"}) code = "y = x" second_cell = parse_cell(code) graph.register_cell("1", second_cell) - assert graph.get_stale() == set(["0", "1"]) + assert graph.get_stale() == {"0", "1"} # add a third cell not related to the others code = "a = 0" third_cell = parse_cell(code) graph.register_cell("3", third_cell) - assert graph.get_stale() == set(["0", "1"]) + assert graph.get_stale() == {"0", "1"} def test_topological_sort_single_node() -> None: @@ -318,6 +420,56 @@ def test_topological_sort_with_cycle() -> None: assert sorted_cells == [] +def test_topological_sort_complex() -> None: + """Test the topological sort.""" + graph = dataflow.DirectedGraph() + + # Create a complex dependency graph + # 0 -> 1 -> 3 + # 0 -> 2 -> 3 + # 4 (standalone) + code = "a = 1" + cell0 = parse_cell(code) + graph.register_cell("0", cell0) + + code = "b = a + 1" + cell1 = parse_cell(code) + graph.register_cell("1", cell1) + + code = "c = a * 2" + cell2 = parse_cell(code) + graph.register_cell("2", cell2) + + code = "d = b + c" + cell3 = parse_cell(code) + graph.register_cell("3", cell3) + + code = "e = 5" + cell4 = parse_cell(code) + graph.register_cell("4", cell4) + + # Check the sort order + sorted_cells = dataflow.topological_sort(graph, ["0", "1", "2", "3", "4"]) + + # The order should respect dependencies + # 0 must come before 1 and 2 + # 1 and 2 must come before 3 + # 4 can be anywhere + + # Get the indices of each cell in the sorted list + indices = {cell: i for i, cell in enumerate(sorted_cells)} + + # Check the relative ordering + assert indices["0"] < indices["1"] + assert indices["0"] < indices["2"] + assert indices["1"] < indices["3"] + assert indices["2"] < indices["3"] + + # Check with a subset of cells + sorted_cells = dataflow.topological_sort(graph, ["1", "3"]) + assert sorted_cells == ["1", "3"] + + @pytest.mark.skipif(not HAS_DUCKDB, reason="duckdb is required") class TestSQL: @pytest.mark.parametrize( @@ -354,8 +506,8 @@ def test_sql_chain(self, code1: str, code2: str) -> None: graph.register_cell("1", second_cell) assert graph.cells == {"0": first_cell, "1": second_cell} - assert graph.parents == {"0": set(), "1": set(["0"])} - assert graph.children == {"0": set(["1"]), "1": set()} + assert graph.parents == {"0": set(), "1": {"0"}} + assert graph.children == {"0": {"1"}, "1": set()} def test_sql_tree_with_declared_df(self) -> None: graph = dataflow.DirectedGraph() @@ -372,8 +524,8 @@ def test_sql_tree_with_declared_df(self) -> None: graph.register_cell("1", second_cell) assert graph.cells == {"0": first_cell, "1": second_cell} - assert graph.parents == {"0": set(), "1": set(["0"])} - assert graph.children == {"0": set(["1"]), "1": set()} + assert graph.parents == {"0": set(), "1": {"0"}} + assert graph.children == {"0": {"1"}, "1": set()} code = 'mo.sql("SELECT * from df")' third_cell = parse_cell(code) @@ -384,8 +536,8 @@ def test_sql_tree_with_declared_df(self) -> None: "1": second_cell, "2": third_cell, } - assert graph.parents == {"0": set(), "1": set(["0"]), "2": set(["0"])} - assert graph.children == {"0": set(["1", "2"]), "1": set(), "2": set()} + assert graph.parents == {"0": set(), "1": {"0"}, "2": {"0"}} + assert graph.children == {"0": {"1", "2"}, "1": set(), "2": set()} code = "df" fourth_cell = parse_cell(code) @@ -399,17 +551,158 @@ def test_sql_tree_with_declared_df(self) -> None: } assert graph.parents == { "0": set(), - "1": set(["0"]), - "2": set(["0"]), - "3": set(["0"]), + "1": {"0"}, + "2": {"0"}, + "3": {"0"}, } assert graph.children == { - "0": set(["1", "2", "3"]), + "0": {"1", "2", "3"}, "1": set(), "2": set(), "3": set(), } + def test_redefine_sql_table_diff_schema(self): + graph = dataflow.DirectedGraph() + code = "t1 = 123" + first_cell = parse_cell(code) + graph.register_cell("0", first_cell) + + code = 'df = mo.sql("CREATE TABLE schema1.t1 (i INTEGER, j INTEGER)")' + second_cell = parse_cell(code) + graph.register_cell("1", second_cell) + + assert graph.cells == { + "0": first_cell, + "1": second_cell, + } + + # Because t1 is qualified with schema1, it is not considered multiply defined + multiply_defined = graph.get_multiply_defined() + assert multiply_defined == [] + + def test_qualified_sql_table_in_defining_cells(self): + graph = dataflow.DirectedGraph() + code = "t1 = 123" + first_cell = parse_cell(code) + graph.register_cell(CellId_t("0"), first_cell) + + code = 'mo.sql("CREATE TABLE schema1.t1 (i INTEGER, j INTEGER)")' + second_cell = parse_cell(code) + graph.register_cell(CellId_t("1"), second_cell) + + assert graph.get_defining_cells("t1") == { + CellId_t("0"), + CellId_t("1"), + } + assert graph.get_multiply_defined() == [] + + def test_redefine_sql_table_same_name_diff_schema(self): + graph = dataflow.DirectedGraph() + code = 'mo.sql("CREATE TABLE finance.datalake.xx (id INT)")' + first_cell = parse_cell(code) + graph.register_cell(CellId_t("0"), first_cell) + + code = 'mo.sql("CREATE TABLE finance.information_layer.xx (id INT)")' + second_cell = parse_cell(code) + graph.register_cell(CellId_t("1"), second_cell) + + assert graph.cells == { + "0": first_cell, + "1": second_cell, + } + assert graph.get_multiply_defined() == [] + + def test_redefine_sql_table_same_name_diff_catalog(self): + graph = dataflow.DirectedGraph() + code = 'mo.sql("CREATE TABLE catalog_one.schema_name.xx (id INT)")' + first_cell = parse_cell(code) + graph.register_cell(CellId_t("0"), first_cell) + + code = 'mo.sql("CREATE TABLE catalog_two.schema_name.xx (id INT)")' + second_cell = parse_cell(code) + graph.register_cell(CellId_t("1"), second_cell) + + assert graph.get_multiply_defined() == [] + + def test_redefine_sql_table_same_qualified_name(self): + graph = dataflow.DirectedGraph() + code = 'mo.sql("CREATE TABLE finance.datalake.xx (id INT)")' + first_cell = parse_cell(code) + graph.register_cell(CellId_t("0"), first_cell) + + code = 'mo.sql("CREATE TABLE finance.datalake.xx (id INT)")' + second_cell = parse_cell(code) + graph.register_cell(CellId_t("1"), second_cell) + + assert graph.get_multiply_defined() == ["xx"] + + def test_redefine_sql_table_unqualified_same_name(self): + graph = dataflow.DirectedGraph() + code = 'mo.sql("CREATE TABLE xx (id INT)")' + first_cell = parse_cell(code) + graph.register_cell(CellId_t("0"), first_cell) + + code = 'mo.sql("CREATE TABLE xx (id INT)")' + second_cell = parse_cell(code) + graph.register_cell(CellId_t("1"), second_cell) + + assert graph.get_multiply_defined() == ["xx"] + + def test_delete_redefined_sql_table_clears_conflict(self): + graph = dataflow.DirectedGraph() + code = 'mo.sql("CREATE TABLE finance.datalake.xx (id INT)")' + first_cell = parse_cell(code) + graph.register_cell(CellId_t("0"), first_cell) + + code = 'mo.sql("CREATE TABLE finance.datalake.xx (id INT)")' + second_cell = parse_cell(code) + graph.register_cell(CellId_t("1"), second_cell) + + assert graph.get_multiply_defined() == ["xx"] + + graph.delete_cell(CellId_t("1")) + + assert graph.get_multiply_defined() == [] + + def test_sql_table_schema_to_python_ref(self): + graph = dataflow.DirectedGraph() + code = 'df = mo.sql("CREATE TABLE t1 (i INTEGER, j INTEGER)")' + first_cell = parse_cell(code) + graph.register_cell("0", first_cell) + + code = "t1 = 123" + second_cell = parse_cell(code) + graph.register_cell("1", second_cell) + + assert graph.cells == { + "0": first_cell, + "1": second_cell, + } + + multiply_defined = graph.get_multiply_defined() + # Without the qualification, t1 is considered multiply defined + assert multiply_defined == ["t1"] + + def test_sql_table_schema_to_python_ref_rev(self): + graph = dataflow.DirectedGraph() + code = "t1 = 123" + first_cell = parse_cell(code) + graph.register_cell("0", first_cell) + + code = 'df = mo.sql("CREATE TABLE t1 (i INTEGER, j INTEGER)")' + second_cell = parse_cell(code) + graph.register_cell("1", second_cell) + + assert graph.cells == { + "0": first_cell, + "1": second_cell, + } + + multiply_defined = graph.get_multiply_defined() + # Without the qualification, t1 is considered multiply defined + assert multiply_defined == ["t1"] + def test_no_sql_table_to_python_ref(self): graph = dataflow.DirectedGraph() code = 'df = mo.sql("CREATE TABLE t1 (i INTEGER, j INTEGER)")' @@ -432,12 +725,12 @@ def test_no_sql_table_to_python_ref(self): "1": second_cell, "2": third_cell, } - assert graph.parents == {"0": set(), "1": set([]), "2": set(["0"])} - assert graph.children == {"0": set(["2"]), "1": set(), "2": set([])} + assert graph.parents == {"0": set(), "1": set(), "2": {"0"}} + assert graph.children == {"0": {"2"}, "1": set(), "2": set()} assert second_cell.language == "python" - assert graph.get_referring_cells("t1", language="sql") == set([]) - assert graph.get_referring_cells("df", language="python") == set(["2"]) + assert graph.get_referring_cells("t1", language="sql") == set() + assert graph.get_referring_cells("df", language="python") == {"2"} def test_python_to_sql_ref(self): graph = dataflow.DirectedGraph() @@ -455,10 +748,106 @@ def test_python_to_sql_ref(self): "0": first_cell, "1": second_cell, } - assert graph.parents == {"0": set(), "1": set(["0"])} - assert graph.children == {"0": set(["1"]), "1": set([])} + assert graph.parents == {"0": set(), "1": {"0"}} + assert graph.children == {"0": {"1"}, "1": set()} + + assert graph.get_referring_cells("df", language="python") == {"1"} + + def test_sql_creation_and_select_from_same_cell(self): + graph = dataflow.DirectedGraph() + code = "df = mo.sql('CREATE TABLE t1 (i INTEGER); SELECT * FROM t1')" + first_cell = parse_cell(code) + graph.register_cell("0", first_cell) + + code = "df" + second_cell = parse_cell(code) + graph.register_cell("1", second_cell) + + assert graph.cells == { + "0": first_cell, + "1": second_cell, + } + + assert graph.parents == {"0": set(), "1": {"0"}} + assert graph.children == {"0": {"1"}, "1": set()} + assert graph.get_referring_cells("df", language="python") == {"1"} + + def test_get_referring_cells_sql_and_python(self) -> None: + """Test the get_referring_cells method.""" + graph = dataflow.DirectedGraph() + + # First cell defines x + code = "x = 0" + first_cell = parse_cell(code) + graph.register_cell("0", first_cell) + + # No cells refer to x yet + assert graph.get_referring_cells("x", language="python") == set() + + # Second cell refers to x in Python + code = "y = x" + second_cell = parse_cell(code) + graph.register_cell("1", second_cell) + + # Second cell should be in the referring cells for x + assert graph.get_referring_cells("x", language="python") == {"1"} + + # Third cell refers to x in SQL + code = "mo.sql('SELECT * FROM x')" + third_cell = parse_cell(code) + graph.register_cell("2", third_cell) + + # Both cells should be in the referring cells for x + assert graph.get_referring_cells("x", language="python") == {"1", "2"} + assert graph.get_referring_cells("x", language="sql") == {"2"} + + # Test language filter (Python vars can leak to SQL, but not vice versa) + code = "mo.sql('CREATE TABLE t1 (i INTEGER)')" + fourth_cell = parse_cell(code) + graph.register_cell("3", fourth_cell) - assert graph.get_referring_cells("df", language="python") == set(["1"]) + code = "mo.sql('SELECT * FROM t1')" + fifth_cell = parse_cell(code) + graph.register_cell("4", fifth_cell) + + code = "t1" + sixth_cell = parse_cell(code) + graph.register_cell("5", sixth_cell) + + # cell "5"'s t1 cannot possibly be a SQL variable + assert graph.get_referring_cells("t1", language="sql") == {"4"} + # t1 could potentially be a Python variable, so it is included in the + # reference set; even if t1 were not defined anywhere, it would still + # return "5" + assert graph.get_referring_cells("t1", language="python") == {"4", "5"} + + # Python cell "5" is not a child of SQL cell "3", even though 5 + # refs "t1" and 3 defines SQL variable t1 + assert graph.children["3"] == {"4"} + + # Test nonexistent variable + assert ( + graph.get_referring_cells("nonexistent", language="python") + == set() + ) + + def test_referring_cells_sql_and_python(self) -> None: + graph = dataflow.DirectedGraph() + code = 'df = mo.sql("select * from my_schema.my_table")' + first_cell = parse_cell(code) + assert first_cell.refs == {"mo", "my_schema.my_table"} + + graph.register_cell("0", first_cell) + + # my_table should not be passed in as a reference to sql cell + code = "df; my_table = ..." + second_cell = parse_cell(code) + graph.register_cell("1", second_cell) + + assert ( + graph.get_referring_cells("my_table", language="python") == set() + ) + assert not graph.cycles def test_attached_db(self): graph = dataflow.DirectedGraph() @@ -479,9 +868,847 @@ def test_attached_db(self): "1": second_cell, "2": third_cell, } - assert graph.parents == {"0": set(), "1": set(["0"]), "2": set([])} - assert graph.children == {"0": set(["1"]), "1": set([]), "2": set([])} + assert graph.parents == {"0": set(), "1": {"0"}, "2": set()} + assert graph.children == {"0": {"1"}, "1": set(), "2": set()} # cell 2 shouldn't count as a referring cell because it isn't a SQL # cell - assert graph.get_referring_cells("my_db", language="sql") == set(["1"]) + assert graph.get_referring_cells("my_db", language="sql") == {"1"} + + +def test_disable_enable_cell() -> None: + """Test disabling and enabling cells.""" + graph = dataflow.DirectedGraph() + + # Create a chain of cells: 0 -> 1 -> 2 + code = "x = 0" + first_cell = parse_cell(code) + graph.register_cell("0", first_cell) + + code = "y = x" + second_cell = parse_cell(code) + graph.register_cell("1", second_cell) + + code = "z = y" + third_cell = parse_cell(code) + graph.register_cell("2", third_cell) + + # Initially all cells should be enabled (not disabled) + assert not graph.cells["0"].config.disabled + assert not graph.cells["1"].config.disabled + assert not graph.cells["2"].config.disabled + + # Disable the first cell + graph.cells["0"].config.disabled = True + graph.disable_cell("0") + + # The first cell should be disabled, and all descendants should be transitively disabled + assert graph.cells["0"].config.disabled + assert graph.cells["1"].disabled_transitively + assert graph.cells["2"].disabled_transitively + + # Make one of them stale + graph.cells["1"].set_stale(stale=True) + + # Re-enable the first cell + graph.cells["0"].config.disabled = False + cells_to_run = graph.enable_cell("0") + + # All cells should be enabled now + assert not graph.cells["0"].config.disabled + assert not graph.cells["1"].disabled_transitively + assert not graph.cells["2"].disabled_transitively + + # Cells to run should include all previously disabled cells and the stale one + assert cells_to_run == {"1"} + + # Test disabling a middle cell + graph.cells["1"].config.disabled = True + graph.disable_cell("1") + + # First cell should remain enabled, second disabled, third transitively disabled + assert not graph.cells["0"].config.disabled + assert graph.cells["1"].config.disabled + assert graph.cells["2"].disabled_transitively + + # Enable the middle cell + graph.cells["1"].config.disabled = False + # Make one of them stale + graph.cells["2"].set_stale(stale=True) + + cells_to_run = graph.enable_cell("1") + + # All cells should be enabled again + assert not graph.cells["0"].config.disabled + assert not graph.cells["1"].config.disabled + assert not graph.cells["2"].disabled_transitively + + # Only cells 1 and 2 need to be run + assert cells_to_run == {"1", "2"} + + +def test_is_disabled() -> None: + """Test the is_disabled method.""" + graph = dataflow.DirectedGraph() + + # Create a diamond dependency: 0 -> 1 -> 3, 0 -> 2 -> 3 + code = "x = 0" + first_cell = parse_cell(code) + graph.register_cell("0", first_cell) + + code = "y = x" + second_cell = parse_cell(code) + graph.register_cell("1", second_cell) + + code = "z = x" + third_cell = parse_cell(code) + graph.register_cell("2", third_cell) + + code = "w = y + z" + fourth_cell = parse_cell(code) + graph.register_cell("3", fourth_cell) + + # No cells are disabled initially + assert not graph.is_disabled("0") + assert not graph.is_disabled("1") + assert not graph.is_disabled("2") + assert not graph.is_disabled("3") + + # Disable the first cell + graph.cells["0"].config.disabled = True + + # All cells should be considered disabled + assert graph.is_disabled("0") + assert graph.is_disabled("1") + assert graph.is_disabled("2") + assert graph.is_disabled("3") + + # Enable the first cell, disable a middle cell + graph.cells["0"].config.disabled = False + graph.cells["1"].config.disabled = True + + # Cell 0 and 2 should not be disabled, but 1 and 3 should be + assert not graph.is_disabled("0") + assert graph.is_disabled("1") + assert not graph.is_disabled("2") + assert graph.is_disabled( + "3" + ) # Disabled because one of its dependencies (1) is disabled + + # Disable both middle cells + graph.cells["2"].config.disabled = True + + # Now all cells except the first should be disabled + assert not graph.is_disabled("0") + assert graph.is_disabled("1") + assert graph.is_disabled("2") + assert graph.is_disabled("3") + + # Test with a cycle + graph = dataflow.DirectedGraph() + code = "x = y" + first_cell = parse_cell(code) + graph.register_cell("0", first_cell) + + code = "y = x" + second_cell = parse_cell(code) + graph.register_cell("1", second_cell) + + # Disable one cell in the cycle + graph.cells["0"].config.disabled = True + + # Both cells should be considered disabled + assert graph.is_disabled("0") + assert graph.is_disabled("1") + + # Test with a disconnected cell + code = "z = 0" + third_cell = parse_cell(code) + graph.register_cell("2", third_cell) + + # The disconnected cell should not be affected by other disabled cells + assert not graph.is_disabled("2") + + +def test_runner_sync() -> None: + """Synchronous Cell.run(**kwargs) path. Must work without an event loop.""" + graph = dataflow.DirectedGraph() + + # Create a chain of cells: 0 -> 1 -> 2 + code = "x = 10" + first_cell = compiler.compile_cell(code, cell_id="0") + graph.register_cell("0", first_cell) + + code = "y = x * 2" + second_cell = compiler.compile_cell(code, cell_id="1") + graph.register_cell("1", second_cell) + + code = "z = y + 5; z" + third_cell = compiler.compile_cell(code, cell_id="2") + graph.register_cell("2", third_cell) + + # Run the last cell + output, defs = by_refs.run_cell_sync(graph, "2", {}) + + # Check output and definitions + assert output == 25 # 10 * 2 + 5 + assert defs == {"z": 25} + + # Run the last cell with substituted values + output, defs = by_refs.run_cell_sync(graph, "2", {"y": 50}) + + # Check output and definitions with substituted value + assert output == 55 # 50 + 5 + assert defs == {"z": 55} + + # Try to run with an invalid argument + try: + by_refs.run_cell_sync(graph, "2", {"invalid": 100}) + raise AssertionError("Should have raised an exception") + except ValueError: + pass # Expected + + +def test_runner_ancestors() -> None: + """Ancestor pruning based on substituted refs.""" + graph = dataflow.DirectedGraph() + + # Create cells with different refs/defs patterns + code = "x = 10" + first_cell = compiler.compile_cell(code, cell_id="0") + graph.register_cell("0", first_cell) + + code = "y = 20" + second_cell = compiler.compile_cell(code, cell_id="1") + graph.register_cell("1", second_cell) + + code = "z = x + y" + third_cell = compiler.compile_cell(code, cell_id="2") + graph.register_cell("2", third_cell) + + # Get ancestors of the third cell + ancestors = _get_ancestors(graph, graph.cells["2"], {}) + assert ancestors == {"0", "1"} + + # When substituting y, only cell 0 should be an ancestor + ancestors = _get_ancestors(graph, graph.cells["2"], {"y": 30}) + assert ancestors == {"0"} + + # When substituting both x and y, there should be no ancestors + ancestors = _get_ancestors(graph, graph.cells["2"], {"x": 40, "y": 30}) + assert ancestors == set() + + +def test_cycles() -> None: + """Test cycle detection and handling.""" + graph = dataflow.DirectedGraph() + + # Create a cycle: 0 -> 1 -> 2 -> 0 + code = "x = z" + first_cell = parse_cell(code) + graph.register_cell("0", first_cell) + + code = "y = x" + second_cell = parse_cell(code) + graph.register_cell("1", second_cell) + + code = "z = y" + third_cell = parse_cell(code) + graph.register_cell("2", third_cell) + + # Check that cycles are detected + assert len(graph.cycles) > 0 + + # Find a cycle that includes all three cells + full_cycle = None + for cycle in graph.cycles: + edges = set(cycle) + if len(edges) == 3: + full_cycle = cycle + break + + assert full_cycle is not None + + # Check that get_cycles finds this cycle + cycles = dataflow.get_cycles(graph, ["0", "1", "2"]) + assert len(cycles) > 0 + + # Test breaking a cycle + graph.delete_cell("0") + + # Cycles should be cleared + assert not graph.cycles + + +def test_del_del_cycle(): + """Two variables that delete the same variable form a cycle.""" + graph = dataflow.DirectedGraph() + + graph.register_cell("0", parse_cell("del x")) + graph.register_cell("1", parse_cell("del x")) + assert len(graph.cycles) == 1 + assert set(next(iter(graph.cycles))) == {("0", "1"), ("1", "0")} + + +def test_del_ref_cycle(): + """One cell deletes a variable and defines another, the other refs both.""" + graph = dataflow.DirectedGraph() + + graph.register_cell("0", parse_cell("x = 1")) + graph.register_cell("1", parse_cell("del x; y = 1")) + graph.register_cell("2", parse_cell("z = x + y")) + assert len(graph.cycles) == 1 + assert set(next(iter(graph.cycles))) == {("1", "2"), ("2", "1")} + + +def test_del_child_of_ref(): + """Cells that delete a variable become a child of cells that reference it.""" + graph = dataflow.DirectedGraph() + + graph.register_cell("0", parse_cell("del x")) + graph.register_cell("1", parse_cell("x")) + graph.register_cell("2", parse_cell("x = 1")) + assert graph.parents["0"] == {"1", "2"} + assert graph.children["0"] == set() + + assert graph.parents["1"] == {"2"} + assert graph.children["1"] == {"0"} + + assert graph.parents["2"] == set() + assert graph.children["2"] == {"0", "1"} + + +def test_get_path() -> None: + """Test the get_path method.""" + graph = dataflow.DirectedGraph() + + # Create a complex path: 0 -> 1 -> 2 -> 3 + # \ / + # -> 4 -> 5 + code = "a = 1" + cell0 = parse_cell(code) + graph.register_cell("0", cell0) + + code = "b = a" + cell1 = parse_cell(code) + graph.register_cell("1", cell1) + + code = "c = b" + cell2 = parse_cell(code) + graph.register_cell("2", cell2) + + code = "d = c + f" + cell3 = parse_cell(code) + graph.register_cell("3", cell3) + + code = "e = a" + cell4 = parse_cell(code) + graph.register_cell("4", cell4) + + code = "f = e" + cell5 = parse_cell(code) + graph.register_cell("5", cell5) + + # Get path from 0 to 3 + path_0_to_3 = graph.get_path("0", "3") + + # Should be a valid path + assert path_0_to_3 + + # Verify it's a valid path by checking edges + for i in range(len(path_0_to_3) - 1): + src, dst = path_0_to_3[i][0], path_0_to_3[i][1] + assert dst in graph.children[src] + + # Check that the path starts at 0 and ends with a node connected to 3 + assert path_0_to_3[0][0] == "0" + assert path_0_to_3[-1][1] == "3" + + # No path should exist between unconnected nodes + code = "g = 100" + cell6 = parse_cell(code) + graph.register_cell("6", cell6) + + # No path from 6 to any other node + assert not graph.get_path("6", "0") + assert not graph.get_path("6", "1") + assert not graph.get_path("6", "2") + assert not graph.get_path("6", "3") + assert not graph.get_path("6", "4") + assert not graph.get_path("6", "5") + + # No path from any node to 6 + assert not graph.get_path("0", "6") + assert not graph.get_path("1", "6") + assert not graph.get_path("2", "6") + assert not graph.get_path("3", "6") + assert not graph.get_path("4", "6") + assert not graph.get_path("5", "6") + + # Path to self should be empty + assert graph.get_path("0", "0") == [] + + +def test_import_block_relatives() -> None: + """Test the import_block_relatives function.""" + graph = dataflow.DirectedGraph() + + # Create an import block + code = "import pandas as pd\nimport numpy as np" + first_cell = parse_cell(code) + first_cell.import_workspace.is_import_block = True + first_cell.import_workspace.imported_defs = {"pd", "np"} + graph.register_cell("0", first_cell) + + # Create cells that use the imports + code = "df = pd.DataFrame()" + second_cell = parse_cell(code) + graph.register_cell("1", second_cell) + + code = "arr = np.array([1, 2, 3])" + third_cell = parse_cell(code) + graph.register_cell("2", third_cell) + + # Create a cell that doesn't use the imports + code = "x = 10" + fourth_cell = parse_cell(code) + graph.register_cell("3", fourth_cell) + + # Test the function + children = dataflow.get_import_block_relatives(graph)("0", True) + + # Should include cells that use pd and np + assert "1" in children + assert "2" in children + assert "3" not in children + + # Test with a non-import block + children = dataflow.get_import_block_relatives(graph)("3", True) + + # Should just return normal children + assert children == graph.children["3"] + + +def test_get_transitive_references() -> None: + """Test the get_transitive_references method.""" + graph = dataflow.DirectedGraph() + + # Create a chain of cells with interdependent functions + code = """ +def func1(): + return 1 + +def func2(): + return func1() + 2 +""" + first_cell = compiler.compile_cell(code, cell_id="0") + graph.register_cell("0", first_cell) + + code = """ +def func3(): + return func2() + 3 + +result = func3() +""" + second_cell = compiler.compile_cell(code, cell_id="1") + graph.register_cell("1", second_cell) + + # Get transitive references from result + refs = graph.get_transitive_references({"result"}) + + # Should include all functions in the chain + assert "result" in refs + assert "func3" in refs + assert "func2" in refs + assert "func1" in refs + + # Test with non-inclusive mode + refs = graph.get_transitive_references({"result"}, inclusive=False) + + # Should include all except result + assert "result" not in refs + assert "func3" in refs + assert "func2" in refs + assert "func1" in refs + + # Test with predicate + # Custom predicate that only includes functions + def is_function(name: Name, data: VariableData) -> bool: + del name + return data.kind == "function" + + refs = graph.get_transitive_references( + {"result", "func3"}, predicate=is_function, inclusive=False + ) + + # Should include only functions + assert refs == {"func2", "func1"} + + # result is not a function, so it should be excluded even with inclusive=True + assert "result" not in refs + + +def test_class_method_references() -> None: + """Test transitive references with class methods.""" + graph = dataflow.DirectedGraph() + + code = """ +class MyClass: + def __init__(self): + self.value = 1 + + def method1(self): + return self.value + helper() + +def helper(): + return 42 +""" + first_cell = parse_cell(code) + graph.register_cell("0", first_cell) + + code = """ +obj = MyClass() +result = obj.method1() +""" + second_cell = parse_cell(code) + graph.register_cell("1", second_cell) + + # Get transitive references from result + refs = graph.get_transitive_references({"result"}) + + # Should include all related symbols + assert "result" in refs + assert "obj" in refs + assert "MyClass" in refs + assert "helper" in refs + + +def test_private_variables() -> None: + """Test handling of private variables in get_transitive_references.""" + graph = dataflow.DirectedGraph() + + # Create a cell with private variables + code = """ +def public_func(): + # This creates a mangled name for _private_var + _private_var = 10 + return _private_var + 5 +""" + cell = parse_cell(code) + graph.register_cell("0", cell) + + code = "result = public_func()" + second_cell = parse_cell(code) + graph.register_cell("1", second_cell) + + # Get transitive references + refs = graph.get_transitive_references({"result"}) + + # Should include public_func + assert "public_func" in refs + + # Private variable shouldn't appear directly + assert "_private_var" not in refs + + +def test_directed_graph_copy() -> None: + """Test DirectedGraph.copy() method for recompiling cells with filename.""" + graph = dataflow.DirectedGraph() + + # Create some cells + code = "import marimo as mo" + cell1 = parse_cell(code) + + graph.register_cell("0", cell1) + + # Mock compile_cell to verify it's called with filename + with patch("marimo._ast.compiler.compile_cell") as mock_compile: + mock_compile.return_value = parse_cell(code) + + filename = "test_notebook.py" + copied_graph = graph.copy(filename) + + # Verify compile_cell was called for each cell with filename + assert mock_compile.call_count == 1 + for call in mock_compile.call_args_list: + assert call[1]["filename"] == filename + + # Verify the copied graph has the same structure + assert len(copied_graph.cells) == 1 + assert set(copied_graph.cells.keys()) == {"0"} + + # Verify import workspace data is preserved + for cell_id in copied_graph.cells: + original_cell = graph.cells[cell_id] + copied_cell = copied_graph.cells[cell_id] + assert ( + copied_cell.import_workspace.imported_defs + == original_cell.import_workspace.imported_defs + ) + assert ( + copied_cell.import_workspace.is_import_block + == original_cell.import_workspace.is_import_block + ) + + +def test_directed_graph_copy_no_filename() -> None: + """Test DirectedGraph.copy() method without filename.""" + graph = dataflow.DirectedGraph() + + # Create a cell + code = "y = x + 1" + cell1 = parse_cell(code) + graph.register_cell("0", cell1) + + # Mock compile_cell to verify it's called without filename + with patch("marimo._ast.compiler.compile_cell") as mock_compile: + mock_compile.return_value = parse_cell(code) + + copied_graph = graph.copy(None) + + # Verify compile_cell was called without filename + assert mock_compile.call_count == 1 + call_args = mock_compile.call_args + assert ( + "filename" not in call_args[1] or call_args[1]["filename"] is None + ) + + # Verify the copied graph has the same structure + assert len(copied_graph.cells) == 1 + assert "0" in copied_graph.cells + + +def test_prune_cells_for_overrides_empty() -> None: + """Test prune_cells_for_overrides with no overrides returns all cells.""" + graph = dataflow.DirectedGraph() + + # Create cells: x = 0, y = x, z = y + cell_0 = parse_cell("x = 0") + graph.register_cell("0", cell_0) + + cell_1 = parse_cell("y = x") + graph.register_cell("1", cell_1) + + cell_2 = parse_cell("z = y") + graph.register_cell("2", cell_2) + + execution_order = ["0", "1", "2"] + + # Test with empty overrides + result = dataflow.prune_cells_for_overrides(graph, execution_order, {}) + assert result == ["0", "1", "2"] + + +def test_prune_cells_for_overrides_single_cell() -> None: + """Test pruning a single cell that defines a single variable.""" + graph = dataflow.DirectedGraph() + + # Create cells: x = 0, y = x, z = y + cell_0 = parse_cell("x = 0") + graph.register_cell("0", cell_0) + + cell_1 = parse_cell("y = x") + graph.register_cell("1", cell_1) + + cell_2 = parse_cell("z = y") + graph.register_cell("2", cell_2) + + execution_order = ["0", "1", "2"] + + # Override x - should prune cell 0 + result = dataflow.prune_cells_for_overrides( + graph, execution_order, {"x": 100} + ) + assert result == ["1", "2"] + + +def test_prune_cells_for_overrides_multiple_defs() -> None: + """Test pruning a cell that defines multiple variables.""" + graph = dataflow.DirectedGraph() + + # Create cell that defines both x and y + cell_0 = parse_cell("x = 0\ny = 1") + graph.register_cell("0", cell_0) + + # Cell that uses x and y + cell_1 = parse_cell("z = x + y") + graph.register_cell("1", cell_1) + + execution_order = ["0", "1"] + + # Override both x and y - should prune cell 0 + result = dataflow.prune_cells_for_overrides( + graph, execution_order, {"x": 100, "y": 200} + ) + assert result == ["1"] + + +def test_prune_cells_for_overrides_incomplete_refs_error() -> None: + """Test that incomplete overrides raise IncompleteRefsError.""" + from marimo._ast.errors import IncompleteRefsError + + graph = dataflow.DirectedGraph() + + # Create cell that defines both x and y + cell_0 = parse_cell("x = 0\ny = 1") + graph.register_cell("0", cell_0) + + # Cell that uses x and y + cell_1 = parse_cell("z = x + y") + graph.register_cell("1", cell_1) + + execution_order = ["0", "1"] + + # Override only x (not y) - should raise error + with pytest.raises(IncompleteRefsError) as exc_info: + dataflow.prune_cells_for_overrides(graph, execution_order, {"x": 100}) + + assert "y" in str(exc_info.value) + assert "Missing: ['y']" in str(exc_info.value) + assert "Provided refs: ['x']" in str(exc_info.value) + + +def test_prune_cells_for_overrides_multiple_cells() -> None: + """Test pruning multiple cells when overriding multiple variables.""" + graph = dataflow.DirectedGraph() + + # Create separate cells for x and y + cell_0 = parse_cell("x = 0") + graph.register_cell("0", cell_0) + + cell_1 = parse_cell("y = 1") + graph.register_cell("1", cell_1) + + # Cell that uses both x and y + cell_2 = parse_cell("z = x + y") + graph.register_cell("2", cell_2) + + execution_order = ["0", "1", "2"] + + # Override both x and y - should prune cells 0 and 1 + result = dataflow.prune_cells_for_overrides( + graph, execution_order, {"x": 100, "y": 200} + ) + assert result == ["2"] + + +def test_prune_cells_for_overrides_partial_override() -> None: + """Test partial override where only some cells are pruned.""" + graph = dataflow.DirectedGraph() + + # Create separate cells for x and y + cell_0 = parse_cell("x = 0") + graph.register_cell("0", cell_0) + + cell_1 = parse_cell("y = 1") + graph.register_cell("1", cell_1) + + # Cell that uses both x and y + cell_2 = parse_cell("z = x + y") + graph.register_cell("2", cell_2) + + execution_order = ["0", "1", "2"] + + # Override only x - should prune only cell 0 + result = dataflow.prune_cells_for_overrides( + graph, execution_order, {"x": 100} + ) + assert result == ["1", "2"] + + +def test_prune_cells_for_overrides_nonexistent_variable() -> None: + """Test overriding a variable that doesn't exist in the graph.""" + graph = dataflow.DirectedGraph() + + # Create cells: x = 0, y = x + cell_0 = parse_cell("x = 0") + graph.register_cell("0", cell_0) + + cell_1 = parse_cell("y = x") + graph.register_cell("1", cell_1) + + execution_order = ["0", "1"] + + # Override a variable that doesn't exist - should not prune anything + result = dataflow.prune_cells_for_overrides( + graph, execution_order, {"nonexistent": 100} + ) + assert result == ["0", "1"] + + +def test_prune_cells_for_overrides_preserves_order() -> None: + """Test that pruning preserves the original execution order.""" + graph = dataflow.DirectedGraph() + + # Create cells in specific order + cell_0 = parse_cell("a = 0") + graph.register_cell("0", cell_0) + + cell_1 = parse_cell("b = 1") + graph.register_cell("1", cell_1) + + cell_2 = parse_cell("c = 2") + graph.register_cell("2", cell_2) + + cell_3 = parse_cell("d = a + c") + graph.register_cell("3", cell_3) + + execution_order = ["0", "1", "2", "3"] + + # Override b - should prune only cell 1, preserving order + result = dataflow.prune_cells_for_overrides( + graph, execution_order, {"b": 100} + ) + assert result == ["0", "2", "3"] + + +def test_is_any_ancestor_errored() -> None: + """Test that is_any_ancestor_errored correctly detects ancestor errors.""" + graph = dataflow.DirectedGraph() + # Create a chain: 0 -> 1 -> 2 + code = "x = 0" + first_cell = parse_cell(code) + graph.register_cell("0", first_cell) + code = "y = x" + second_cell = parse_cell(code) + graph.register_cell("1", second_cell) + code = "z = y" + third_cell = parse_cell(code) + graph.register_cell("2", third_cell) + + # No errors initially + assert not graph.is_any_ancestor_errored("0") + assert not graph.is_any_ancestor_errored("1") + assert not graph.is_any_ancestor_errored("2") + + # Set cell 0 to exception state + graph.cells["0"].set_run_result_status("exception") + assert not graph.is_any_ancestor_errored("0") # no ancestors + assert graph.is_any_ancestor_errored("1") # parent 0 has error + assert graph.is_any_ancestor_errored("2") # grandparent 0 has error + + # Fix cell 0 - clear the error + graph.cells["0"].set_run_result_status("success") + assert not graph.is_any_ancestor_errored("0") + assert not graph.is_any_ancestor_errored("1") + assert not graph.is_any_ancestor_errored("2") + + +def test_is_any_ancestor_errored_marimo_error() -> None: + """Test that is_any_ancestor_errored detects marimo-error status too.""" + graph = dataflow.DirectedGraph() + code = "x = 0" + first_cell = parse_cell(code) + graph.register_cell("0", first_cell) + code = "y = x" + second_cell = parse_cell(code) + graph.register_cell("1", second_cell) + + # Set cell 0 to marimo-error state (e.g. registration/syntax error) + graph.cells["0"].set_run_result_status("marimo-error") + assert not graph.is_any_ancestor_errored("0") # no ancestors + assert graph.is_any_ancestor_errored("1") # parent 0 has marimo-error + + # Fix cell 0 + graph.cells["0"].set_run_result_status("success") + assert not graph.is_any_ancestor_errored("1") diff --git a/tests/_runtime/test_dataflow_cases.py b/tests/_runtime/test_dataflow_cases.py new file mode 100644 index 00000000000..171d6deed31 --- /dev/null +++ b/tests/_runtime/test_dataflow_cases.py @@ -0,0 +1,694 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from dataclasses import dataclass +from functools import partial +from typing import TYPE_CHECKING + +import pytest + +from marimo._ast import compiler +from marimo._dependencies.dependencies import DependencyManager +from marimo._runtime import dataflow +from marimo._types.ids import CellId_t + +parse_cell = partial(compiler.compile_cell, cell_id=CellId_t("0")) + +HAS_DUCKDB = DependencyManager.duckdb.has() + +if TYPE_CHECKING: + from collections.abc import Iterable + + +class KnownFailure(Exception): + """An expected failure in a test case.""" + + +@dataclass +class GraphTestCase: + """A test case for dataflow graph operations.""" + + # Test description + name: str + + # If enabled + + # Code to create and register + code: dict[str, str] + + # Expected graph structure + expected_parents: dict[str, Iterable[str]] | None = None + expected_children: dict[str, Iterable[str]] | None = None + expected_stale: Iterable[str] | None = None + + # Expected refs/defs + expected_refs: dict[str, Iterable[str]] | None = None + expected_defs: dict[str, Iterable[str]] | None = None + + enabled: bool = True + xfail: bool | str = False + + def __post_init__(self) -> None: + # Convert all to a [] + if self.expected_parents is not None: + self.expected_parents = { + cell_id: set(parents) + for cell_id, parents in self.expected_parents.items() + } + if self.expected_children is not None: + self.expected_children = { + cell_id: set(children) + for cell_id, children in self.expected_children.items() + } + if self.expected_stale is not None: + self.expected_stale = set(self.expected_stale) + if self.expected_refs is not None: + self.expected_refs = { + cell_id: set(refs) + for cell_id, refs in self.expected_refs.items() + } + if self.expected_defs is not None: + self.expected_defs = { + cell_id: set(defs) + for cell_id, defs in self.expected_defs.items() + } + + +PYTHON_CASES = [ + # Basic Python Cases + GraphTestCase( + name="single node", + code={"0": "x = 0"}, + expected_parents={"0": []}, + expected_children={"0": []}, + expected_refs={"0": []}, + expected_defs={"0": ["x"]}, + ), + GraphTestCase( + name="chain", + code={"0": "x = 0", "1": "y = x", "2": "z = y\nzz = x"}, + expected_parents={"0": [], "1": ["0"], "2": ["0", "1"]}, + expected_children={"0": ["1", "2"], "1": ["2"], "2": []}, + expected_refs={"0": [], "1": ["x"], "2": ["x", "y"]}, + expected_defs={ + "0": ["x"], + "1": ["y"], + "2": ["z", "zz"], + }, + ), + GraphTestCase( + name="cycle", + code={"0": "x = y", "1": "y = x"}, + expected_parents={"0": ["1"], "1": ["0"]}, + expected_children={"0": ["1"], "1": ["0"]}, + expected_refs={"0": ["y"], "1": ["x"]}, + expected_defs={"0": ["x"], "1": ["y"]}, + ), + GraphTestCase( + name="diamond", + code={ + "0": "x = 0", + "1": "y = x", + "2": "z = y\nzz = x", + "3": "a = z", + }, + expected_parents={ + "0": [], + "1": ["0"], + "2": ["0", "1"], + "3": ["2"], + }, + expected_children={ + "0": ["1", "2"], + "1": ["2"], + "2": ["3"], + "3": [], + }, + expected_refs={ + "0": [], + "1": ["x"], + "2": ["x", "y"], + "3": ["z"], + }, + expected_defs={ + "0": ["x"], + "1": ["y"], + "2": ["z", "zz"], + "3": ["a"], + }, + ), + GraphTestCase( + name="variable del", + code={"0": "x = 0", "1": "y = x", "2": "del x"}, + expected_parents={"0": [], "1": ["0"], "2": ["0", "1"]}, + expected_children={"0": ["1", "2"], "1": ["2"], "2": []}, + expected_refs={"0": [], "1": ["x"], "2": ["x"]}, + expected_defs={ + "0": ["x"], + "1": ["y"], + "2": [], + }, + ), +] + +SQL_CASES = [ + GraphTestCase( + name="python -> sql", + enabled=HAS_DUCKDB, + code={ + "0": "df = pd.read_csv('data.csv')", + "1": "result = mo.sql(f'FROM df WHERE name = {name}')", + }, + expected_parents={"0": [], "1": ["0"]}, + expected_children={"0": ["1"], "1": []}, + expected_refs={"0": ["pd"], "1": ["df", "mo", "name"]}, + expected_defs={"0": ["df"], "1": ["result"]}, + ), + GraphTestCase( + name="sql -> python via output", + enabled=HAS_DUCKDB, + code={ + "0": "result = mo.sql(f'FROM my_table WHERE name = {name}')", + "1": "df = result.head()", + }, + expected_parents={"0": [], "1": ["0"]}, + expected_children={"0": ["1"], "1": []}, + expected_refs={"0": ["mo", "name", "my_table"], "1": ["result"]}, + expected_defs={"0": ["result"], "1": ["df"]}, + ), + GraphTestCase( + name="sql -/> python when creating a table", + enabled=HAS_DUCKDB, + code={ + "0": "_ = mo.sql(f'CREATE TABLE my_table (name STRING)')", + "1": "my_table = df.head()", + }, + expected_parents={"0": [], "1": []}, + expected_children={"0": [], "1": []}, + expected_refs={"0": ["mo"], "1": ["df"]}, + expected_defs={"0": ["my_table"], "1": ["my_table"]}, + ), + GraphTestCase( + name="sql redefinition", + enabled=HAS_DUCKDB, + code={ + "0": "df = pd.read_csv('data.csv')", + "1": "df = mo.sql(f'FROM df')", + }, + expected_parents={"0": [], "1": ["0"]}, + expected_children={"0": ["1"], "1": []}, + expected_refs={"0": ["pd"], "1": ["df", "mo"]}, + expected_defs={"0": ["df"], "1": ["df"]}, + ), + GraphTestCase( + name="sql should not reference python variables when schema", + enabled=HAS_DUCKDB, + code={ + "0": "df = pd.read_csv('data.csv')", + "1": "result = mo.sql(f'FROM my_schema.df')", + }, + expected_parents={"0": [], "1": []}, + expected_children={"0": [], "1": []}, + expected_refs={"0": ["pd"], "1": ["mo", "my_schema.df"]}, + expected_defs={"0": ["df"], "1": ["result"]}, + ), + GraphTestCase( + name="sql should not reference python variables when schema", + enabled=HAS_DUCKDB, + code={ + "0": "my_schema = 100", + "1": "_ = mo.sql(f'FROM my_schema.df')", + }, + expected_parents={"0": [], "1": []}, + expected_children={"0": [], "1": []}, + expected_refs={"0": [], "1": ["mo", "my_schema.df"]}, + expected_defs={"0": ["my_schema"], "1": []}, + ), + GraphTestCase( + name="sql should not reference python variables when catalog", + enabled=HAS_DUCKDB, + code={ + "0": "my_catalog = 100", + "1": "_ = mo.sql(f'FROM my_catalog.my_schema.df')", + }, + expected_parents={"0": [], "1": []}, + expected_children={"0": [], "1": []}, + expected_refs={"0": [], "1": ["mo", "my_catalog.my_schema.df"]}, + expected_defs={"0": ["my_catalog"], "1": []}, + ), + GraphTestCase( + name="sql table reference resolves to table name even if created with schema", + enabled=HAS_DUCKDB, + code={ + "0": "_df = mo.sql(f'CREATE TABLE my_schema.my_table (name STRING)')", + "1": "_df = mo.sql(f'FROM my_table SELECT *')", + }, + expected_parents={"0": [], "1": ["0"]}, + expected_children={"0": ["1"], "1": []}, + expected_refs={"0": ["mo"], "1": ["my_table", "mo"]}, + expected_defs={"0": ["my_table"], "1": []}, + ), + GraphTestCase( + name="sql table reference resolves to table name even if created with catalog and schema", + enabled=HAS_DUCKDB, + code={ + "0": "_df = mo.sql(f'CREATE TABLE my_catalog.my_schema.my_table (name STRING)')", + "1": "_df = mo.sql(f'FROM my_table SELECT *')", + }, + expected_parents={"0": [], "1": ["0"]}, + expected_children={"0": ["1"], "1": []}, + expected_refs={"0": ["mo"], "1": ["my_table", "mo"]}, + expected_defs={"0": ["my_table"], "1": []}, + ), + GraphTestCase( + name="sql table created from another table reference", + enabled=HAS_DUCKDB, + code={ + "0": "_df = mo.sql(f'CREATE TABLE schema_one.my_table (name STRING)')", + "1": "_df = mo.sql(f'CREATE TABLE schema_two.my_table_two AS SELECT * FROM schema_one.my_table')", + }, + expected_parents={"0": [], "1": ["0"]}, + expected_children={"0": ["1"], "1": []}, + expected_refs={"0": ["mo"], "1": ["mo", "schema_one.my_table"]}, + expected_defs={"0": ["my_table"], "1": ["my_table_two"]}, + ), + GraphTestCase( + name="sql table reference with catalog and schema", + enabled=HAS_DUCKDB, + code={ + "0": "_ = mo.sql(f'CREATE TABLE my_catalog.my_schema.my_table (name STRING)')", + "1": "_ = mo.sql(f'FROM my_catalog.my_schema.my_table SELECT *')", + }, + expected_parents={"0": [], "1": ["0"]}, + expected_children={"0": ["1"], "1": []}, + expected_refs={ + "0": ["mo"], + "1": ["my_catalog.my_schema.my_table", "mo"], + }, + expected_defs={"0": ["my_table"], "1": []}, + ), + GraphTestCase( + name="different schemas with same table name", + enabled=HAS_DUCKDB, + code={ + "0": "_df = mo.sql(f'CREATE TABLE schema_one.my_table (name STRING)')", + "1": "_df = mo.sql(f'CREATE TABLE schema_two.my_table (name STRING)')", + "2": "_df = mo.sql(f'FROM schema_one.my_table SELECT *')", + }, + expected_parents={"0": [], "1": [], "2": ["0"]}, + expected_children={"0": ["2"], "1": [], "2": []}, + expected_refs={ + "0": ["mo"], + "1": ["mo"], + "2": ["mo", "schema_one.my_table"], + }, + expected_defs={"0": ["my_table"], "1": ["my_table"], "2": []}, + ), + GraphTestCase( + name="sql definitions with same name as qualified schema and table", + enabled=HAS_DUCKDB, + code={ + "0": "my_table = mo.sql(f'CREATE TABLE schema_one.my_table (name STRING)')", + "1": "schema_one = mo.sql(f'CREATE TABLE schema_one.my_table (name STRING)')", + "2": "my_table", + "3": "schema_one", + }, + expected_parents={"0": [], "1": [], "2": ["0"], "3": ["1"]}, + expected_children={"0": ["2"], "1": ["3"], "2": [], "3": []}, + expected_refs={ + "0": ["mo"], + "1": ["mo"], + "2": ["my_table"], + "3": ["schema_one"], + }, + expected_defs={ + "0": ["my_table"], + "1": ["my_table", "schema_one"], + }, + ), + GraphTestCase( + name="sql catalog and schema with same name", + enabled=HAS_DUCKDB, + code={ + "0": "_ = mo.sql(f'CREATE TABLE my_db.my_db.my_table (name STRING)')", + "1": "_ = mo.sql(f'FROM my_db.my_db.my_table SELECT *')", + }, + expected_parents={"0": [], "1": ["0"]}, + expected_children={"0": ["1"], "1": []}, + expected_refs={ + "0": ["mo"], + "1": ["mo", "my_db.my_db.my_table"], + }, + expected_defs={"0": ["my_table"], "1": []}, + ), + GraphTestCase( + name="sql view creation with schema reference", + enabled=HAS_DUCKDB, + code={ + "0": "_ = mo.sql(f'CREATE TABLE my_schema.base_table (id INT)')", + "1": "_ = mo.sql(f'CREATE VIEW my_schema.my_view AS SELECT * FROM my_schema.base_table')", + "2": "_ = mo.sql(f'FROM my_schema.my_view SELECT *')", + }, + expected_parents={"0": [], "1": ["0"], "2": ["1"]}, + expected_children={"0": ["1"], "1": ["2"], "2": []}, + expected_refs={ + "0": ["mo"], + "1": ["mo", "my_schema.base_table"], + "2": ["mo", "my_schema.my_view"], + }, + expected_defs={"0": ["base_table"], "1": ["my_view"], "2": []}, + ), + GraphTestCase( + name="sql case insensitive schema matching", + enabled=HAS_DUCKDB, + code={ + "0": "_ = mo.sql(f'CREATE TABLE MY_SCHEMA.my_table (name STRING)')", + "1": "_ = mo.sql(f'FROM my_schema.my_table SELECT *')", + }, + expected_parents={"0": [], "1": ["0"]}, + expected_children={"0": ["1"], "1": []}, + expected_refs={ + "0": ["mo"], + "1": ["mo", "my_schema.my_table"], + }, + expected_defs={"0": ["my_table"], "1": []}, + ), + GraphTestCase( + name="sql no reference to python variable when using catalog", + enabled=HAS_DUCKDB, + code={ + "0": "my_catalog_var = 'test_catalog'", + "1": "_ = mo.sql(f'FROM my_catalog_var.schema.table SELECT *')", + }, + expected_parents={"0": [], "1": []}, + expected_children={"0": [], "1": []}, + expected_refs={ + "0": [], + "1": ["mo", "my_catalog_var.schema.table"], + }, + expected_defs={"0": ["my_catalog_var"], "1": []}, + ), + GraphTestCase( + name="sql no reference to python variable when using schema", + enabled=HAS_DUCKDB, + code={ + "0": "my_schema_var = 'test_schema'", + "1": "_ = mo.sql(f'FROM my_schema_var.table SELECT *')", + }, + expected_parents={"0": [], "1": []}, + expected_children={"0": [], "1": []}, + expected_refs={ + "0": [], + "1": ["mo", "my_schema_var.table"], + }, + expected_defs={"0": ["my_schema_var"], "1": []}, + ), + GraphTestCase( + name="sql catalog.schema.table requires both catalog and schema to match", + enabled=HAS_DUCKDB, + code={ + "0": "_ = mo.sql(f'CREATE TABLE catalog_one.schema_one.my_table (name STRING)')", + "1": "_ = mo.sql(f'FROM catalog_one.schema_one.my_table SELECT *')", + "2": "_ = mo.sql(f'FROM catalog_two.schema_one.my_table SELECT *')", + "3": "_ = mo.sql(f'FROM catalog_one.schema_two.my_table SELECT *')", + }, + expected_parents={"0": [], "1": ["0"], "2": [], "3": []}, + expected_children={"0": ["1"], "1": [], "2": [], "3": []}, + expected_refs={ + "0": ["mo"], + "1": ["mo", "catalog_one.schema_one.my_table"], + "2": ["mo", "catalog_two.schema_one.my_table"], + "3": ["mo", "catalog_one.schema_two.my_table"], + }, + expected_defs={"0": ["my_table"], "1": [], "2": [], "3": []}, + ), + GraphTestCase( + name="sql table substring doesn't cause false positive", + enabled=HAS_DUCKDB, + code={ + "0": "_ = mo.sql(f'CREATE TABLE catalog_one.schema_one.my_table (name STRING)')", + "1": "_ = mo.sql(f'FROM catalog_one.schema_one.my_table_suffix SELECT *')", + "2": "_ = mo.sql(f'FROM catalog_one.schema_one.prefix_my_table SELECT *')", + }, + expected_parents={"0": [], "1": [], "2": []}, + expected_children={"0": [], "1": [], "2": []}, + expected_refs={ + "0": ["mo"], + "1": ["mo", "catalog_one.schema_one.my_table_suffix"], + "2": ["mo", "catalog_one.schema_one.prefix_my_table"], + }, + expected_defs={"0": ["my_table"], "1": [], "2": []}, + ), + GraphTestCase( + name="sql table schema substring doesn't cause false positive", + enabled=HAS_DUCKDB, + code={ + "0": "_ = mo.sql(f'CREATE TABLE catalog_one.schema_one.my_table (name STRING)')", + "1": "_ = mo.sql(f'FROM catalog_one.my_table.suffix SELECT *')", + }, + expected_parents={"0": [], "1": []}, + expected_children={"0": [], "1": []}, + expected_refs={ + "0": ["mo"], + "1": ["mo", "catalog_one.my_table.suffix"], + }, + expected_defs={"0": ["my_table"], "1": []}, + ), + GraphTestCase( + name="sql table attach statements, single definition", + enabled=HAS_DUCKDB, + code={ + "0": "_ = mo.sql(f\"ATTACH 'my_db.db' as my_db\")", + "1": "_ = mo.sql(f'FROM my_db.main.my_table SELECT *')", + }, + expected_parents={"0": [], "1": ["0"]}, + expected_children={"0": ["1"], "1": []}, + expected_refs={ + "0": ["mo"], + "1": ["mo", "my_db.main.my_table"], + }, + expected_defs={"0": ["my_db"], "1": []}, + ), + GraphTestCase( + name="sql table attach statements, multiple definitions", + enabled=HAS_DUCKDB, + code={ + "0": "_ = mo.sql(f\"ATTACH 'my_db.db' as my_db\")", + "1": "_ = mo.sql(f'CREATE OR REPLACE TABLE my_db.my_table AS SELECT 1')", + "2": "_ = mo.sql(f'FROM my_db.main.my_table SELECT *')", + "3": "_ = mo.sql(f'FROM my_db.my_table SELECT *')", + }, + expected_parents={"0": [], "1": [], "2": ["0", "1"], "3": ["0", "1"]}, + expected_children={"0": ["2", "3"], "1": ["2", "3"], "2": [], "3": []}, + expected_refs={ + "0": ["mo"], + "1": ["mo"], + "2": ["mo", "my_db.main.my_table"], + "3": ["mo", "my_db.my_table"], + }, + expected_defs={"0": ["my_db"], "1": ["my_table"], "2": [], "3": []}, + ), + GraphTestCase( + name="create table with the same name from a different schema", + enabled=HAS_DUCKDB, + code={ + "0": "_ = mo.sql(f'CREATE TABLE my_table AS SELECT * FROM schema_one.my_table')", + "1": "_ = mo.sql(f'SELECT * FROM my_table')", + }, + expected_parents={"0": [], "1": ["0"]}, + expected_children={"0": ["1"], "1": []}, + expected_refs={ + "0": ["mo", "schema_one.my_table"], + "1": ["mo", "my_table"], + }, + expected_defs={"0": ["my_table"], "1": []}, + ), + GraphTestCase( + name="create table with the same name from catalog.schema hierarchy", + enabled=HAS_DUCKDB, + code={ + "0": "_ = mo.sql(f'CREATE TABLE my_table AS SELECT * FROM catalog_one.schema_one.my_table')", + "1": "_ = mo.sql(f'SELECT * FROM my_table')", + }, + expected_parents={"0": [], "1": ["0"]}, + expected_children={"0": ["1"], "1": []}, + expected_refs={ + "0": ["mo", "catalog_one.schema_one.my_table"], + "1": ["mo", "my_table"], + }, + expected_defs={"0": ["my_table"], "1": []}, + ), + GraphTestCase( + name="create schema with the same name from a different catalog", + enabled=HAS_DUCKDB, + code={ + "0": "_ = mo.sql(f'CREATE SCHEMA my_schema')", + "1": "_ = mo.sql(f'CREATE TABLE my_schema.my_table AS SELECT * FROM catalog_one.my_schema.my_table')", + "2": "_ = mo.sql(f'SELECT * FROM my_schema.my_table')", + }, + expected_parents={"0": [], "1": [], "2": ["1"]}, + expected_children={"0": [], "1": ["2"], "2": []}, + expected_refs={ + "0": ["mo"], + "1": ["mo", "catalog_one.my_schema.my_table"], + "2": ["mo", "my_schema.my_table"], + }, + expected_defs={"0": ["my_schema"], "1": ["my_table"], "2": []}, + ), + GraphTestCase( + name="create table that references itself in join prevents self-loop", + enabled=HAS_DUCKDB, + code={ + "0": "_ = mo.sql(f'CREATE TABLE users AS SELECT 1 as id')", + "1": "_ = mo.sql(f'CREATE TABLE orders AS SELECT u.id FROM schema_one.users u JOIN schema_two.orders o ON u.id = o.user_id')", + "2": "_ = mo.sql(f'SELECT * FROM orders')", + }, + expected_parents={"0": [], "1": ["0"], "2": ["1"]}, + expected_children={"0": ["1"], "1": ["2"], "2": []}, + expected_refs={ + "0": ["mo"], + "1": ["mo", "schema_one.users", "schema_two.orders"], + "2": ["mo", "orders"], + }, + expected_defs={"0": ["users"], "1": ["orders"], "2": []}, + ), + GraphTestCase( + name="multiple tables with hierarchical self-reference patterns", + enabled=HAS_DUCKDB, + code={ + "0": "_ = mo.sql(f'CREATE TABLE table_a AS SELECT * FROM db1.table_a')", + "1": "_ = mo.sql(f'CREATE TABLE table_b AS SELECT * FROM db2.table_b')", + "2": "_ = mo.sql(f'SELECT * FROM table_a UNION ALL SELECT * FROM table_b')", + }, + expected_parents={"0": [], "1": [], "2": ["0", "1"]}, + expected_children={"0": ["2"], "1": ["2"], "2": []}, + expected_refs={ + "0": ["mo", "db1.table_a"], + "1": ["mo", "db2.table_b"], + "2": ["mo", "table_a", "table_b"], + }, + expected_defs={"0": ["table_a"], "1": ["table_b"], "2": []}, + ), + GraphTestCase( + name="create table from hierarchical ref then reference it hierarchically", + enabled=HAS_DUCKDB, + code={ + "0": "_ = mo.sql(f'CREATE SCHEMA my_schema')", + "1": "_ = mo.sql(f'CREATE TABLE my_schema.data AS SELECT * FROM external.my_schema.data')", + "2": "_ = mo.sql(f'SELECT * FROM my_schema.data')", + }, + expected_parents={"0": [], "1": [], "2": ["1"]}, + expected_children={"0": [], "1": ["2"], "2": []}, + expected_refs={ + "0": ["mo"], + "1": ["mo", "external.my_schema.data"], + "2": ["mo", "my_schema.data"], + }, + expected_defs={"0": ["my_schema"], "1": ["data"], "2": []}, + ), + GraphTestCase( + name="sql table multiple definitions, different order", + enabled=HAS_DUCKDB, + code={ + "0": "_ = mo.sql(f'CREATE OR REPLACE TABLE my_db.my_table AS SELECT 1')", + "1": "_ = mo.sql(f\"ATTACH 'my_db.db' as my_db\")", + "2": "_ = mo.sql(f'FROM my_db.main.my_table SELECT *')", + }, + expected_parents={"0": [], "1": [], "2": ["0", "1"]}, + expected_children={"0": ["2"], "1": ["2"], "2": []}, + expected_refs={ + "0": ["mo"], + "1": ["mo"], + "2": ["mo", "my_db.main.my_table"], + }, + expected_defs={"0": ["my_table"], "1": ["my_db"], "2": []}, + ), + GraphTestCase( + name="sql table ordering doesn't cause false positives", + enabled=HAS_DUCKDB, + code={ + "0": "_ = mo.sql(f'CREATE TABLE catalog_one.schema_one.my_table (name STRING)')", + "1": "_ = mo.sql(f'CREATE TABLE catalog_one.my_table.schema_one (name STRING)')", + "2": "_ = mo.sql(f'CREATE TABLE schema_one.my_table.catalog_one (name STRING)')", + "3": "_ = mo.sql(f'CREATE TABLE schema_one.catalog_one.my_table (name STRING)')", + "4": "_ = mo.sql(f'CREATE TABLE my_table.catalog_one.schema_one (name STRING)')", + "5": "_ = mo.sql(f'CREATE TABLE my_table.schema_one.catalog_one (name STRING)')", + }, + expected_parents={ + "0": [], + "1": [], + "2": [], + "3": [], + "4": [], + "5": [], + }, + expected_children={ + "0": [], + "1": [], + "2": [], + "3": [], + "4": [], + "5": [], + }, + expected_refs={ + "0": ["mo"], + "1": ["mo"], + "2": ["mo"], + "3": ["mo"], + "4": ["mo"], + "5": ["mo"], + }, + expected_defs={ + "0": ["my_table"], + "1": ["schema_one"], + "2": ["catalog_one"], + "3": ["my_table"], + "4": ["schema_one"], + "5": ["catalog_one"], + }, + ), +] + +CASES = PYTHON_CASES + SQL_CASES + + +@pytest.mark.parametrize("case", CASES) +def test_cases(case: GraphTestCase) -> None: + print(f"Running {case.name}") + graph = dataflow.DirectedGraph() + + if not case.enabled: + pytest.skip(f"Skipping {case.name} because it's not enabled") + + for cell_id, code in case.code.items(): + cell = parse_cell(code) + graph.register_cell(CellId_t(cell_id), cell) + + def make_assertions(): + if case.expected_refs: + for cell_id, refs in case.expected_refs.items(): + assert graph.cells[CellId_t(cell_id)].refs == refs, ( + f"Cell {cell_id} has refs {graph.cells[CellId_t(cell_id)].refs}, expected {refs}" + ) + if case.expected_defs: + for cell_id, defs in case.expected_defs.items(): + assert graph.cells[CellId_t(cell_id)].defs == defs, ( + f"Cell {cell_id} has defs {graph.cells[CellId_t(cell_id)].defs}, expected {defs}" + ) + assert graph.parents == case.expected_parents, ( + f"Graph parents {graph.parents} do not match expected {case.expected_parents}" + ) + assert graph.children == case.expected_children, ( + f"Graph children {graph.children} do not match expected {case.expected_children}" + ) + + if case.xfail: + if isinstance(case.xfail, str): + print(case.xfail) + try: + make_assertions() + except AssertionError as e: + pytest.xfail(str(e)) + raise KnownFailure(str(e)) from e + else: + make_assertions() diff --git a/tests/_runtime/test_dotenv.py b/tests/_runtime/test_dotenv.py new file mode 100644 index 00000000000..530713622f9 --- /dev/null +++ b/tests/_runtime/test_dotenv.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import os +import tempfile + +import pytest + +from marimo._config.config import merge_default_config +from marimo._runtime.commands import ( + CreateNotebookCommand, + UpdateUIElementCommand, +) +from marimo._utils.platform import is_windows +from tests.conftest import MockedKernel + +# These tests work with and without python-dotenv installed. + + +@pytest.mark.skipif( + is_windows(), + reason="Does not work on Windows", +) +def test_load_dotenv_success(mocked_kernel: MockedKernel): + # Create a temporary .env file + with tempfile.NamedTemporaryFile(suffix=".env", mode="w+") as env_file: + env_file.write("TEST_VAR=test_value\n") + env_file.write("ANOTHER_VAR=another_value\n") + env_file.flush() + + # Create a custom config with the path to our temp .env file + custom_config = merge_default_config( + { + "runtime": { + "dotenv": [env_file.name], + }, + } + ) + mocked_kernel.k.user_config = custom_config + + # Load the .env file + mocked_kernel.k.load_dotenv() + + # Check that the environment variables were set + assert os.environ.get("TEST_VAR") == "test_value" + assert os.environ.get("ANOTHER_VAR") == "another_value" + + +def test_load_dotenv_nonexistent_file(mocked_kernel: MockedKernel): + # Create a config with a nonexistent .env file + custom_config = merge_default_config( + { + "runtime": { + "dotenv": ["nonexistent.env"], + }, + } + ) + mocked_kernel.k.user_config = custom_config + + # Create a kernel with our custom config + # This should not raise an exception + mocked_kernel.k.load_dotenv() + + +@pytest.mark.skipif( + is_windows(), + reason="Does not work on Windows", +) +async def test_load_dotenv_on_instantiate(mocked_kernel: MockedKernel): + # Create a temporary .env file + with tempfile.NamedTemporaryFile(suffix=".env", mode="w+") as env_file: + env_file.write("INSTANTIATE_TEST_VAR=instantiate_value\n") + env_file.flush() + + # Create a custom config with the path to our temp .env file + custom_config = merge_default_config( + { + "runtime": { + "dotenv": [env_file.name], + }, + } + ) + mocked_kernel.k.user_config = custom_config + + # Create a CreationRequest + request = CreateNotebookCommand( + execution_requests=(), + cell_ids=(), + set_ui_element_value_request=UpdateUIElementCommand( + object_ids=[], + values=[], + ), + auto_run=True, + ) + + # Call instantiate, which should call load_dotenv + await mocked_kernel.k.instantiate(request) + + # Check that the environment variable was set + assert os.environ.get("INSTANTIATE_TEST_VAR") == "instantiate_value" diff --git a/tests/_runtime/test_duckdb_wasm.py b/tests/_runtime/test_duckdb_wasm.py new file mode 100644 index 00000000000..63dbae77e24 --- /dev/null +++ b/tests/_runtime/test_duckdb_wasm.py @@ -0,0 +1,1577 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import gzip +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from marimo._runtime._wasm._duckdb import ( + patch_duckdb_for_wasm, + patch_duckdb_query_for_wasm, +) +from marimo._runtime._wasm._duckdb.sources import ( + remote_file_source_from_table, +) +from marimo._sql.engines.duckdb import DuckDBEngine +from tests.conftest import ExecReqProvider, mock_pyodide + +pytestmark = pytest.mark.requires("duckdb", "pandas", "sqlglot") + +pytest.importorskip("duckdb") +pytest.importorskip("pandas") +pytest.importorskip("sqlglot") + +if TYPE_CHECKING: + from collections.abc import Sequence + + from marimo._runtime.runtime import Kernel + + +def _normalize_value(value: object) -> object: + import pandas as pd + + if hasattr(value, "tolist") and not isinstance( + value, dict | list | str | bytes | bytearray + ): + return value.tolist() + if isinstance(value, list): + return [_normalize_value(item) for item in value] + if isinstance(value, dict): + return {key: _normalize_value(item) for key, item in value.items()} + if isinstance(value, bytes | bytearray): + return bytes(value) + try: + return None if bool(pd.isna(value)) else value + except (TypeError, ValueError): + return value + + +def _records(df: object) -> list[dict[str, object]]: + return [ + {key: _normalize_value(value) for key, value in row.items()} + for row in df.to_dict("records") # type: ignore[attr-defined] + ] + + +def _rows(rows: Sequence[Sequence[object]]) -> list[tuple[object, ...]]: + return [tuple(_normalize_value(value) for value in row) for row in rows] + + +@dataclass(frozen=True) +class RemoteFixture: + url: str + suffix: str + data: bytes + + +@dataclass(frozen=True) +class QueryParityCase: + name: str + query: str + fixtures: tuple[RemoteFixture, ...] + + +@dataclass(frozen=True) +class DirectReadParityCase: + name: str + function_name: str + fixture: RemoteFixture + source_kwarg: str | None = None + options: tuple[tuple[str, object], ...] = () + + +def _parquet_bytes(sql: str) -> bytes: + import duckdb + + with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as file: + path = Path(file.name) + try: + duckdb.sql(sql).write_parquet(str(path)) + return path.read_bytes() + finally: + path.unlink(missing_ok=True) + + +def _local_fixture_path( + fixture: RemoteFixture, tmp_path: Path, filename: str +) -> str: + path = tmp_path / f"{filename}{fixture.suffix}" + path.write_bytes(fixture.data) + return path.as_posix() + + +def _local_query( + remote_query: str, + fixtures: Sequence[RemoteFixture], + tmp_path: Path, +) -> str: + query = remote_query + for idx, fixture in enumerate(fixtures): + query = query.replace( + fixture.url, + _local_fixture_path(fixture, tmp_path, f"remote_{idx}"), + ) + return query + + +def _native_rows(query: str) -> list[tuple[object, ...]]: + import duckdb + + connection = duckdb.connect(":memory:") + try: + return _rows(connection.sql(query).fetchall()) + finally: + connection.close() + + +def _patched_rows( + query: str, + fixtures: Sequence[RemoteFixture], +) -> tuple[list[tuple[object, ...]], list[str]]: + import duckdb + + fixtures_by_url = {fixture.url: fixture for fixture in fixtures} + + def fetch(url: str) -> bytes: + return fixtures_by_url[url].data + + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + side_effect=fetch, + ) as fetch_url_bytes, + ): + patch_result = patch_duckdb_query_for_wasm(query) + + assert patch_result is not None + assert all(fixture.url not in patch_result.query for fixture in fixtures) + + connection = duckdb.connect(":memory:") + try: + for table_name, df in patch_result.tables.items(): + connection.register(table_name, df) + rows = _rows(connection.sql(patch_result.query).fetchall()) + finally: + connection.close() + + fetched_urls = [call.args[0] for call in fetch_url_bytes.call_args_list] + return rows, fetched_urls + + +def _direct_reader_args( + case: DirectReadParityCase, source: str +) -> tuple[tuple[object, ...], dict[str, object]]: + kwargs = dict(case.options) + if case.source_kwarg is None: + return (source,), kwargs + kwargs[case.source_kwarg] = source + return (), kwargs + + +def _run_direct_reader( + case: DirectReadParityCase, + source: str, + *, + api_kind: str, +) -> list[tuple[object, ...]]: + import duckdb + + args, kwargs = _direct_reader_args(case, source) + if api_kind == "module": + relation = getattr(duckdb, case.function_name)(*args, **kwargs) + return _rows(relation.fetchall()) + + connection = duckdb.connect(":memory:") + try: + if api_kind == "connection": + relation = getattr(connection, case.function_name)(*args, **kwargs) + elif api_kind == "module-connection-kw": + kwargs["connection"] = connection + relation = getattr(duckdb, case.function_name)(*args, **kwargs) + else: + raise ValueError(f"Unknown DuckDB direct reader API: {api_kind}") + return _rows(relation.fetchall()) + finally: + connection.close() + + +def _patched_direct_rows( + case: DirectReadParityCase, + *, + api_kind: str, +) -> tuple[list[tuple[object, ...]], list[str]]: + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=case.fixture.data, + ) as fetch_url_bytes, + ): + unpatch = patch_duckdb_for_wasm() + try: + rows = _run_direct_reader( + case, case.fixture.url, api_kind=api_kind + ) + finally: + unpatch() + + fetched_urls = [call.args[0] for call in fetch_url_bytes.call_args_list] + return rows, fetched_urls + + +def _direct_reader_parity_cases() -> list[DirectReadParityCase]: + return [ + DirectReadParityCase( + "csv-positional", + "read_csv", + RemoteFixture( + "https://datasets.marimo.app/cars.csv", + ".csv", + b"1;ford\n2;toyota\n", + ), + options=(("delimiter", ";"), ("header", False)), + ), + DirectReadParityCase( + "parquet-path-or-buffer", + "read_parquet", + RemoteFixture( + "https://datasets.marimo.app/cars.parquet", + ".parquet", + _parquet_bytes("SELECT 'ford' AS make"), + ), + source_kwarg="path_or_buffer", + ), + DirectReadParityCase( + "json-path-or-buffer", + "read_json", + RemoteFixture( + "https://datasets.marimo.app/cars.json", + ".json", + b'[{"make":"ford"},{"make":"toyota"}]', + ), + source_kwarg="path_or_buffer", + options=(("format", "array"),), + ), + ] + + +def _query_parity_cases() -> list[QueryParityCase]: + csv = RemoteFixture( + "https://example.com/cars.csv", + ".csv", + b"make,mpg\nford,25\ntoyota,18\n", + ) + csv_semicolon = RemoteFixture( + "https://example.com/cars-semicolon.csv", + ".csv", + b"1;ford\n2;toyota\n", + ) + csv_gzip = RemoteFixture( + "https://example.com/cars.csv.gz", + ".csv.gz", + gzip.compress(b"make,mpg\nford,25\n"), + ) + csv_download = RemoteFixture( + "https://example.com/download", + "", + b"make,mpg\nford,25\n", + ) + csv_normalize = RemoteFixture( + "https://example.com/names.csv", + ".csv", + b"make name,mpg\nford,25\n", + ) + tsv = RemoteFixture( + "https://example.com/walmarts.tsv", + ".tsv", + b"longitude\tlatitude\n1\t2\n", + ) + csv_a = RemoteFixture("https://example.com/a.csv", ".csv", b"a,b\n1,2\n") + csv_b = RemoteFixture("https://example.com/b.csv", ".csv", b"a,c\n3,4\n") + parquet = RemoteFixture( + "https://example.com/a.parquet", + ".parquet", + _parquet_bytes( + """ + SELECT 1 AS a, 'x' AS b + UNION ALL SELECT 2, 'y' + """ + ), + ) + parquet_b = RemoteFixture( + "https://example.com/b.parquet", + ".parquet", + _parquet_bytes("SELECT 3 AS a, 'z' AS b"), + ) + json_array = RemoteFixture( + "https://example.com/a.json", + ".json", + b'[{"a":1,"b":"x"},{"a":2,"b":"y"}]', + ) + json_array_b = RemoteFixture( + "https://example.com/b.json", + ".json", + b'[{"a":3,"b":"z"}]', + ) + complex_json = RemoteFixture( + "https://example.com/countries.json", + ".json", + b'{"type":"Topology","arcs":[[[0]],1]}', + ) + unstructured_json = RemoteFixture( + "https://example.com/unstructured.json", + ".json", + b'{"a":1} {"a":2}', + ) + ndjson = RemoteFixture( + "https://example.com/a.ndjson", + ".ndjson", + b'{"a":1}\n{"a":2}\n', + ) + ndjson_gzip = RemoteFixture( + "https://example.com/events.ndjson.gz", + ".ndjson.gz", + gzip.compress(b'{"event_id":5,"value":10}\n'), + ) + ndjson_objects_gzip = RemoteFixture( + "https://example.com/objects.ndjson.gz", + ".ndjson.gz", + gzip.compress(b'{"a":1}\n{"a":2}\n'), + ) + geojson = RemoteFixture( + "https://example.com/a.geojson", + ".geojson", + b'{"type":"FeatureCollection","features":[]}', + ) + text = RemoteFixture("https://example.com/a.txt", ".txt", b"hello") + blob = RemoteFixture("https://example.com/a.bin", ".bin", b"\x00\x01") + return [ + QueryParityCase( + "direct-csv-literal", + f"SELECT make, mpg FROM '{csv.url}' ORDER BY make", + (csv,), + ), + QueryParityCase( + "csv-reader-options", + f""" + SELECT column1 FROM read_csv( + '{csv_semicolon.url}', delim := ';', header := false + ) + ORDER BY column0 + """, + (csv_semicolon,), + ), + QueryParityCase( + "tsv-escaped-delimiter", + f""" + SELECT * FROM read_csv_auto('{tsv.url}', delim='\\t') + """, + (tsv,), + ), + QueryParityCase( + "gzipped-csv", + f"SELECT make, mpg FROM read_csv('{csv_gzip.url}')", + (csv_gzip,), + ), + QueryParityCase( + "reader-without-extension", + f"SELECT make, mpg FROM read_csv('{csv_download.url}')", + (csv_download,), + ), + QueryParityCase( + "csv-normalize-names", + f""" + SELECT make_name, mpg FROM read_csv( + '{csv_normalize.url}', normalize_names=true + ) + """, + (csv_normalize,), + ), + QueryParityCase( + "csv-union-by-name", + f""" + SELECT * FROM read_csv( + ['{csv_a.url}', '{csv_b.url}'], union_by_name=true + ) + ORDER BY a + """, + (csv_a, csv_b), + ), + QueryParityCase( + "parquet-reader", + f"SELECT * FROM read_parquet('{parquet.url}') ORDER BY a", + (parquet,), + ), + QueryParityCase( + "parquet-list", + f""" + SELECT * FROM read_parquet(['{parquet.url}', '{parquet_b.url}']) + ORDER BY a + """, + (parquet, parquet_b), + ), + QueryParityCase( + "parquet-scan-alias", + f"SELECT * FROM parquet_scan('{parquet.url}') ORDER BY a", + (parquet,), + ), + QueryParityCase( + "parquet-direct-literal", + f"SELECT * FROM '{parquet.url}' ORDER BY a", + (parquet,), + ), + QueryParityCase( + "json-reader", + f"SELECT * FROM read_json_auto('{json_array.url}') ORDER BY a", + (json_array,), + ), + QueryParityCase( + "json-list", + f""" + SELECT * FROM read_json_auto( + ['{json_array.url}', '{json_array_b.url}'] + ) + ORDER BY a + """, + (json_array, json_array_b), + ), + QueryParityCase( + "complex-json", + f"SELECT type, arcs FROM read_json_auto('{complex_json.url}')", + (complex_json,), + ), + QueryParityCase( + "unstructured-json", + f""" + SELECT * FROM read_json( + '{unstructured_json.url}', format='unstructured' + ) + ORDER BY a + """, + (unstructured_json,), + ), + QueryParityCase( + "ndjson-reader", + f"SELECT * FROM read_ndjson('{ndjson.url}') ORDER BY a", + (ndjson,), + ), + QueryParityCase( + "direct-ndjson-literal", + f"SELECT * FROM '{ndjson.url}' ORDER BY a", + (ndjson,), + ), + QueryParityCase( + "gzipped-ndjson", + f""" + SELECT * FROM read_json( + '{ndjson_gzip.url}', format='newline_delimited', + compression='gzip' + ) + """, + (ndjson_gzip,), + ), + QueryParityCase( + "ndjson-objects", + f"SELECT json FROM read_ndjson_objects('{json_array.url}')", + (json_array,), + ), + QueryParityCase( + "json-objects-auto-gzip", + f""" + SELECT json FROM read_json_objects_auto('{ndjson_objects_gzip.url}') + ORDER BY json + """, + (ndjson_objects_gzip,), + ), + QueryParityCase( + "geojson-reader", + f"SELECT type FROM read_json_auto('{geojson.url}')", + (geojson,), + ), + QueryParityCase( + "text-and-blob", + f""" + SELECT content, size FROM read_text('{text.url}') + UNION ALL + SELECT content::VARCHAR, size FROM read_blob('{blob.url}') + """, + (text, blob), + ), + QueryParityCase( + "mixed-parquet-json", + f""" + SELECT * FROM read_parquet('{parquet.url}') + UNION ALL + SELECT * FROM read_json_auto('{json_array.url}') + ORDER BY a + """, + (parquet, json_array), + ), + ] + + +def test_patch_duckdb_for_wasm_noop_outside_pyodide() -> None: + import duckdb + + original_read_csv = duckdb.read_csv + original_sql = duckdb.sql + original_connection_sql = duckdb.DuckDBPyConnection.sql + + unpatch = patch_duckdb_for_wasm() + + assert duckdb.read_csv is original_read_csv + assert duckdb.sql is original_sql + assert duckdb.DuckDBPyConnection.sql is original_connection_sql + unpatch() + assert duckdb.read_csv is original_read_csv + assert duckdb.sql is original_sql + assert duckdb.DuckDBPyConnection.sql is original_connection_sql + + +class TestDuckDBWasmDirectReadPatch: + @staticmethod + def test_patch_installation_does_not_require_sqlglot() -> None: + import duckdb + + original = duckdb.read_csv + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._duckdb._require_sqlglot", + side_effect=AssertionError("sqlglot should be lazy"), + ), + ): + unpatch = patch_duckdb_for_wasm() + try: + assert duckdb.read_csv is not original + finally: + unpatch() + + assert duckdb.read_csv is original + + @staticmethod + @pytest.mark.parametrize("api_kind", ["module", "connection"]) + @pytest.mark.parametrize( + "case", + _direct_reader_parity_cases(), + ids=lambda case: case.name, + ) + def test_direct_readers_match_native_duckdb( + case: DirectReadParityCase, + api_kind: str, + tmp_path: Path, + ) -> None: + native_rows = _run_direct_reader( + case, + _local_fixture_path(case.fixture, tmp_path, case.name), + api_kind=api_kind, + ) + patched_rows, fetched_urls = _patched_direct_rows( + case, api_kind=api_kind + ) + + assert patched_rows == native_rows + assert fetched_urls == [case.fixture.url] + + @staticmethod + @pytest.mark.parametrize( + "case", + [ + case + for case in _direct_reader_parity_cases() + if case.function_name != "read_csv" + ], + ids=lambda case: case.name, + ) + def test_module_readers_preserve_connection_kw( + case: DirectReadParityCase, tmp_path: Path + ) -> None: + native_rows = _run_direct_reader( + case, + _local_fixture_path(case.fixture, tmp_path, case.name), + api_kind="module-connection-kw", + ) + patched_rows, fetched_urls = _patched_direct_rows( + case, api_kind="module-connection-kw" + ) + + assert patched_rows == native_rows + assert fetched_urls == [case.fixture.url] + + +class TestDuckDBWasmQueryPatch: + @staticmethod + def test_noop_outside_pyodide() -> None: + assert ( + patch_duckdb_query_for_wasm( + "SELECT * FROM 'https://datasets.marimo.app/cars.csv'" + ) + is None + ) + + @staticmethod + def test_read_parquet_node_preserves_this_argument() -> None: + from sqlglot import exp + + table = exp.Table( + this=exp.ReadParquet( + this=exp.Literal.string("https://example.com/a.parquet") + ) + ) + + source = remote_file_source_from_table(table) + + assert source is not None + assert source.reader_name == "parquet" + assert [file.url for file in source.files] == [ + "https://example.com/a.parquet" + ] + + @staticmethod + @pytest.mark.parametrize( + ("query", "expected_source"), + [ + ( + "SELECT * FROM 'https://example.com/a.csv'", + True, + ), + ( + 'SELECT * FROM "https://example.com/a.csv"', + False, + ), + ( + """ + SELECT 1, 'https://example.com/a.csv' AS label + FROM "https://example.com/a.csv" + """, + False, + ), + ], + ids=[ + "direct-literal", + "double-quoted-identifier", + "string-literal-and-double-quoted-identifier", + ], + ) + def test_token_metadata_fallback_detects_single_quoted_remote_sources( + query: str, + expected_source: bool, + ) -> None: + from sqlglot import exp + + table = exp.Table( + this=exp.Identifier(this="https://example.com/a.csv", quoted=True) + ) + + source = remote_file_source_from_table( + table, + query=query, + ) + + if not expected_source: + assert source is None + return + + assert source is not None + assert source.reader_name == "csv" + assert [file.url for file in source.files] == [ + "https://example.com/a.csv" + ] + + @staticmethod + @pytest.mark.parametrize( + "function_name", + [ + "read_json_objects", + "read_json_objects_auto", + "read_ndjson_objects", + ], + ) + def test_json_objects_reader_preserves_requested_function( + monkeypatch: pytest.MonkeyPatch, function_name: str + ) -> None: + import duckdb + import pandas as pd + + from marimo._runtime._wasm._duckdb.dataframe import ( + read_json_objects_dataframe, + ) + + queries: list[str] = [] + + class Relation: + def df(self) -> pd.DataFrame: + return pd.DataFrame({"json": []}) + + def fake_sql(query: str, *, params: list[object]) -> Relation: + del params + queries.append(query) + return Relation() + + monkeypatch.setattr(duckdb, "sql", fake_sql) + + read_json_objects_dataframe( + b'{"a":1}\n', + {}, + url="https://example.com/a.json", + function_name=function_name, + ) + + assert queries == [f"SELECT * FROM {function_name}(?)"] + + @staticmethod + @pytest.mark.parametrize( + "case", + _query_parity_cases(), + ids=lambda case: case.name, + ) + def test_rewrites_remote_sources_with_native_duckdb_parity( + case: QueryParityCase, tmp_path: Path + ) -> None: + native_rows = _native_rows( + _local_query(case.query, case.fixtures, tmp_path) + ) + patched_rows, fetched_urls = _patched_rows(case.query, case.fixtures) + + assert patched_rows == native_rows + assert fetched_urls == [fixture.url for fixture in case.fixtures] + + @staticmethod + def test_list_argument_requires_matching_schemas() -> None: + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + side_effect=[b"a,b\n1,2\n", b"a,c\n3,4\n"], + ), + pytest.raises(ValueError), + ): + patch_duckdb_query_for_wasm( + """ + SELECT * FROM read_csv([ + 'https://example.com/a.csv', + 'https://example.com/b.csv' + ]) + """, + ) + + @staticmethod + def test_rewrites_direct_geojson_literal() -> None: + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=b'{"type":"FeatureCollection","features":[]}', + ), + ): + patch_result = patch_duckdb_query_for_wasm( + "FROM 'https://example.com/a.geojson'", + ) + + assert patch_result is not None + assert ( + patch_result.query == "SELECT * FROM __marimo_wasm_duckdb_remote_0" + ) + assert _records(next(iter(patch_result.tables.values()))) == [ + {"type": "FeatureCollection", "features": []} + ] + + @staticmethod + def test_avoids_reserved_table_names() -> None: + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=b"make,mpg\nford,25\n", + ), + ): + patch_result = patch_duckdb_query_for_wasm( + "SELECT * FROM 'https://datasets.marimo.app/cars.csv'", + reserved_names=("__marimo_wasm_duckdb_remote_0",), + ) + + assert patch_result is not None + assert ( + patch_result.query == "SELECT * FROM __marimo_wasm_duckdb_remote_1" + ) + + @staticmethod + def test_avoids_sql_cte_table_names_case_insensitively() -> None: + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=b"mpg\n25\n", + ), + ): + patch_result = patch_duckdb_query_for_wasm( + """ + WITH __MARIMO_WASM_DUCKDB_REMOTE_0 AS (SELECT 99 AS mpg) + SELECT mpg FROM 'https://datasets.marimo.app/cars.csv' + """, + ) + + assert patch_result is not None + assert "FROM __marimo_wasm_duckdb_remote_1" in patch_result.query + + @staticmethod + def test_does_not_rewrite_create_view_remote_source() -> None: + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=b"make,mpg\nford,25\n", + ) as fetch_url_bytes, + ): + patch_result = patch_duckdb_query_for_wasm( + """ + CREATE OR REPLACE VIEW remote_cars AS + SELECT * FROM 'https://datasets.marimo.app/cars.csv' + """, + ) + + assert patch_result is None + fetch_url_bytes.assert_not_called() + + +class TestDuckDBWasmMoSqlIntegration: + @staticmethod + async def test_mo_sql_rewrites_remote_literal_in_kernel( + executing_kernel: Kernel, exec_req: ExecReqProvider + ) -> None: + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=b"make,mpg\nford,25\ntoyota,18\n", + ) as fetch_url_bytes, + patch.object( + DuckDBEngine, "sql_output_format", return_value="native" + ), + ): + await executing_kernel.run( + [ + exec_req.get("import marimo as mo"), + exec_req.get( + """ + result = mo.sql( + ''' + SELECT make + FROM 'https://datasets.marimo.app/cars.csv' + WHERE mpg > 20 + ''', + output=False, + ) + """ + ), + ] + ) + + result = executing_kernel.globals["result"] + assert result.fetchall() == [("ford",)] + fetch_url_bytes.assert_called_once_with( + "https://datasets.marimo.app/cars.csv" + ) + + @staticmethod + async def test_mo_sql_create_table_remote_literal_runs_once( + executing_kernel: Kernel, exec_req: ExecReqProvider + ) -> None: + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=b"make,mpg\nford,25\ntoyota,18\n", + ) as fetch_url_bytes, + patch.object( + DuckDBEngine, "sql_output_format", return_value="native" + ), + ): + await executing_kernel.run( + [ + exec_req.get("import duckdb"), + exec_req.get("import marimo as mo"), + exec_req.get( + """ + result = mo.sql( + ''' + CREATE OR REPLACE TABLE __marimo_wasm_create_once AS ( + SELECT * FROM 'https://datasets.marimo.app/cars.csv' + ) + ''', + output=False, + ) + """ + ), + exec_req.get( + """ + rows = duckdb.sql( + ''' + SELECT make, mpg + FROM __marimo_wasm_create_once + ORDER BY make + ''' + ).fetchall() + duckdb.sql("DROP TABLE IF EXISTS __marimo_wasm_create_once") + """ + ), + ] + ) + + assert executing_kernel.globals["result"] is None + assert executing_kernel.globals["rows"] == [ + ("ford", 25), + ("toyota", 18), + ] + fetch_url_bytes.assert_called_once_with( + "https://datasets.marimo.app/cars.csv" + ) + + +class TestDuckDBWasmSqlUtils: + @staticmethod + def test_wrapped_sql_rewrites_remote_literal_with_explicit_connection() -> ( + None + ): + import duckdb + + from marimo._sql.utils import wrapped_sql + + connection = duckdb.connect(":memory:") + try: + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=b"make,mpg\nford,25\ntoyota,18\n", + ) as fetch_url_bytes, + ): + relation = wrapped_sql( + """ + SELECT make + FROM 'https://datasets.marimo.app/cars.csv' + WHERE mpg > 20 + """, + connection, + ) + rows = relation.fetchall() + finally: + connection.close() + + assert rows == [("ford",)] + fetch_url_bytes.assert_called_once_with( + "https://datasets.marimo.app/cars.csv" + ) + + @staticmethod + def test_wrapped_sql_rewrites_double_quoted_reader_url() -> None: + import duckdb + + from marimo._sql.utils import wrapped_sql + + connection = duckdb.connect(":memory:") + try: + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=b"make,mpg\nford,25\ntoyota,18\n", + ) as fetch_url_bytes, + ): + relation = wrapped_sql( + """ + SELECT make + FROM read_csv( + "https://datasets.marimo.app/cars.csv" + ) + WHERE mpg > 20 + """, + connection, + ) + rows = relation.fetchall() + finally: + connection.close() + + assert rows == [("ford",)] + fetch_url_bytes.assert_called_once_with( + "https://datasets.marimo.app/cars.csv" + ) + + @staticmethod + def test_execute_duckdb_sql_rewrites_remote_literal_with_explicit_connection() -> ( + None + ): + import duckdb + + from marimo._sql.utils import execute_duckdb_sql + + table_name = "__marimo_duckdb_wasm_sql_utils_execute_test" + connection = duckdb.connect(":memory:") + try: + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=b"make,mpg\nford,25\ntoyota,18\n", + ) as fetch_url_bytes, + ): + execute_duckdb_sql( + f""" + CREATE OR REPLACE TABLE {table_name} AS + SELECT make + FROM 'https://datasets.marimo.app/cars.csv' + WHERE mpg > ? + """, + [20], + connection, + ) + rows = connection.sql( + f"SELECT make FROM {table_name} ORDER BY make" + ).fetchall() + finally: + connection.close() + + assert rows == [("ford",)] + fetch_url_bytes.assert_called_once_with( + "https://datasets.marimo.app/cars.csv" + ) + + +class TestDuckDBWasmSqlApiPatch: + @staticmethod + def test_module_sql_rewrites_remote_literal_and_preserves_params() -> None: + import duckdb + + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=b"make,mpg\nford,25\ntoyota,18\n", + ) as fetch_url_bytes, + ): + unpatch = patch_duckdb_for_wasm() + try: + relation = duckdb.sql( + """ + SELECT make FROM 'https://datasets.marimo.app/cars.csv' + WHERE mpg > ? + """, + params=[20], + ) + finally: + unpatch() + + assert relation.fetchall() == [("ford",)] + fetch_url_bytes.assert_called_once_with( + "https://datasets.marimo.app/cars.csv" + ) + + @staticmethod + def test_module_query_rewrites_reader_call() -> None: + import duckdb + + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=b"1;ford\n2;toyota\n", + ) as fetch_url_bytes, + ): + unpatch = patch_duckdb_for_wasm() + try: + relation = duckdb.query( + """ + SELECT column1 FROM read_csv( + 'https://datasets.marimo.app/cars.csv', + delim=';', header=false + ) + """ + ) + finally: + unpatch() + + assert relation.fetchall() == [("ford",), ("toyota",)] + fetch_url_bytes.assert_called_once_with( + "https://datasets.marimo.app/cars.csv" + ) + + @staticmethod + def test_module_query_df_rewrites_reader_call() -> None: + import duckdb + import pandas as pd + + local_df = pd.DataFrame({"make": ["ford"], "score": [7]}) + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=b"make,mpg\nford,25\ntoyota,18\n", + ) as fetch_url_bytes, + ): + unpatch = patch_duckdb_for_wasm() + try: + relation = duckdb.query_df( + df=local_df, + virtual_table_name="query_df_local", + sql_query=""" + SELECT query_df_local.score, cars.mpg + FROM query_df_local + JOIN read_csv('https://datasets.marimo.app/cars.csv') AS cars + USING (make) + """, + ) + rows = relation.fetchall() + finally: + unpatch() + duckdb.sql("DROP VIEW IF EXISTS query_df_local") + + assert rows == [(7, 25)] + fetch_url_bytes.assert_called_once_with( + "https://datasets.marimo.app/cars.csv" + ) + + @staticmethod + def test_module_query_df_avoids_existing_catalog_table_names() -> None: + import duckdb + import pandas as pd + + local_df = pd.DataFrame({"make": ["ford"], "score": [7]}) + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=b"make,mpg\nford,25\ntoyota,18\n", + ), + ): + unpatch = patch_duckdb_for_wasm() + try: + duckdb.sql( + """ + CREATE OR REPLACE TABLE "__MARIMO_WASM_DUCKDB_REMOTE_0" + AS SELECT 'ford' AS make, 99 AS mpg + """ + ) + relation = duckdb.query_df( + df=local_df, + virtual_table_name="query_df_local_collision", + sql_query=""" + SELECT query_df_local_collision.score, cars.mpg + FROM query_df_local_collision + JOIN read_csv('https://datasets.marimo.app/cars.csv') AS cars + USING (make) + """, + ) + rows = relation.fetchall() + finally: + unpatch() + duckdb.sql("DROP VIEW IF EXISTS query_df_local_collision") + duckdb.sql( + 'DROP TABLE IF EXISTS "__MARIMO_WASM_DUCKDB_REMOTE_0"' + ) + + assert rows == [(7, 25)] + + @staticmethod + def test_module_sql_preserves_caller_replacement_scan() -> None: + import duckdb + import pandas as pd + + local_df = pd.DataFrame({"x": [1, 2]}) + with mock_pyodide(): + unpatch = patch_duckdb_for_wasm() + try: + rows = duckdb.sql("SELECT sum(x) FROM local_df").fetchall() + finally: + unpatch() + + assert rows == [(3,)] + + @staticmethod + def test_module_sql_skips_catalog_lookup_without_remote_source() -> None: + import duckdb + + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._duckdb._duckdb_catalog_names" + ) as catalog_names, + ): + unpatch = patch_duckdb_for_wasm() + try: + rows = duckdb.sql("SELECT 1").fetchall() + finally: + unpatch() + + assert rows == [(1,)] + catalog_names.assert_not_called() + + @staticmethod + def test_module_sql_without_remote_source_does_not_require_sqlglot() -> ( + None + ): + import duckdb + + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._duckdb._require_sqlglot", + side_effect=AssertionError("sqlglot should be lazy"), + ), + ): + unpatch = patch_duckdb_for_wasm() + try: + rows = duckdb.sql("SELECT 1").fetchall() + finally: + unpatch() + + assert rows == [(1,)] + + @staticmethod + def test_module_execute_rewrites_before_side_effects() -> None: + import duckdb + + table_name = "__marimo_duckdb_wasm_execute_test" + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=b"make,mpg\nford,25\ntoyota,18\n", + ), + ): + unpatch = patch_duckdb_for_wasm() + try: + duckdb.execute( + f""" + CREATE OR REPLACE TABLE {table_name} AS + SELECT make FROM 'https://datasets.marimo.app/cars.csv' + WHERE mpg > ? + """, + [20], + ) + rows = duckdb.sql( + f"SELECT make FROM {table_name} ORDER BY make" + ).fetchall() + finally: + unpatch() + duckdb.sql(f"DROP TABLE IF EXISTS {table_name}") + + assert rows == [("ford",)] + + @staticmethod + def test_module_execute_creates_table_from_remote_literal() -> None: + import duckdb + + table_name = "__marimo_duckdb_wasm_execute_create_test" + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=b"make,mpg\nford,25\ntoyota,18\n", + ) as fetch_url_bytes, + ): + unpatch = patch_duckdb_for_wasm() + try: + duckdb.execute( + f""" + CREATE OR REPLACE TABLE {table_name} AS ( + SELECT * FROM 'https://datasets.marimo.app/cars.csv' + ) + """ + ) + rows = duckdb.sql( + f"SELECT make, mpg FROM {table_name} ORDER BY make" + ).fetchall() + finally: + unpatch() + duckdb.sql(f"DROP TABLE IF EXISTS {table_name}") + + assert rows == [("ford", 25), ("toyota", 18)] + fetch_url_bytes.assert_called_once_with( + "https://datasets.marimo.app/cars.csv" + ) + + @staticmethod + def test_connection_methods_preserve_caller_replacement_scans() -> None: + import duckdb + import pandas as pd + + local_df = pd.DataFrame({"x": [1, 2]}) + connection = duckdb.connect(":memory:") + try: + with mock_pyodide(): + unpatch = patch_duckdb_for_wasm() + try: + sql_rows = connection.sql( + "SELECT sum(x) FROM local_df" + ).fetchall() + query_rows = connection.query( + "SELECT count(*) FROM local_df" + ).fetchall() + execute_rows = connection.execute( + "SELECT max(x) FROM local_df" + ).fetchall() + finally: + unpatch() + finally: + connection.close() + + assert sql_rows == [(3,)] + assert query_rows == [(2,)] + assert execute_rows == [(2,)] + + @staticmethod + def test_connection_execute_creates_table_from_remote_literal() -> None: + import duckdb + + table_name = "__marimo_duckdb_wasm_conn_execute_create_test" + connection = duckdb.connect(":memory:") + try: + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=b"make,mpg\nford,25\ntoyota,18\n", + ) as fetch_url_bytes, + ): + unpatch = patch_duckdb_for_wasm() + try: + connection.execute( + f""" + CREATE OR REPLACE TABLE {table_name} AS ( + SELECT * FROM 'https://datasets.marimo.app/cars.csv' + ) + """ + ) + rows = connection.execute( + f"SELECT make, mpg FROM {table_name} ORDER BY make" + ).fetchall() + finally: + unpatch() + finally: + connection.close() + + assert rows == [("ford", 25), ("toyota", 18)] + fetch_url_bytes.assert_called_once_with( + "https://datasets.marimo.app/cars.csv" + ) + + @staticmethod + def test_connection_sql_joins_caller_local_and_remote_tables() -> None: + import duckdb + import pandas as pd + + local_df = pd.DataFrame({"make": ["ford"], "score": [7]}) + connection = duckdb.connect(":memory:") + try: + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=b"make,mpg\nford,25\ntoyota,18\n", + ) as fetch_url_bytes, + ): + unpatch = patch_duckdb_for_wasm() + try: + relation = connection.sql( + """ + SELECT local_df.score, cars.mpg + FROM local_df + JOIN read_csv('https://datasets.marimo.app/cars.csv') AS cars + USING (make) + """ + ) + rows = relation.fetchall() + finally: + unpatch() + finally: + connection.close() + + assert rows == [(7, 25)] + fetch_url_bytes.assert_called_once_with( + "https://datasets.marimo.app/cars.csv" + ) + + @staticmethod + def test_connection_sql_avoids_existing_catalog_table_names() -> None: + import duckdb + + connection = duckdb.connect(":memory:") + try: + connection.sql( + """ + CREATE OR REPLACE TABLE "__MARIMO_WASM_DUCKDB_REMOTE_0" + AS SELECT 99 AS mpg + """ + ) + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=b"mpg\n25\n", + ) as fetch_url_bytes, + ): + unpatch = patch_duckdb_for_wasm() + try: + rows = connection.sql( + """ + SELECT mpg + FROM 'https://datasets.marimo.app/cars.csv' + """ + ).fetchall() + finally: + unpatch() + finally: + connection.close() + + assert rows == [(25,)] + fetch_url_bytes.assert_called_once_with( + "https://datasets.marimo.app/cars.csv" + ) + + @staticmethod + @pytest.mark.parametrize("api_kind", ["sql", "query", "execute"]) + def test_module_methods_with_connection_avoid_existing_catalog_table_names( + api_kind: str, + ) -> None: + import duckdb + + connection = duckdb.connect(":memory:") + result_table = "__marimo_duckdb_wasm_module_conn_result" + try: + connection.sql( + """ + CREATE OR REPLACE TABLE "__MARIMO_WASM_DUCKDB_REMOTE_0" + AS SELECT 99 AS mpg + """ + ) + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=b"mpg\n25\n", + ) as fetch_url_bytes, + ): + unpatch = patch_duckdb_for_wasm() + try: + if api_kind == "execute": + duckdb.execute( + f""" + CREATE OR REPLACE TABLE {result_table} AS + SELECT mpg + FROM 'https://datasets.marimo.app/cars.csv' + """, + connection=connection, + ) + rows = connection.sql( + f"SELECT mpg FROM {result_table}" + ).fetchall() + else: + rows = getattr(duckdb, api_kind)( + """ + SELECT mpg + FROM 'https://datasets.marimo.app/cars.csv' + """, + connection=connection, + ).fetchall() + finally: + unpatch() + finally: + connection.close() + + assert rows == [(25,)] + fetch_url_bytes.assert_called_once_with( + "https://datasets.marimo.app/cars.csv" + ) + + @staticmethod + def test_double_quoted_url_table_identifier_is_not_remote_source() -> None: + import duckdb + + table_name = '"https://datasets.marimo.app/cars.csv"' + try: + duckdb.sql( + f"CREATE OR REPLACE TABLE {table_name} AS SELECT 42 AS x" + ) + with ( + mock_pyodide(), + patch( + "marimo._runtime._wasm._fetch.fetch_url_bytes", + return_value=b"x\n7\n", + ) as fetch_url_bytes, + ): + unpatch = patch_duckdb_for_wasm() + try: + rows = duckdb.sql(f"SELECT x FROM {table_name}").fetchall() + finally: + unpatch() + finally: + duckdb.sql(f"DROP TABLE IF EXISTS {table_name}") + + assert rows == [(42,)] + fetch_url_bytes.assert_not_called() + + @staticmethod + @mock_pyodide() + def test_unpatch_restores_module_functions_and_connection_methods() -> ( + None + ): + import duckdb + + original_sql = duckdb.sql + original_query_df = duckdb.query_df + original_execute = duckdb.execute + original_connection_sql = duckdb.DuckDBPyConnection.sql + original_connection_execute = duckdb.DuckDBPyConnection.execute + original_connection_read_csv = duckdb.DuckDBPyConnection.read_csv + original_connection_read_parquet = ( + duckdb.DuckDBPyConnection.read_parquet + ) + original_connection_read_json = duckdb.DuckDBPyConnection.read_json + + unpatch = patch_duckdb_for_wasm() + assert duckdb.sql is not original_sql + assert duckdb.query_df is not original_query_df + assert duckdb.execute is not original_execute + assert duckdb.DuckDBPyConnection.sql is not original_connection_sql + assert ( + duckdb.DuckDBPyConnection.execute + is not original_connection_execute + ) + assert ( + duckdb.DuckDBPyConnection.read_csv + is not original_connection_read_csv + ) + assert ( + duckdb.DuckDBPyConnection.read_parquet + is not original_connection_read_parquet + ) + assert ( + duckdb.DuckDBPyConnection.read_json + is not original_connection_read_json + ) + + unpatch() + assert duckdb.sql is original_sql + assert duckdb.query_df is original_query_df + assert duckdb.execute is original_execute + assert duckdb.DuckDBPyConnection.sql is original_connection_sql + assert duckdb.DuckDBPyConnection.execute is original_connection_execute + assert ( + duckdb.DuckDBPyConnection.read_csv is original_connection_read_csv + ) + assert ( + duckdb.DuckDBPyConnection.read_parquet + is original_connection_read_parquet + ) + assert ( + duckdb.DuckDBPyConnection.read_json + is original_connection_read_json + ) + + unpatch() diff --git a/tests/_runtime/test_edges.py b/tests/_runtime/test_edges.py new file mode 100644 index 00000000000..99d7ee87f8f --- /dev/null +++ b/tests/_runtime/test_edges.py @@ -0,0 +1,563 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from functools import partial + +import pytest + +from marimo._ast import compiler +from marimo._dependencies.dependencies import DependencyManager +from marimo._runtime.dataflow import edges +from marimo._runtime.dataflow.definitions import DefinitionRegistry +from marimo._runtime.dataflow.topology import MutableGraphTopology + +parse_cell = partial(compiler.compile_cell, cell_id="0") + +HAS_SQL = DependencyManager.duckdb.has() and DependencyManager.polars.has() + + +class TestEdgeComputer: + """Tests for edge computation functions.""" + + def setup_method(self) -> None: + """Set up a fresh topology and definitions for each test.""" + self.topology = MutableGraphTopology() + self.definitions = DefinitionRegistry() + + def test_get_referring_cells_python_no_refs(self) -> None: + """Test getting referring cells when no cells reference the variable.""" + cell1 = parse_cell("x = 1") + self.topology.add_node("cell_1", cell1) + + referring = edges.get_referring_cells( + "x", language="python", topology=self.topology + ) + + assert referring == set() + + def test_get_referring_cells_python_single_ref(self) -> None: + """Test getting referring cells with a single reference.""" + cell1 = parse_cell("x = 1") + cell2 = parse_cell("y = x") + + self.topology.add_node("cell_1", cell1) + self.topology.add_node("cell_2", cell2) + + referring = edges.get_referring_cells( + "x", language="python", topology=self.topology + ) + + assert referring == {"cell_2"} + + def test_get_referring_cells_python_multiple_refs(self) -> None: + """Test getting referring cells with multiple references.""" + cell1 = parse_cell("x = 1") + cell2 = parse_cell("y = x") + cell3 = parse_cell("z = x * 2") + cell4 = parse_cell("w = x + y") + + self.topology.add_node("cell_1", cell1) + self.topology.add_node("cell_2", cell2) + self.topology.add_node("cell_3", cell3) + self.topology.add_node("cell_4", cell4) + + referring = edges.get_referring_cells( + "x", language="python", topology=self.topology + ) + + assert referring == {"cell_2", "cell_3", "cell_4"} + + def test_get_referring_cells_python_no_self_reference(self) -> None: + """Test that a cell doesn't include itself in referring cells.""" + cell1 = parse_cell("x = x + 1") + + self.topology.add_node("cell_1", cell1) + + referring = edges.get_referring_cells( + "x", language="python", topology=self.topology + ) + + # cell_1 both defines and references x, but should be in the result + assert referring == {"cell_1"} + + def test_compute_edges_simple_dependency(self) -> None: + """Test computing edges for a simple dependency.""" + cell1 = parse_cell("x = 1") + cell2 = parse_cell("y = x") + + self.topology.add_node("cell_1", cell1) + self.topology.add_node("cell_2", cell2) + + # Register cell1's definitions + for name, var_data in cell1.variable_data.items(): + self.definitions.register_definition("cell_1", name, var_data) + + # Compute edges for cell2 + parents, children = edges.compute_edges_for_cell( + "cell_2", cell2, self.topology, self.definitions + ) + + assert parents == {"cell_1"} + assert children == set() + + def test_compute_edges_multiple_parents(self) -> None: + """Test computing edges with multiple parent dependencies.""" + cell1 = parse_cell("x = 1") + cell2 = parse_cell("y = 2") + cell3 = parse_cell("z = x + y") + + self.topology.add_node("cell_1", cell1) + self.topology.add_node("cell_2", cell2) + self.topology.add_node("cell_3", cell3) + + # Register definitions + for name, var_data in cell1.variable_data.items(): + self.definitions.register_definition("cell_1", name, var_data) + for name, var_data in cell2.variable_data.items(): + self.definitions.register_definition("cell_2", name, var_data) + + # Compute edges for cell3 + parents, children = edges.compute_edges_for_cell( + "cell_3", cell3, self.topology, self.definitions + ) + + assert parents == {"cell_1", "cell_2"} + assert children == set() + + def test_compute_edges_with_children(self) -> None: + """Test computing edges when a cell has both parents and children.""" + cell1 = parse_cell("x = 1") + cell2 = parse_cell("y = x") + cell3 = parse_cell("z = y") + + self.topology.add_node("cell_1", cell1) + self.topology.add_node("cell_2", cell2) + self.topology.add_node("cell_3", cell3) + + # Register definitions for cell1 + for name, var_data in cell1.variable_data.items(): + self.definitions.register_definition("cell_1", name, var_data) + + # Register definitions for cell2 + for name, var_data in cell2.variable_data.items(): + self.definitions.register_definition("cell_2", name, var_data) + + # Compute edges for cell2 (middle cell) + parents, children = edges.compute_edges_for_cell( + "cell_2", cell2, self.topology, self.definitions + ) + + assert parents == {"cell_1"} + assert children == {"cell_3"} + + def test_compute_edges_no_dependencies(self) -> None: + """Test computing edges for an independent cell.""" + cell1 = parse_cell("x = 1") + + self.topology.add_node("cell_1", cell1) + + parents, children = edges.compute_edges_for_cell( + "cell_1", cell1, self.topology, self.definitions + ) + + assert parents == set() + assert children == set() + + def test_compute_edges_with_deleted_refs(self) -> None: + """Test computing edges when a cell deletes a variable.""" + cell1 = parse_cell("x = 1") + cell2 = parse_cell("y = x") + cell3 = parse_cell("del x") + + self.topology.add_node("cell_1", cell1) + self.topology.add_node("cell_2", cell2) + self.topology.add_node("cell_3", cell3) + + # Register definitions + for name, var_data in cell1.variable_data.items(): + self.definitions.register_definition("cell_1", name, var_data) + + # Compute edges for cell3 (which deletes x) + parents, children = edges.compute_edges_for_cell( + "cell_3", cell3, self.topology, self.definitions + ) + + # cell3 should become a child of cells that reference x (cell2) + assert parents == {"cell_1", "cell_2"} + assert children == set() + + def test_compute_edges_multiple_deleted_refs_create_cycle(self) -> None: + """Test that multiple cells deleting the same variable create cycles.""" + cell1 = parse_cell("x = 1") + cell2 = parse_cell("del x") + cell3 = parse_cell("del x") + + self.topology.add_node("cell_1", cell1) + self.topology.add_node("cell_2", cell2) + self.topology.add_node("cell_3", cell3) + + # Register definitions + for name, var_data in cell1.variable_data.items(): + self.definitions.register_definition("cell_1", name, var_data) + + # Compute edges for cell2 + parents2, children2 = edges.compute_edges_for_cell( + "cell_2", cell2, self.topology, self.definitions + ) + + # Compute edges for cell3 + parents3, _children3 = edges.compute_edges_for_cell( + "cell_3", cell3, self.topology, self.definitions + ) + + # Both cells should have cell1 as parent + assert "cell_1" in parents2 + assert "cell_1" in parents3 + + # cell2 and cell3 should be children of each other (cycle) + assert "cell_3" in children2 + assert "cell_2" in parents3 + + def test_compute_edges_cell_references_its_own_def(self) -> None: + """Test computing edges when a cell references its own definition.""" + cell1 = parse_cell("x = x + 1 if 'x' in globals() else 0") + + self.topology.add_node("cell_1", cell1) + + parents, children = edges.compute_edges_for_cell( + "cell_1", cell1, self.topology, self.definitions + ) + + # A cell doesn't create an edge to itself + assert parents == set() + assert children == set() + + def test_compute_edges_diamond_dependency(self) -> None: + """Test computing edges in a diamond-shaped dependency graph.""" + cell1 = parse_cell("a = 1") + cell2 = parse_cell("b = a") + cell3 = parse_cell("c = a") + cell4 = parse_cell("d = b + c") + + self.topology.add_node("cell_1", cell1) + self.topology.add_node("cell_2", cell2) + self.topology.add_node("cell_3", cell3) + self.topology.add_node("cell_4", cell4) + + # Register definitions + for name, var_data in cell1.variable_data.items(): + self.definitions.register_definition("cell_1", name, var_data) + for name, var_data in cell2.variable_data.items(): + self.definitions.register_definition("cell_2", name, var_data) + for name, var_data in cell3.variable_data.items(): + self.definitions.register_definition("cell_3", name, var_data) + + # Check cell1's children (only direct children, not grandchildren) + _parents1, children1 = edges.compute_edges_for_cell( + "cell_1", cell1, self.topology, self.definitions + ) + assert children1 == {"cell_2", "cell_3"} + + # Check cell4's parents (it references b and c, not a directly) + parents4, _children4 = edges.compute_edges_for_cell( + "cell_4", cell4, self.topology, self.definitions + ) + assert parents4 == {"cell_2", "cell_3"} + + def test_compute_edges_with_undefined_reference(self) -> None: + """Test computing edges when a cell references an undefined variable.""" + cell1 = parse_cell("y = x") # x is not defined + + self.topology.add_node("cell_1", cell1) + + # Should not raise an error, just return empty parents + parents, children = edges.compute_edges_for_cell( + "cell_1", cell1, self.topology, self.definitions + ) + + assert parents == set() + assert children == set() + + def test_compute_edges_with_function_closure(self) -> None: + """Test computing edges with function closures.""" + cell1 = parse_cell("x = 1") + cell2 = parse_cell("def foo():\n return x") + + self.topology.add_node("cell_1", cell1) + self.topology.add_node("cell_2", cell2) + + # Register definitions + for name, var_data in cell1.variable_data.items(): + self.definitions.register_definition("cell_1", name, var_data) + + # Compute edges for cell2 + parents, children = edges.compute_edges_for_cell( + "cell_2", cell2, self.topology, self.definitions + ) + + assert parents == {"cell_1"} + assert children == set() + + def test_compute_edges_with_class_definition(self) -> None: + """Test computing edges with class definitions.""" + cell1 = parse_cell("x = 1") + cell2 = parse_cell("class MyClass:\n value = x") + + self.topology.add_node("cell_1", cell1) + self.topology.add_node("cell_2", cell2) + + # Register definitions + for name, var_data in cell1.variable_data.items(): + self.definitions.register_definition("cell_1", name, var_data) + + # Compute edges for cell2 + parents, children = edges.compute_edges_for_cell( + "cell_2", cell2, self.topology, self.definitions + ) + + assert parents == {"cell_1"} + assert children == set() + + def test_compute_edges_chain_of_dependencies(self) -> None: + """Test a long chain of dependencies.""" + cells = [] + for i in range(5): + if i == 0: + cell = parse_cell(f"x{i} = {i}") + else: + cell = parse_cell(f"x{i} = x{i - 1} + 1") + self.topology.add_node(f"cell_{i}", cell) + cells.append((f"cell_{i}", cell)) + + # Register definitions + for cell_id, cell in cells[:-1]: + for name, var_data in cell.variable_data.items(): + self.definitions.register_definition(cell_id, name, var_data) + + # Check last cell's parents + parents, children = edges.compute_edges_for_cell( + "cell_4", cells[-1][1], self.topology, self.definitions + ) + + assert parents == {"cell_3"} + assert children == set() + + @pytest.mark.skipif(not HAS_SQL, reason="requires duckdb and polars") + def test_get_referring_cells_sql_basic(self) -> None: + """Test getting referring cells for SQL variables.""" + # SQL cell that creates a table + cell1 = compiler.compile_cell( + "import duckdb; import polars as pl; df = pl.DataFrame({'a': [1, 2, 3]})", + cell_id="0", + ) + cell2 = compiler.compile_cell("df.select(pl.col('a'))", cell_id="1") + + self.topology.add_node("cell_1", cell1) + self.topology.add_node("cell_2", cell2) + + referring = edges.get_referring_cells( + "df", language="python", topology=self.topology + ) + + assert referring == {"cell_2"} + + @pytest.mark.skipif(not HAS_SQL, reason="requires duckdb and polars") + def test_compute_edges_sql_to_python(self) -> None: + """Test that SQL table definitions don't create edges to Python refs.""" + # This tests the language isolation: SQL defs don't leak to Python + cell1 = compiler.compile_cell( + "df = mo.sql('CREATE TABLE my_table AS SELECT 1 as x')", + cell_id="0", + ) + cell2 = compiler.compile_cell("print(my_table)", cell_id="1") + + self.topology.add_node("cell_1", cell1) + self.topology.add_node("cell_2", cell2) + + # Register SQL table definition + for name, var_data in cell1.variable_data.items(): + self.definitions.register_definition("cell_1", name, var_data) + + # Compute edges for cell2 (Python cell) + _parents, _children = edges.compute_edges_for_cell( + "cell_2", cell2, self.topology, self.definitions + ) + + # SQL definitions should not create edges to Python cells + # (unless explicitly exported) + # Since my_table is SQL, it won't be a parent + # However, the compiler might not recognize it as SQL without execution + # So this test verifies the edge computation logic + + def test_compute_edges_multiple_definitions_of_same_var(self) -> None: + """Test computing edges when multiple cells define the same variable.""" + cell1 = parse_cell("x = 1") + cell2 = parse_cell("x = 2") + cell3 = parse_cell("y = x") + + self.topology.add_node("cell_1", cell1) + self.topology.add_node("cell_2", cell2) + self.topology.add_node("cell_3", cell3) + + # Register both definitions of x + for name, var_data in cell1.variable_data.items(): + self.definitions.register_definition("cell_1", name, var_data) + for name, var_data in cell2.variable_data.items(): + self.definitions.register_definition("cell_2", name, var_data) + + # Compute edges for cell3 + parents, _children = edges.compute_edges_for_cell( + "cell_3", cell3, self.topology, self.definitions + ) + + # cell3 should depend on both cells that define x + assert parents == {"cell_1", "cell_2"} + + def test_compute_edges_complex_scenario(self) -> None: + """Test a complex scenario with multiple patterns.""" + # Create a complex graph: + # cell1: defines a + # cell2: defines b = a + # cell3: defines c = a + b + # cell4: uses c, deletes a + # cell5: defines d = b + + cell1 = parse_cell("a = 1") + cell2 = parse_cell("b = a") + cell3 = parse_cell("c = a + b") + cell4 = parse_cell("result = c; del a") + cell5 = parse_cell("d = b") + + cells = [ + ("cell_1", cell1), + ("cell_2", cell2), + ("cell_3", cell3), + ("cell_4", cell4), + ("cell_5", cell5), + ] + + for cell_id, cell in cells: + self.topology.add_node(cell_id, cell) + + # Register definitions for cells 1-3 + for cell_id, cell in cells[:3]: + for name, var_data in cell.variable_data.items(): + self.definitions.register_definition(cell_id, name, var_data) + + # Compute edges for cell4 + parents4, _children4 = edges.compute_edges_for_cell( + "cell_4", cell4, self.topology, self.definitions + ) + + # cell4 should: + # - depend on cell3 (for c) + # - depend on cell1 (for del a) + # - depend on cell2 (because it references a, which cell4 deletes) + assert "cell_3" in parents4 # uses c + assert "cell_1" in parents4 # deletes a + assert "cell_2" in parents4 # cell2 references a + + def test_is_valid_cell_reference_missing_cell(self) -> None: + """Test validation when a cell reference points to a non-existent cell.""" + # The _is_valid_cell_reference function logs an error when a cell is not found + result = edges._is_valid_cell_reference( + "nonexistent", "some_var", self.topology + ) + + assert result is False + + def test_is_valid_cell_reference_valid_cell(self) -> None: + """Test validation when a cell reference is valid.""" + cell1 = parse_cell("x = 1") + self.topology.add_node("cell_1", cell1) + + result = edges._is_valid_cell_reference("cell_1", "x", self.topology) + + assert result is True + + def test_compute_edges_with_imports(self) -> None: + """Test computing edges with import statements.""" + cell1 = parse_cell("import math") + cell2 = parse_cell("x = math.pi") + + self.topology.add_node("cell_1", cell1) + self.topology.add_node("cell_2", cell2) + + # Register definitions + for name, var_data in cell1.variable_data.items(): + self.definitions.register_definition("cell_1", name, var_data) + + # Compute edges for cell2 + parents, _children = edges.compute_edges_for_cell( + "cell_2", cell2, self.topology, self.definitions + ) + + assert parents == {"cell_1"} + + def test_compute_edges_with_from_import(self) -> None: + """Test computing edges with from-import statements.""" + cell1 = parse_cell("from math import pi") + cell2 = parse_cell("x = pi") + + self.topology.add_node("cell_1", cell1) + self.topology.add_node("cell_2", cell2) + + # Register definitions + for name, var_data in cell1.variable_data.items(): + self.definitions.register_definition("cell_1", name, var_data) + + # Compute edges for cell2 + parents, _children = edges.compute_edges_for_cell( + "cell_2", cell2, self.topology, self.definitions + ) + + assert parents == {"cell_1"} + + def test_compute_edges_list_comprehension(self) -> None: + """Test computing edges with list comprehensions.""" + cell1 = parse_cell("numbers = [1, 2, 3]") + cell2 = parse_cell("squared = [x**2 for x in numbers]") + + self.topology.add_node("cell_1", cell1) + self.topology.add_node("cell_2", cell2) + + # Register definitions + for name, var_data in cell1.variable_data.items(): + self.definitions.register_definition("cell_1", name, var_data) + + # Compute edges for cell2 + parents, _children = edges.compute_edges_for_cell( + "cell_2", cell2, self.topology, self.definitions + ) + + assert parents == {"cell_1"} + + def test_compute_edges_with_multiple_assignments(self) -> None: + """Test computing edges with multiple assignments.""" + cell1 = parse_cell("x = y = z = 1") + cell2 = parse_cell("a = x") + cell3 = parse_cell("b = y") + cell4 = parse_cell("c = z") + + self.topology.add_node("cell_1", cell1) + self.topology.add_node("cell_2", cell2) + self.topology.add_node("cell_3", cell3) + self.topology.add_node("cell_4", cell4) + + # Register definitions + for name, var_data in cell1.variable_data.items(): + self.definitions.register_definition("cell_1", name, var_data) + + # All cells should depend on cell1 + for cell_id in ["cell_2", "cell_3", "cell_4"]: + cell = self.topology.cells[cell_id] + parents, _children = edges.compute_edges_for_cell( + cell_id, cell, self.topology, self.definitions + ) + assert parents == {"cell_1"} + + # Check that cell1 has all three as children + _parents1, children1 = edges.compute_edges_for_cell( + "cell_1", cell1, self.topology, self.definitions + ) + assert children1 == {"cell_2", "cell_3", "cell_4"} diff --git a/tests/_runtime/test_executor_debugger.py b/tests/_runtime/test_executor_debugger.py new file mode 100644 index 00000000000..d998b004d1f --- /dev/null +++ b/tests/_runtime/test_executor_debugger.py @@ -0,0 +1,302 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from typing import Any + +import pytest + +from tests._messaging.mocks import MockStream + + +class _FakeDebugger: + """Minimal stand-in for `MarimoPdb` used by the frame watcher tests. + + Records `interaction()` calls (by line) instead of entering a real pdb + session. + """ + + def __init__( + self, + *, + quit_on_interaction: bool = False, + step: bool = False, + ) -> None: + self.breakpoints: dict[Any, set[int]] = {} + self.interaction_lines: list[int] = [] + self.nosigint = False + self.quitting = False + self.botframe: Any = None + self._quit_on_interaction = quit_on_interaction + # `step=True` mimics a `step`/`next` command: pdb wants to stop at + # every upcoming line (`stop_here` truthy). Default mimics `continue`. + self._step = step + + def disable_sigint(self) -> None: + self.nosigint = True + + def reset(self) -> None: + self.quitting = False + + def stop_here(self, frame: Any) -> bool: + del frame + return self._step + + def interaction(self, frame: Any, traceback: Any) -> None: + del traceback + self.interaction_lines.append(frame.f_lineno) + # Mimic pdb's `quit` command setting the bdb quitting flag. + if self._quit_on_interaction: + self.quitting = True + + +class TestFrameWatcher: + @staticmethod + def _cell_code(cell_id: str, code: str) -> Any: + from marimo._ast.compiler import get_filename + + return compile(code, get_filename(cell_id), "exec") + + @staticmethod + def test_records_current_cell_line() -> None: + from marimo._runtime.executor.lifecycles.debugger import FrameWatcher + + watcher = FrameWatcher(_FakeDebugger()) # type: ignore[arg-type] + code = TestFrameWatcher._cell_code("abc", "a = 1\nb = 2\nc = 3\n") + watcher.install() + try: + exec(code, {}) + current = watcher._current + finally: + watcher.uninstall() + assert current == ("abc", 3) + + @staticmethod + def test_ignores_non_cell_frames() -> None: + from marimo._runtime.executor.lifecycles.debugger import FrameWatcher + + watcher = FrameWatcher(_FakeDebugger()) # type: ignore[arg-type] + # A plain (non-cell) filename must never be traced. + code = compile("a = 1\nb = 2\n", "", "exec") + watcher.install() + try: + exec(code, {}) + current = watcher._current + finally: + watcher.uninstall() + assert current is None + + @staticmethod + def test_breakpoint_drops_into_pdb() -> None: + from marimo._runtime.executor.lifecycles.debugger import FrameWatcher + + debugger = _FakeDebugger() + debugger.breakpoints = {"abc": {2}} + watcher = FrameWatcher(debugger) # type: ignore[arg-type] + code = TestFrameWatcher._cell_code("abc", "a = 1\nb = 2\nc = 3\n") + watcher.install() + try: + exec(code, {}) + finally: + watcher.uninstall() + # The pdb prompt opens once, at the breakpoint line, and not for + # other lines. + assert debugger.interaction_lines == [2] + + @staticmethod + def test_breakpoint_in_loop_rehits_each_iteration() -> None: + from marimo._runtime.executor.lifecycles.debugger import FrameWatcher + + debugger = _FakeDebugger() + # Line 2 (the loop body) has a breakpoint; it should fire on every + # iteration even though `interaction` (continue) returns each time. + debugger.breakpoints = {"abc": {2}} + watcher = FrameWatcher(debugger) # type: ignore[arg-type] + code = TestFrameWatcher._cell_code( + "abc", "for i in range(3):\n x = i\n" + ) + watcher.install() + try: + exec(code, {}) + finally: + watcher.uninstall() + assert debugger.interaction_lines == [2, 2, 2] + + @staticmethod + def test_step_stops_at_each_following_line() -> None: + from marimo._runtime.executor.lifecycles.debugger import FrameWatcher + + # Breakpoint at line 1; stepping then stops at every subsequent line + # (the watcher consults pdb's `stop_here` once a session is underway). + debugger = _FakeDebugger(step=True) + debugger.breakpoints = {"abc": {1}} + watcher = FrameWatcher(debugger) # type: ignore[arg-type] + code = TestFrameWatcher._cell_code("abc", "a = 1\nb = 2\nc = 3\n") + watcher.install() + try: + exec(code, {}) + finally: + watcher.uninstall() + assert debugger.interaction_lines == [1, 2, 3] + + @staticmethod + def test_quit_stops_the_cell() -> None: + from marimo._runtime.control_flow import MarimoStopError + from marimo._runtime.executor.lifecycles.debugger import FrameWatcher + + debugger = _FakeDebugger(quit_on_interaction=True) + debugger.breakpoints = {"abc": {2}} + watcher = FrameWatcher(debugger) # type: ignore[arg-type] + code = TestFrameWatcher._cell_code("abc", "a = 1\nb = 2\nc = 3\n") + watcher.install() + try: + # Quitting at the breakpoint raises MarimoStopError out of the + # cell body (the clean-stop path), so line 3 never runs. + with pytest.raises(MarimoStopError): + exec(code, {}) + finally: + watcher.uninstall() + assert debugger.interaction_lines == [2] + + @staticmethod + def test_disables_pdb_sigint_hijack() -> None: + from marimo._runtime.executor.lifecycles.debugger import FrameWatcher + + debugger = _FakeDebugger() + watcher = FrameWatcher(debugger) # type: ignore[arg-type] + watcher.install() + try: + # marimo owns SIGINT, so pdb must not install its own handler. + assert debugger.nosigint is True + finally: + watcher.uninstall() + + @staticmethod + def test_uninstall_restores_previous_trace() -> None: + import sys + + from marimo._runtime.executor.lifecycles.debugger import FrameWatcher + + prev = sys.gettrace() + watcher = FrameWatcher(_FakeDebugger()) # type: ignore[arg-type] + watcher.install() + # `_trace` is a bound method (new wrapper each access), so compare the + # underlying instance rather than identity. + installed = sys.gettrace() + assert getattr(installed, "__self__", None) is watcher + watcher.uninstall() + assert sys.gettrace() is prev + + @staticmethod + def test_broadcast_emits_active_line_notification() -> None: + from marimo._messaging.notification import ActiveLineNotification + from marimo._runtime.executor.lifecycles.debugger import FrameWatcher + + watcher = FrameWatcher(_FakeDebugger()) # type: ignore[arg-type] + watcher._stream = MockStream() + watcher._broadcast("abc", 5) # type: ignore[arg-type] + watcher._broadcast("abc", None) # type: ignore[arg-type] + + ops = watcher._stream.parsed_operations + assert len(ops) == 2 + assert isinstance(ops[0], ActiveLineNotification) + assert ops[0].cell_id == "abc" + assert ops[0].line == 5 + assert ops[1].line is None + + +class TestFrameWatcherWithoutDebugger: + """The watcher with `debugger=None` (the `line_timing` flag) only streams + the active line; there is no pdb to enter.""" + + @staticmethod + def test_records_current_cell_line() -> None: + from marimo._runtime.executor.lifecycles.debugger import FrameWatcher + + watcher = FrameWatcher(None) + code = TestFrameWatcher._cell_code("abc", "a = 1\nb = 2\nc = 3\n") + watcher.install() + try: + exec(code, {}) + current = watcher._current + finally: + watcher.uninstall() + assert current == ("abc", 3) + + @staticmethod + def test_install_uninstall_restores_previous_trace() -> None: + import sys + + from marimo._runtime.executor.lifecycles.debugger import FrameWatcher + + prev = sys.gettrace() + watcher = FrameWatcher(None) + watcher.install() + installed = sys.gettrace() + assert getattr(installed, "__self__", None) is watcher + watcher.uninstall() + assert sys.gettrace() is prev + + @staticmethod + def test_runs_to_completion_without_entering_pdb() -> None: + from marimo._runtime.executor.lifecycles.debugger import FrameWatcher + + watcher = FrameWatcher(None) + code = TestFrameWatcher._cell_code( + "abc", "total = 0\nfor i in range(3):\n total += i\n" + ) + glbls: dict[str, Any] = {} + watcher.install() + try: + exec(code, glbls) + finally: + watcher.uninstall() + assert glbls["total"] == 3 + + @staticmethod + def test_heartbeat_flushes_active_line() -> None: + import threading + import time + + from marimo._messaging.notification import ActiveLineNotification + from marimo._runtime.executor.lifecycles.debugger import FrameWatcher + + watcher = FrameWatcher(None) + stream = MockStream() + watcher._stream = stream + watcher._current = ("abc", 5) # type: ignore[assignment] + watcher._stop.clear() + thread = threading.Thread(target=watcher._heartbeat, daemon=True) + thread.start() + try: + for _ in range(100): + if stream.parsed_operations: + break + time.sleep(0.01) + finally: + watcher._stop.set() + thread.join(timeout=1.0) + + ops = stream.parsed_operations + assert ops, "heartbeat never flushed the active line" + assert isinstance(ops[0], ActiveLineNotification) + assert (ops[0].cell_id, ops[0].line) == ("abc", 5) + assert watcher._flushed == ("abc", 5) + + @staticmethod + def test_uninstall_broadcasts_line_clear() -> None: + from marimo._runtime.executor.lifecycles.debugger import FrameWatcher + + watcher = FrameWatcher(None) + code = TestFrameWatcher._cell_code("abc", "a = 1\n") + watcher.install() + # No runtime context in tests, so install() found no stream; give the + # watcher one to observe the clear broadcast on uninstall. + stream = MockStream() + watcher._stream = stream + try: + exec(code, {}) + finally: + watcher.uninstall() + + assert stream.parsed_operations + assert stream.parsed_operations[-1].line is None diff --git a/tests/_runtime/test_executor_evaluator.py b/tests/_runtime/test_executor_evaluator.py new file mode 100644 index 00000000000..10f5ffed519 --- /dev/null +++ b/tests/_runtime/test_executor_evaluator.py @@ -0,0 +1,733 @@ +# Copyright 2026 Marimo. All rights reserved. +# Stub classes here conform to the ExecutionLifecycle / Executor +# Protocols, so they take `cell` / `glbls` even when the test body +# doesn't use them. +# ruff: noqa: ARG001, ARG002 +"""Tests for the Evaluator + ExecutionLifecycle composition. + +Covers setup chain order, Skip termination, teardown reverse order, +teardown visibility of body exceptions, teardown-wins semantics on +double raise, and KeyboardInterrupt propagation through teardown. +""" + +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any + +import pytest + +from marimo._runtime.exceptions import MarimoRuntimeException +from marimo._runtime.executor import ( + DefaultExecutor, + Evaluator, + ExecutionLifecycle, + Skip, +) +from marimo._runtime.runner.cell_runner import Runner +from marimo._runtime.runner.result import RunResult + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + +class _Recorder: + """Lifecycle that records setup/teardown calls into a shared log.""" + + def __init__( + self, + log: list[str], + tag: str, + skip: Skip | None = None, + setup_raises: BaseException | None = None, + teardown_raises: BaseException | None = None, + ) -> None: + self.name = f"recorder-{tag}" + self._log = log + self._tag = tag + self._skip = skip + self._setup_raises = setup_raises + self._teardown_raises = teardown_raises + self.last_run_result: Any = None + + def setup(self, cell: Any, glbls: dict[str, Any]) -> Skip | None: + self._log.append(f"setup:{self._tag}") + if self._setup_raises is not None: + raise self._setup_raises + return self._skip + + def teardown( + self, cell: Any, glbls: dict[str, Any], run_result: Any + ) -> None: + self._log.append(f"teardown:{self._tag}") + self.last_run_result = run_result + if self._teardown_raises is not None: + raise self._teardown_raises + + +class _StubExecutor: + """Executor that runs a caller-provided body, no exec/eval.""" + + name = "stub" + + def __init__(self, body: Any) -> None: + self._body = body + + def execute_cell(self, cell: Any, glbls: dict[str, Any]) -> Any: + return self._body(cell, glbls) + + async def execute_cell_async( + self, cell: Any, glbls: dict[str, Any] + ) -> Any: + result = self._body(cell, glbls) + if asyncio.iscoroutine(result): + return await result + return result + + +async def test_skip_terminates_setup_chain_but_runs_completed_teardowns() -> ( + None +): + log: list[str] = [] + a = _Recorder( + log, "A", skip=Skip(result=RunResult(output=42, exception=None)) + ) + b = _Recorder(log, "B") + + body_ran = [False] + + def body(cell: Any, glbls: dict[str, Any]) -> Any: + body_ran[0] = True + return "should-not-see-this" + + ev = Evaluator(executor=_StubExecutor(body), lifecycles=[a, b]) + result = await ev.evaluate(cell=None, glbls={}) + + assert result.output == 42 + assert result.exception is None + assert body_ran[0] is False + # A setup ran, A teardown ran. B setup did NOT run, B teardown did + # NOT run. + assert log == ["setup:A", "teardown:A"] + + +async def test_skip_result_preserves_accumulated_output() -> None: + """`Skip(result=RunResult(...))` threads the entire RunResult + through teardown — `output`, `exception`, and + `accumulated_output` all survive, including any future fields + added to `RunResult`.""" + log: list[str] = [] + skip_result = RunResult( + output="cached", exception=None, accumulated_output="streamed" + ) + a = _Recorder(log, "A", skip=Skip(result=skip_result)) + + ev = Evaluator(executor=_StubExecutor(lambda *_: "unused"), lifecycles=[a]) + result = await ev.evaluate(cell=None, glbls={}) + + assert result.output == "cached" + assert result.accumulated_output == "streamed" + assert result.exception is None + # Teardown saw the same RunResult that came back out. + assert a.last_run_result is result + + +async def test_teardowns_fire_in_reverse_order_on_success() -> None: + log: list[str] = [] + a = _Recorder(log, "A") + b = _Recorder(log, "B") + c = _Recorder(log, "C") + + ev = Evaluator( + executor=_StubExecutor(lambda *_: "ok"), + lifecycles=[a, b, c], + ) + result = await ev.evaluate(cell=None, glbls={}) + + assert result.output == "ok" + assert result.exception is None + assert log == [ + "setup:A", + "setup:B", + "setup:C", + "teardown:C", + "teardown:B", + "teardown:A", + ] + + +async def test_teardown_sees_body_exception_via_run_result() -> None: + log: list[str] = [] + a = _Recorder(log, "A") + + def boom(cell: Any, glbls: dict[str, Any]) -> Any: + raise ValueError("body bomb") + + ev = Evaluator(executor=_StubExecutor(boom), lifecycles=[a]) + # The _StubExecutor doesn't wrap user exceptions; the body's + # ValueError lands directly in result.exception, and the teardown + # sees that same exception via run_result. + result = await ev.evaluate(cell=None, glbls={}) + + assert isinstance(result.exception, ValueError) + assert str(result.exception) == "body bomb" + assert a.last_run_result is not None + assert isinstance(a.last_run_result.exception, ValueError) + + +async def test_default_executor_wraps_user_exception_in_marimo_runtime() -> ( + None +): + """DefaultExecutor turns user exceptions into MarimoRuntimeException + with the user exception as __cause__. The teardown sees the wrapped + form, and the returned RunResult carries it as its exception.""" + from marimo._ast.cell import CellImpl + + log: list[str] = [] + a = _Recorder(log, "A") + + body_src = "raise ValueError('user bomb')" + + class _FakeCell: + cell_id = "0" + body = compile(body_src, "", "exec") + last_expr = compile("None", "", "eval") + + def is_coroutine(self) -> bool: + return False + + del CellImpl # silence unused-import + ev = Evaluator(executor=DefaultExecutor(), lifecycles=[a]) + result = await ev.evaluate(_FakeCell(), {}) # type: ignore[arg-type] + + assert isinstance(result.exception, MarimoRuntimeException) + assert isinstance(result.exception.__cause__, ValueError) + assert a.last_run_result is not None + # Teardown saw the wrapped exception, not the raw ValueError. + assert isinstance(a.last_run_result.exception, MarimoRuntimeException) + + +def _cause_traceback_filenames(exc: BaseException) -> list[str]: + cause = exc.__cause__ + assert cause is not None + tb = cause.__traceback__ + files: list[str] = [] + while tb is not None: + files.append(tb.tb_frame.f_code.co_filename) + tb = tb.tb_next + return files + + +def test_default_executor_strips_own_frame_from_cause_sync() -> None: + """`DefaultExecutor.execute_cell` must not leave its own frame on + the cause's `__traceback__` — user-facing tracebacks should begin + at user code (the compiled `` source).""" + + class _FakeCell: + cell_id = "0" + body = compile("raise ValueError('user bomb')", "", "exec") + last_expr = compile("None", "", "eval") + + with pytest.raises(MarimoRuntimeException) as exc_info: + DefaultExecutor().execute_cell(_FakeCell(), {}) # type: ignore[arg-type] + + files = _cause_traceback_filenames(exc_info.value) + assert files, "cause traceback unexpectedly empty" + assert not any("executor/executor.py" in f for f in files), files + assert files[0] == "" + + +async def test_default_executor_strips_own_frame_from_cause_async() -> None: + """Same as the sync variant, for `execute_cell_async`.""" + + class _FakeCell: + cell_id = "0" + body = compile("raise ValueError('user bomb')", "", "exec") + last_expr = compile("None", "", "eval") + + with pytest.raises(MarimoRuntimeException) as exc_info: + await DefaultExecutor().execute_cell_async(_FakeCell(), {}) # type: ignore[arg-type] + + files = _cause_traceback_filenames(exc_info.value) + assert files, "cause traceback unexpectedly empty" + assert not any("executor/executor.py" in f for f in files), files + assert files[0] == "" + + +async def test_teardown_runs_for_completed_setups_when_later_setup_raises() -> ( + None +): + log: list[str] = [] + a = _Recorder(log, "A") + b = _Recorder(log, "B", setup_raises=RuntimeError("setup-B raised")) + c = _Recorder(log, "C") # never reached + + ev = Evaluator( + executor=_StubExecutor(lambda *_: "ok"), + lifecycles=[a, b, c], + ) + result = await ev.evaluate(cell=None, glbls={}) + + assert isinstance(result.exception, RuntimeError) + assert str(result.exception) == "setup-B raised" + # A.setup ran (completed), B.setup ran and raised, C.setup did not + # run. Teardowns run only for lifecycles whose setup *completed* + # without raising — so only A. B is not teardowned because its + # state was never established. + assert log == [ + "setup:A", + "setup:B", + "teardown:A", + ] + + +async def test_teardown_wins_on_double_raise() -> None: + log: list[str] = [] + a = _Recorder(log, "A", teardown_raises=RuntimeError("teardown wins")) + + def body(cell: Any, glbls: dict[str, Any]) -> Any: + raise ValueError("body loses") + + ev = Evaluator(executor=_StubExecutor(body), lifecycles=[a]) + result = await ev.evaluate(cell=None, glbls={}) + + # Teardown exception replaces body exception in the final RunResult. + assert isinstance(result.exception, RuntimeError) + assert str(result.exception) == "teardown wins" + + +async def test_keyboard_interrupt_captured_into_run_result() -> None: + log: list[str] = [] + a = _Recorder(log, "A") + + def body(cell: Any, glbls: dict[str, Any]) -> Any: + raise KeyboardInterrupt + + ev = Evaluator(executor=_StubExecutor(body), lifecycles=[a]) + result = await ev.evaluate(cell=None, glbls={}) + + # Teardown ran (state still cleaned up) even though body raised + # BaseException, and the interrupt is captured in the RunResult + # rather than propagating out of evaluate(). + assert log == ["setup:A", "teardown:A"] + assert isinstance(result.exception, KeyboardInterrupt) + assert isinstance(a.last_run_result.exception, KeyboardInterrupt) + + +def test_strict_lifecycle_round_trip() -> None: + """Globals restored to pre-state after StrictLifecycle setup + + teardown.""" + from marimo._runtime.executor.lifecycles.strict import StrictLifecycle + + class _FakeCell: + cell_id = "c0" + refs: set[str] = set() + defs: set[str] = set() + + class _FakeGraph: + def get_transitive_references( + self, refs: set[str], predicate: Any + ) -> set[str]: + return set() + + lifecycle = StrictLifecycle(graph=_FakeGraph()) # type: ignore[arg-type] + glbls: dict[str, Any] = { + "x": 1, + "y": [1, 2, 3], + "__builtins__": __builtins__, + } + pre = {k: v for k, v in glbls.items()} + + skip = lifecycle.setup(_FakeCell(), glbls) # type: ignore[arg-type] + assert skip is None + + # During setup, glbls should be the sanitized scope (subset). + assert "x" not in glbls # No refs declared → x is not in scope. + + lifecycle.teardown(_FakeCell(), glbls, run_result=None) # type: ignore[arg-type] + + # Globals restored — same values for unchanged keys. + assert glbls["x"] == pre["x"] + assert glbls["y"] == pre["y"] + + +class _StrictGraph: + """`_FakeGraph` for `StrictLifecycle` setup-path tests. + + `transitive_refs` controls what `get_transitive_references` returns + so the test can drive `setup` past sanitization into the + error-construction branch. `defining_cells` maps refs to defining + cell IDs; refs absent from the map raise `KeyError` to exercise + the `unmangle_local` fallback. + """ + + def __init__( + self, + transitive_refs: set[str], + defining_cells: dict[str, list[str]] | None = None, + ) -> None: + self._transitive_refs = transitive_refs + self._defining_cells = defining_cells or {} + + def get_transitive_references( + self, refs: set[str], predicate: Any + ) -> set[str]: + return set(self._transitive_refs) + + def get_defining_cells(self, ref: str) -> list[str]: + return self._defining_cells[ref] + + +class _StrictCell: + def __init__(self, refs: set[str], defs: set[str] | None = None) -> None: + self.cell_id = "c0" + self.refs = refs + self.defs = defs or set() + + +def test_strict_setup_skip_on_undefined_ref() -> None: + """Unresolved ref → `Skip(result=RunResult(output=err, exception=err))` + where `err` is a `MarimoStrictExecutionError` with no blamed cell + (graph has no defining cell and the ref is not a private var).""" + from marimo._messaging.errors import MarimoStrictExecutionError + from marimo._runtime.executor.lifecycles.strict import StrictLifecycle + + lifecycle = StrictLifecycle( + graph=_StrictGraph(transitive_refs={"x"}) # type: ignore[arg-type] + ) + glbls: dict[str, Any] = {"__builtins__": {}} + + skip = lifecycle.setup(_StrictCell(refs={"x"}), glbls) # type: ignore[arg-type] + + assert skip is not None + assert skip.result is not None + err = skip.result.exception + assert isinstance(err, MarimoStrictExecutionError) + assert err.ref == "x" + assert err.blamed_cell is None + assert skip.result.output is err + + +def test_strict_setup_skip_on_ref_before_def() -> None: + """Ref appears in the cell's own `defs` → ref-before-def branch.""" + from marimo._messaging.errors import MarimoStrictExecutionError + from marimo._runtime.executor.lifecycles.strict import StrictLifecycle + + lifecycle = StrictLifecycle( + graph=_StrictGraph(transitive_refs={"x"}) # type: ignore[arg-type] + ) + glbls: dict[str, Any] = {"__builtins__": {}} + + skip = lifecycle.setup( + _StrictCell(refs={"x"}, defs={"x"}), # type: ignore[arg-type] + glbls, + ) + + assert skip is not None + assert skip.result is not None + err = skip.result.exception + assert isinstance(err, MarimoStrictExecutionError) + assert err.ref == "x" + assert err.blamed_cell is None + + +def test_strict_setup_skip_resolves_blamed_cell_via_graph() -> None: + """`get_defining_cells` returns the owning cell → blamed_cell.""" + from marimo._messaging.errors import MarimoStrictExecutionError + from marimo._runtime.executor.lifecycles.strict import StrictLifecycle + + lifecycle = StrictLifecycle( + graph=_StrictGraph( # type: ignore[arg-type] + transitive_refs={"x"}, + defining_cells={"x": ["other"]}, + ) + ) + glbls: dict[str, Any] = {"__builtins__": {}} + + skip = lifecycle.setup(_StrictCell(refs={"x"}), glbls) # type: ignore[arg-type] + + assert skip is not None + assert skip.result is not None + err = skip.result.exception + assert isinstance(err, MarimoStrictExecutionError) + assert err.blamed_cell == "other" + + +def test_strict_setup_skip_falls_back_to_private_var_owner() -> None: + """`KeyError` from the graph → `unmangle_local` resolves the + owning cell for mangled private vars.""" + from marimo._messaging.errors import MarimoStrictExecutionError + from marimo._runtime.executor.lifecycles.strict import StrictLifecycle + + # `_cell_ZZZ_priv` unmangles to (name="_priv", cell="ZZZ"). + private_ref = "_cell_ZZZ_priv" + lifecycle = StrictLifecycle( + graph=_StrictGraph( # type: ignore[arg-type] + transitive_refs={private_ref}, + ) + ) + glbls: dict[str, Any] = {"__builtins__": {}} + + skip = lifecycle.setup( + _StrictCell(refs={private_ref}), # type: ignore[arg-type] + glbls, + ) + + assert skip is not None + assert skip.result is not None + err = skip.result.exception + assert isinstance(err, MarimoStrictExecutionError) + assert err.blamed_cell == "ZZZ" + + +def test_strict_setup_skip_does_not_mutate_globals_or_stash_backup() -> None: + """The Skip early-return must happen before globals are cleared and + before the backup is stashed. `teardown` must then be a no-op.""" + from marimo._runtime.executor.lifecycles.strict import StrictLifecycle + + lifecycle = StrictLifecycle( + graph=_StrictGraph(transitive_refs={"x"}) # type: ignore[arg-type] + ) + glbls: dict[str, Any] = { + "preserve_me": 42, + "__builtins__": {}, + } + pre = dict(glbls) + + skip = lifecycle.setup(_StrictCell(refs={"x"}), glbls) # type: ignore[arg-type] + assert skip is not None + assert glbls == pre, "Skip path must not mutate globals" + assert lifecycle._backups == {}, "Skip path must not stash a backup" + + lifecycle.teardown(_StrictCell(refs={"x"}), glbls, skip.result) # type: ignore[arg-type] + assert glbls == pre, "teardown after Skip must be a no-op" + + +def test_execution_lifecycle_protocol_conformance() -> None: + """A Protocol-conforming class without inheriting works as a + lifecycle.""" + log: list[str] = [] + + class _MyLifecycle: + name = "mine" + + def setup(self, cell: Any, glbls: dict[str, Any]) -> Skip | None: + log.append("setup") + return None + + def teardown( + self, cell: Any, glbls: dict[str, Any], run_result: Any + ) -> None: + log.append("teardown") + + # Static type check via assignment to a ExecutionLifecycle-typed + # variable. If the Protocol is misshaped, mypy/pyright complains + # here, not at runtime. + lifecycle: ExecutionLifecycle = _MyLifecycle() + assert lifecycle.name == "mine" + + +# --- Async cancellation ----------------------------------------------------- + + +def _async_body(src: str) -> Any: + """Compile `src` with top-level-await support; returns a code object + whose `co_flags` carry `CO_COROUTINE` so `_is_coroutine` is True.""" + import ast + + return compile(src, "", "exec", flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT) + + +async def test_executor_async_cancellation_propagates_unwrapped() -> None: + """`asyncio.CancelledError` must propagate unwrapped through + `DefaultExecutor.execute_cell_async` — wrapping it as + `MarimoRuntimeException` would mask the cancellation.""" + + class _AsyncCell: + cell_id = "0" + body = _async_body("import asyncio\nawait asyncio.sleep(100)") + last_expr = compile("None", "", "eval") + + def is_coroutine(self) -> bool: + return True + + task = asyncio.create_task( + DefaultExecutor().execute_cell_async(_AsyncCell(), {}) # type: ignore[arg-type] + ) + # Yield so the task enters the awaited sleep before we cancel. + await asyncio.sleep(0) + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + +async def test_start_task_cancel_all_propagates() -> None: + """`cancel_all` schedules cancellation via `call_soon_threadsafe` so a + loop blocked in `select()` wakes immediately; a plain `Future.cancel` + leaves the loop sleeping until the task's next scheduled wakeup.""" + from marimo._runtime.runner.scheduler import SequentialScheduler + + sched = SequentialScheduler( + cells_to_run=[], + graph=None, # type: ignore[arg-type] + ) + + async def slow() -> RunResult: + await asyncio.sleep(60) + return RunResult(output=None, exception=None) + + async with sched.start_task("c0", slow()) as task: # type: ignore[arg-type] + await asyncio.sleep(0) + assert sched.has_active_tasks() + sched.cancel_all() + with pytest.raises(asyncio.CancelledError): + await task + + assert sched.interrupted is True + + +async def test_start_task_cancels_when_interrupted_pre_entry() -> None: + """`start_task` must refuse to admit a new task once `cancel_all` has + fired — otherwise a SIGINT racing in just before the task is + registered could leave the freshly-created task running detached.""" + from marimo._runtime.runner.scheduler import SequentialScheduler + + sched = SequentialScheduler( + cells_to_run=[], + graph=None, # type: ignore[arg-type] + ) + sched.cancel_all() # flips _interrupted + + async def body() -> RunResult: + await asyncio.sleep(60) + return RunResult(output=None, exception=None) + + coro = body() + with pytest.raises(asyncio.CancelledError): + async with sched.start_task("c0", coro): # type: ignore[arg-type] + pass + # `coro` was closed before becoming a task; nothing to leak. + assert not sched.has_active_tasks() + + +async def test_runner_evaluate_interruptible_routes_async_cells_to_scheduler() -> ( + None +): + """`Runner.evaluate_interruptible` must funnel coroutine cells + through `scheduler.start_task` so the SIGINT-handler's `cancel_all` + can preempt them.""" + + class _StubScheduler: + def __init__(self) -> None: + self.started: list[tuple[str, Any]] = [] + + @asynccontextmanager + async def start_task( + self, cell_id: str, coro: Any + ) -> AsyncIterator[asyncio.Task[Any]]: + self.started.append((cell_id, coro)) + task = asyncio.ensure_future(coro) + try: + yield task + finally: + if not task.done(): + task.cancel() + + class _AsyncCell: + cell_id = "c0" + body = _async_body("x = 1") + last_expr = compile("None", "", "eval") + + def is_coroutine(self) -> bool: + return True + + class _RunnerStub: + def __init__(self) -> None: + self.glbls: dict[str, Any] = {} + self._scheduler = _StubScheduler() + self._evaluator = Evaluator( + executor=DefaultExecutor(), lifecycles=[] + ) + + evaluate_interruptible = ( + Runner.evaluate_interruptible # type: ignore[attr-defined] + ) + + runner = _RunnerStub() + result = await runner.evaluate_interruptible(_AsyncCell()) # type: ignore[arg-type] + assert result.exception is None + assert runner._scheduler.started, ( + "async cell must be routed through scheduler.start_task" + ) + + +async def test_runner_evaluate_interruptible_surfaces_cancelled_as_run_result() -> ( + None +): + """When `start_task` refuses to admit a coroutine cell because + `cancel_all` already fired, the resulting `CancelledError` must come + back as `RunResult(exception=CancelledError)`. The broad-except path + in `Runner.run` would otherwise log an internal error and emit an + empty success-like result, masking the interrupt.""" + from marimo._runtime.runner.scheduler import SequentialScheduler + + sched = SequentialScheduler( + cells_to_run=[], + graph=None, # type: ignore[arg-type] + ) + sched.cancel_all() # pre-admit refusal path + + class _AsyncCell: + cell_id = "c0" + body = _async_body("x = 1") + last_expr = compile("None", "", "eval") + + def is_coroutine(self) -> bool: + return True + + class _RunnerStub: + def __init__(self) -> None: + self.glbls: dict[str, Any] = {} + self._scheduler = sched + self._evaluator = Evaluator( + executor=DefaultExecutor(), lifecycles=[] + ) + + evaluate_interruptible = ( + Runner.evaluate_interruptible # type: ignore[attr-defined] + ) + + runner = _RunnerStub() + result = await runner.evaluate_interruptible(_AsyncCell()) # type: ignore[arg-type] + assert isinstance(result.exception, asyncio.CancelledError) + + +async def test_scheduler_async_context_publishes_on_kernel_context() -> None: + """`async with scheduler` sets `_active_scheduler` on entry and + clears it on exit so the SIGINT handler can find the scheduler.""" + from unittest.mock import MagicMock + + from marimo._runtime.context.kernel_context import ( + KernelRuntimeContext, + ) + from marimo._runtime.context.types import _THREAD_LOCAL_CONTEXT + from marimo._runtime.runner.scheduler import SequentialScheduler + + sched = SequentialScheduler( + cells_to_run=[], + graph=None, # type: ignore[arg-type] + ) + + # `spec=KernelRuntimeContext` makes `isinstance` accept the mock. + ctx = MagicMock(spec=KernelRuntimeContext) + ctx._active_scheduler = None + prior = _THREAD_LOCAL_CONTEXT.runtime_context + _THREAD_LOCAL_CONTEXT.runtime_context = ctx + try: + async with sched: + assert ctx._active_scheduler is sched + assert ctx._active_scheduler is None + finally: + _THREAD_LOCAL_CONTEXT.runtime_context = prior diff --git a/tests/_runtime/test_functions.py b/tests/_runtime/test_functions.py index 03cf1b4c2c2..98afe7ab395 100644 --- a/tests/_runtime/test_functions.py +++ b/tests/_runtime/test_functions.py @@ -1,12 +1,23 @@ from __future__ import annotations import dataclasses +import logging +from typing import TYPE_CHECKING +from marimo import _loggers +from marimo._runtime.commands import InvokeFunctionCommand +from marimo._runtime.context import get_context from marimo._runtime.functions import ( Function, FunctionNamespace, FunctionRegistry, ) +from marimo._types.ids import RequestId + +if TYPE_CHECKING: + import pytest + + from marimo._runtime.runtime import Kernel @dataclasses.dataclass @@ -84,3 +95,82 @@ def test_function_registry() -> None: registry.delete(namespace) assert registry.get_function(namespace, "test_function") is None + + +def _invoke(namespace: str, function_name: str) -> InvokeFunctionCommand: + return InvokeFunctionCommand( + function_call_id=RequestId("call"), + namespace=namespace, + function_name=function_name, + args={"value": 1}, + ) + + +async def test_function_call_request_not_found(k: Kernel) -> None: + status, _, found = await k.function_call_request( + _invoke("unregistered-namespace", "missing") + ) + assert found is False + assert status.code == "error" + assert status.title == "Function not found" + + +async def test_function_call_request_found_after_register(k: Kernel) -> None: + namespace = "registered-namespace" + get_context().function_registry.register( + namespace, + Function(name="echo", arg_cls=Args, function=lambda x: x.value), + ) + + _, _, found = await k.function_call_request(_invoke(namespace, "echo")) + assert found is True + + +async def test_function_call_request_not_found_logs( + k: Kernel, caplog: pytest.LogCaptureFixture +) -> None: + logger = _loggers.marimo_logger() + old_propagate = logger.propagate + logger.propagate = True + try: + with caplog.at_level(logging.WARNING): + await k.function_call_request(_invoke("missing-ns", "missing-fn")) + finally: + logger.propagate = old_propagate + + records = [ + record + for record in caplog.records + if "Function call not found" in record.getMessage() + ] + assert len(records) == 1 + message = records[0].getMessage() + assert "namespace=missing-ns" in message + assert "function=missing-fn" in message + assert "namespace_registered=False" in message + assert "child_contexts_searched=0" in message + + +async def test_function_call_request_found_does_not_log( + k: Kernel, caplog: pytest.LogCaptureFixture +) -> None: + namespace = "registered-namespace" + get_context().function_registry.register( + namespace, + Function(name="echo", arg_cls=Args, function=lambda x: x.value), + ) + + logger = _loggers.marimo_logger() + old_propagate = logger.propagate + logger.propagate = True + try: + with caplog.at_level(logging.WARNING): + await k.function_call_request(_invoke(namespace, "echo")) + finally: + logger.propagate = old_propagate + + assert not [ + record + for record in caplog.records + if "Function call not found" in record.getMessage() + ] diff --git a/tests/_runtime/test_interrupt_handlers.py b/tests/_runtime/test_interrupt_handlers.py new file mode 100644 index 00000000000..6e4bfee3e75 --- /dev/null +++ b/tests/_runtime/test_interrupt_handlers.py @@ -0,0 +1,187 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import signal +from unittest.mock import MagicMock, patch + +import pytest + +from marimo._dependencies.dependencies import DependencyManager +from marimo._runtime.context.kernel_context import KernelRuntimeContext +from marimo._runtime.context.types import ExecutionContext +from marimo._runtime.handlers import construct_interrupt_handler +from marimo._runtime.runtime import MarimoInterrupt + +HAS_DUCKDB = DependencyManager.duckdb.has() + + +def _kernel_context_mock(exec_ctx: ExecutionContext) -> MagicMock: + """Mock that satisfies the handler's `isinstance(KernelRuntimeContext)` + check; `active_scheduler=None` selects the sync raise path.""" + ctx = MagicMock(spec=KernelRuntimeContext) + ctx.execution_context = exec_ctx + ctx.active_scheduler = None + return ctx + + +@pytest.mark.skipif(not HAS_DUCKDB, reason="DuckDB not installed") +def test_duckdb_interrupt_handler_called_when_connection_present(): + """Test that duckdb.interrupt() is called when a connection is present.""" + import duckdb + + # Create a mock connection that we can spy on + mock_conn = MagicMock(spec=duckdb.DuckDBPyConnection) + + # Create an execution context with a duckdb connection + exec_ctx = ExecutionContext(cell_id="cell_id", setting_element_value=False) + + with patch( + "marimo._runtime.handlers.safe_get_context" + ) as mock_safe_get_context: + mock_safe_get_context.return_value = _kernel_context_mock(exec_ctx) + + with exec_ctx.with_connection(mock_conn): + interrupt_handler = construct_interrupt_handler() + + # Trigger the interrupt handler + with pytest.raises(MarimoInterrupt): + interrupt_handler(signal.SIGINT, None) + + # Verify duckdb connection's interrupt was called + mock_conn.interrupt.assert_called_once() + + +@pytest.mark.skipif(not HAS_DUCKDB, reason="DuckDB not installed") +def test_duckdb_interrupt_handler_no_error_when_connection_none(): + """Test that no error occurs when connection is None.""" + # Create an execution context without a connection + exec_ctx = ExecutionContext(cell_id="cell_id", setting_element_value=False) + exec_ctx.duckdb_connection = None + + # Mock the context to return our execution context + with patch( + "marimo._runtime.handlers.safe_get_context" + ) as mock_safe_get_context: + mock_context = _kernel_context_mock(exec_ctx) + mock_safe_get_context.return_value = mock_context + + interrupt_handler = construct_interrupt_handler() + + # Should not raise error from duckdb interrupt (only MarimoInterrupt) + with pytest.raises(MarimoInterrupt): + interrupt_handler(signal.SIGINT, None) + + +@pytest.mark.skipif(not HAS_DUCKDB, reason="DuckDB not installed") +def test_duckdb_interrupt_handler_exception_handling(): + """Test that exceptions during interrupt() don't crash the kernel.""" + import duckdb + + # Create a mock connection that raises an exception + mock_conn = MagicMock(spec=duckdb.DuckDBPyConnection) + mock_conn.interrupt.side_effect = RuntimeError("Mock error") + + # Create an execution context with a duckdb connection + exec_ctx = ExecutionContext(cell_id="cell_id", setting_element_value=False) + + # Mock the context to return our execution context + with patch( + "marimo._runtime.handlers.safe_get_context" + ) as mock_safe_get_context: + mock_context = _kernel_context_mock(exec_ctx) + mock_safe_get_context.return_value = mock_context + + # Make interrupt() raise an exception + with exec_ctx.with_connection(mock_conn): + interrupt_handler = construct_interrupt_handler() + + # Should raise MarimoInterrupt, not RuntimeError + # The RuntimeError should be caught and logged + with pytest.raises(MarimoInterrupt): + interrupt_handler(signal.SIGINT, None) + + # Verify interrupt was attempted + mock_conn.interrupt.assert_called_once() + + +def test_sigint_between_cells_cancels_queue_and_raises() -> None: + """SIGINT landing between two cells (scheduler still running its + queue, no cell installed in `execution_context`) must halt the + queue and raise `MarimoInterrupt`. Regression for the P2 where the + handler returned early on `execution_context is None` before + consulting `active_scheduler`.""" + sched = MagicMock() + sched.has_active_tasks.return_value = False + + ctx = MagicMock(spec=KernelRuntimeContext) + ctx.execution_context = None + ctx.active_scheduler = sched + + with patch("marimo._runtime.handlers.safe_get_context", return_value=ctx): + interrupt_handler = construct_interrupt_handler() + with pytest.raises(MarimoInterrupt): + interrupt_handler(signal.SIGINT, None) + + sched.cancel_all.assert_called_once() + + +def test_sigint_with_active_async_task_cancels_without_raising() -> None: + """When an async cell is in flight (scheduler reports active tasks), + the handler must call `cancel_all` and return — raising from a + signal handler would escape into asyncio internals and surface as + an internal-error empty RunResult.""" + sched = MagicMock() + sched.has_active_tasks.return_value = True + + ctx = MagicMock(spec=KernelRuntimeContext) + ctx.execution_context = None + ctx.active_scheduler = sched + + with patch("marimo._runtime.handlers.safe_get_context", return_value=ctx): + interrupt_handler = construct_interrupt_handler() + # No exception raised. + interrupt_handler(signal.SIGINT, None) + + sched.cancel_all.assert_called_once() + + +def test_sigint_with_no_scheduler_and_no_cell_is_noop() -> None: + """No scheduler installed and no cell in flight — the handler must + return silently without raising or calling broadcast.""" + ctx = MagicMock(spec=KernelRuntimeContext) + ctx.execution_context = None + ctx.active_scheduler = None + + with patch("marimo._runtime.handlers.safe_get_context", return_value=ctx): + interrupt_handler = construct_interrupt_handler() + # No exception raised. + interrupt_handler(signal.SIGINT, None) + + +def test_ignore_console_ctrl_c_keeps_interrupt_main_working() -> None: + """`interrupt_main()` (the deliberate interrupt path) must still fire + the SIGINT handler after `ignore_console_ctrl_c()`. Runs in a + subprocess to leave the test runner's console handling untouched.""" + import subprocess + import sys + import textwrap + + script = textwrap.dedent( + """ + import signal, sys, threading, _thread + from marimo._runtime.win32_interrupt_handler import ( + ignore_console_ctrl_c, + ) + + fired = threading.Event() + signal.signal(signal.SIGINT, lambda *args: fired.set()) + ignore_console_ctrl_c() + threading.Timer(0.1, _thread.interrupt_main).start() + fired.wait(5) + sys.exit(0 if fired.is_set() else 1) + """ + ) + completed = subprocess.run( + [sys.executable, "-c", script], timeout=30, capture_output=True + ) + assert completed.returncode == 0, completed.stderr.decode() diff --git a/tests/_runtime/test_kernel_lifecycle.py b/tests/_runtime/test_kernel_lifecycle.py new file mode 100644 index 00000000000..4a1a4082bae --- /dev/null +++ b/tests/_runtime/test_kernel_lifecycle.py @@ -0,0 +1,330 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import asyncio +import queue as _queue +import threading +from typing import TYPE_CHECKING, Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from marimo._runtime.commands import ( + CodeCompletionCommand, + CommandMessage, + ExecuteCellsCommand, + ModelCommand, + SetBreakpointsCommand, + StopKernelCommand, + UpdateUIElementCommand, +) +from marimo._runtime.kernel_lifecycle import ( + asyncio_queue_reader, + collapse_out_of_band, + drain_stale, + listen_messages, + make_control_enqueuer, + threaded_queue_reader, +) +from marimo._types.ids import CellId_t, UIElementId, WidgetModelId + +if TYPE_CHECKING: + from collections.abc import Iterator + + +@pytest.fixture +def kernel() -> Any: + k = MagicMock() + k.handle_message = AsyncMock() + return k + + +@pytest.fixture +def control() -> asyncio.Queue[Any]: + return asyncio.Queue() + + +@pytest.fixture +def ui() -> asyncio.Queue[Any]: + return asyncio.Queue() + + +def _execute(cell_id: str = "c1") -> ExecuteCellsCommand: + return ExecuteCellsCommand(cell_ids=[CellId_t(cell_id)], codes=["x = 1"]) + + +def _ui_update( + elem_id: str = "u1", value: Any = None +) -> UpdateUIElementCommand: + return UpdateUIElementCommand( + object_ids=[UIElementId(elem_id)], values=[value] + ) + + +async def test_threaded_queue_reader_offloads_blocking_get( + monkeypatch: pytest.MonkeyPatch, +) -> None: + q: _queue.Queue[CommandMessage] = _queue.Queue() + command = StopKernelCommand() + q.put(command) + + event_loop_thread = threading.current_thread() + reader_thread: threading.Thread | None = None + original_get = q.get + + def tracked_get() -> CommandMessage: + nonlocal reader_thread + reader_thread = threading.current_thread() + return original_get() + + monkeypatch.setattr(q, "get", tracked_get) + + assert await threaded_queue_reader(q) is command + assert reader_thread is not None + assert reader_thread is not event_loop_thread + + +async def test_listen_messages_exits_on_stop_command( + kernel: Any, + control: asyncio.Queue[Any], + ui: asyncio.Queue[Any], +) -> None: + cmd = _execute() + control.put_nowait(cmd) + control.put_nowait(StopKernelCommand()) + # A request enqueued *after* StopKernel must not be dispatched. + control.put_nowait(_execute("after-stop")) + + await listen_messages(kernel, control, ui, asyncio_queue_reader) + + assert kernel.handle_message.await_count == 1 + assert kernel.handle_message.await_args.args == (cmd,) + + +async def test_listen_messages_skips_none_requests( + kernel: Any, + control: asyncio.Queue[Any], + ui: asyncio.Queue[Any], +) -> None: + control.put_nowait(None) + cmd = _execute() + control.put_nowait(cmd) + control.put_nowait(StopKernelCommand()) + + await listen_messages(kernel, control, ui, asyncio_queue_reader) + + assert kernel.handle_message.await_count == 1 + assert kernel.handle_message.await_args.args == (cmd,) + + +async def test_listen_messages_swallows_handle_message_exception_non_ui( + kernel: Any, + control: asyncio.Queue[Any], + ui: asyncio.Queue[Any], +) -> None: + kernel.handle_message.side_effect = [RuntimeError("boom"), None] + control.put_nowait(_execute("first")) + control.put_nowait(_execute("second")) + control.put_nowait(StopKernelCommand()) + + await listen_messages(kernel, control, ui, asyncio_queue_reader) + + # Second dispatch proves the loop survived the first raise. + assert kernel.handle_message.await_count == 2 + + +async def test_listen_messages_swallows_handle_message_exception_ui_branch( + kernel: Any, + control: asyncio.Queue[Any], + ui: asyncio.Queue[Any], +) -> None: + """Regression test: the UI-merge branch used to let `handle_message` + exceptions propagate while the non-UI branch caught them.""" + kernel.handle_message.side_effect = [RuntimeError("boom"), None] + ui_cmd = _ui_update() + control.put_nowait(ui_cmd) + ui.put_nowait(ui_cmd) + control.put_nowait(_execute("after-ui")) + control.put_nowait(StopKernelCommand()) + + await listen_messages(kernel, control, ui, asyncio_queue_reader) + + assert kernel.handle_message.await_count == 2 + + +async def test_listen_messages_exits_when_reader_raises( + kernel: Any, + control: asyncio.Queue[Any], + ui: asyncio.Queue[Any], +) -> None: + async def failing_reader(_queue: Any) -> Any: + raise OSError("queue closed") + + await listen_messages(kernel, control, ui, failing_reader) + + kernel.handle_message.assert_not_called() + + +async def test_listen_messages_survives_interrupted_read( + kernel: Any, + control: asyncio.Queue[Any], + ui: asyncio.Queue[Any], +) -> None: + """A SIGINT-aborted queue read (EINTR) must not stop the control + loop.""" + cmd = _execute() + reads: Iterator[CommandMessage | BaseException] = iter( + [ + InterruptedError(4, "Interrupted function call"), + cmd, + StopKernelCommand(), + ] + ) + + async def interrupted_reader(_queue: object) -> CommandMessage: + result = next(reads) + if isinstance(result, BaseException): + raise result + return result + + await listen_messages(kernel, control, ui, interrupted_reader) + + # The dispatch after the EINTR proves the loop kept reading. + assert kernel.handle_message.await_count == 1 + assert kernel.handle_message.await_args.args == (cmd,) + + +async def test_listen_messages_merges_ui_updates( + kernel: Any, + control: asyncio.Queue[Any], + ui: asyncio.Queue[Any], +) -> None: + """Contiguous UI updates against the same element collapse to one dispatch.""" + first = _ui_update("u", 1) + second = _ui_update("u", 2) + # Enqueue both on control + ui (matches what _enqueue_control_request does). + control.put_nowait(first) + ui.put_nowait(first) + control.put_nowait(second) + ui.put_nowait(second) + control.put_nowait(StopKernelCommand()) + + await listen_messages(kernel, control, ui, asyncio_queue_reader) + + # Both UI updates merge into a single batched dispatch. + assert kernel.handle_message.await_count == 1 + dispatched = kernel.handle_message.await_args.args[0] + assert isinstance(dispatched, UpdateUIElementCommand) + assert dispatched.values == [2] + + +@pytest.mark.parametrize( + "queue_factory", + [asyncio.Queue, _queue.Queue], + ids=["asyncio", "threading"], +) +def test_drain_stale_returns_latest_when_queue_empty( + queue_factory: Any, +) -> None: + q = queue_factory() + latest = _execute("only") + assert drain_stale(q, latest=latest) is latest + + +@pytest.mark.parametrize( + "queue_factory", + [asyncio.Queue, _queue.Queue], + ids=["asyncio", "threading"], +) +def test_drain_stale_returns_newest_pending(queue_factory: Any) -> None: + q = queue_factory() + initial = _execute("initial") + newer = _execute("newer") + newest = _execute("newest") + q.put_nowait(newer) + q.put_nowait(newest) + + assert drain_stale(q, latest=initial) is newest + # Drained: nothing else remains. + assert q.empty() + + +@pytest.mark.parametrize( + "queue_factory", + [asyncio.Queue, _queue.Queue], + ids=["asyncio", "threading"], +) +def test_collapse_out_of_band_returns_first_when_empty( + queue_factory: Any, +) -> None: + q = queue_factory() + first = CodeCompletionCommand(id="r1", document="x.", cell_id="c1") + assert collapse_out_of_band(q, first=first) == [first] + + +@pytest.mark.parametrize( + "queue_factory", + [asyncio.Queue, _queue.Queue], + ids=["asyncio", "threading"], +) +def test_collapse_out_of_band_keeps_latest_per_type( + queue_factory: Any, +) -> None: + q = queue_factory() + completion_old = CodeCompletionCommand( + id="r1", document="x.", cell_id="c1" + ) + breakpoints = SetBreakpointsCommand(breakpoints={"c1": [1]}) + completion_new = CodeCompletionCommand( + id="r2", document="y.", cell_id="c1" + ) + q.put_nowait(breakpoints) + q.put_nowait(completion_new) + + result = collapse_out_of_band(q, first=completion_old) + + # One command per type, latest wins, in first-seen order. + assert result == [completion_new, breakpoints] + assert q.empty() + + +def test_make_control_enqueuer_routes_plain_command_to_control_only() -> None: + control: asyncio.Queue[Any] = asyncio.Queue() + ui: asyncio.Queue[Any] = asyncio.Queue() + enqueue = make_control_enqueuer(control, ui) + + cmd = _execute() + enqueue(cmd) + + assert control.get_nowait() is cmd + assert ui.empty() + + +def test_make_control_enqueuer_mirrors_ui_element_command() -> None: + control: asyncio.Queue[Any] = asyncio.Queue() + ui: asyncio.Queue[Any] = asyncio.Queue() + enqueue = make_control_enqueuer(control, ui) + + cmd = _ui_update("u", 1) + enqueue(cmd) + + assert control.get_nowait() is cmd + assert ui.get_nowait() is cmd + + +def test_make_control_enqueuer_mirrors_model_command() -> None: + from marimo._runtime.commands import ModelUpdateMessage + + control: asyncio.Queue[Any] = asyncio.Queue() + ui: asyncio.Queue[Any] = asyncio.Queue() + enqueue = make_control_enqueuer(control, ui) + + cmd = ModelCommand( + model_id=WidgetModelId("m1"), + message=ModelUpdateMessage(state={"x": 1}, buffer_paths=[]), + buffers=[], + ) + enqueue(cmd) + + assert control.get_nowait() is cmd + assert ui.get_nowait() is cmd diff --git a/tests/_runtime/test_kernel_request_handlers.py b/tests/_runtime/test_kernel_request_handlers.py new file mode 100644 index 00000000000..9dd52da484c --- /dev/null +++ b/tests/_runtime/test_kernel_request_handlers.py @@ -0,0 +1,80 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from marimo._runtime.commands import ModelCommand, ModelUpdateMessage +from marimo._runtime.kernel_request_handlers import KernelRequestHandlers +from marimo._types.ids import WidgetModelId + +if TYPE_CHECKING: + from tests.conftest import MockedKernel + + +class TestReceiveModelMessage: + @pytest.fixture + def model_command(self) -> ModelCommand: + return ModelCommand( + model_id=WidgetModelId("comm-id"), + message=ModelUpdateMessage(state={}, buffer_paths=[]), + buffers=[], + ) + + async def test_empty_state_skips_ui_dispatch( + self, + mocked_kernel: MockedKernel, + model_command: ModelCommand, + ) -> None: + kernel = mocked_kernel.k + handlers = KernelRequestHandlers(kernel) + + with ( + patch( + "marimo._runtime.kernel_request_handlers.WIDGET_COMM_MANAGER" + ) as mock_comm_manager, + patch.object( + kernel, + "set_ui_element_value", + new=AsyncMock(), + ) as mock_set_ui, + ): + mock_comm_manager.receive_comm_message.return_value = ( + "ui-element-id", + {}, + ) + kernel.state_updates = MagicMock() + kernel.state_updates.__bool__ = MagicMock(return_value=False) + + await handlers._handle_receive_model_message(model_command) + + mock_set_ui.assert_not_called() + + async def test_non_empty_state_dispatches( + self, + mocked_kernel: MockedKernel, + model_command: ModelCommand, + ) -> None: + kernel = mocked_kernel.k + handlers = KernelRequestHandlers(kernel) + + with ( + patch( + "marimo._runtime.kernel_request_handlers.WIDGET_COMM_MANAGER" + ) as mock_comm_manager, + patch.object( + kernel, + "set_ui_element_value", + new=AsyncMock(return_value=True), + ) as mock_set_ui, + ): + mock_comm_manager.receive_comm_message.return_value = ( + "ui-element-id", + {"value": 1}, + ) + + await handlers._handle_receive_model_message(model_command) + + mock_set_ui.assert_awaited_once() diff --git a/tests/_runtime/test_manage_script_metadata.py b/tests/_runtime/test_manage_script_metadata.py index 600714887da..3eebba93cf7 100644 --- a/tests/_runtime/test_manage_script_metadata.py +++ b/tests/_runtime/test_manage_script_metadata.py @@ -1,16 +1,31 @@ -# Copyright 2024 Marimo. All rights reserved. +# Copyright 2026 Marimo. All rights reserved. from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, cast +from unittest.mock import AsyncMock, Mock, call, patch import pytest -from marimo._config.config import ( - merge_default_config, -) +from marimo._config.config import merge_default_config from marimo._config.settings import GLOBAL_SETTINGS from marimo._dependencies.dependencies import DependencyManager -from tests.conftest import MockedKernel +from marimo._dependencies.errors import ManyModulesNotFoundError +from marimo._messaging.notification import ( + InstallingPackageAlertNotification, + MissingPackageAlertNotification, +) +from marimo._runtime.commands import ( + CommandMessage, + InstallPackagesCommand, +) +from marimo._runtime.packages.package_managers import create_package_manager +from marimo._runtime.packages.pypi_package_manager import ( + MicropipPackageManager, + PipPackageManager, +) +from marimo._runtime.packages.utils import is_python_isolated +from marimo._runtime.runner import cell_runner +from tests.conftest import MockedKernel, mock_pyodide if TYPE_CHECKING: import pathlib @@ -19,6 +34,10 @@ @pytest.mark.skipif(not HAS_UV, reason="uv not installed") +@patch( + "marimo._runtime.packages.pypi_package_manager.UvPackageManager.is_in_uv_project", + new=property(lambda _: False), +) async def test_manage_script_metadata_uv( tmp_path: pathlib.Path, mocked_kernel: MockedKernel ) -> None: @@ -40,34 +59,38 @@ async def test_manage_script_metadata_uv( ) ) # Add marimo, skip os - k._maybe_register_cell("0", "import marimo as mo\nimport os") + k._maybe_register_cell("0", "import marimo as mo\nimport os", stale=False) with open(filename) as f: # noqa: ASYNC230 contents = f.read() - assert '"marimo",' in contents + assert '"marimo' in contents assert "markdown" not in contents assert '"os",' not in contents # Add markdown - k._maybe_register_cell("1", "import markdown") + k._maybe_register_cell("1", "import markdown", stale=False) with open(filename) as f: # noqa: ASYNC230 contents = f.read() - assert '"marimo",' in contents + assert '"marimo' in contents assert '"os",' not in contents assert '"markdown==' in contents # Remove marimo, it's still in requirements - k._maybe_register_cell("0", "import os") + k._maybe_register_cell("0", "import os", stale=False) with open(filename) as f: # noqa: ASYNC230 contents = f.read() - assert '"marimo",' in contents + assert '"marimo' in contents assert '"markdown==' in contents assert '"os",' not in contents @pytest.mark.skipif(not HAS_UV, reason="uv not installed") +@patch( + "marimo._runtime.packages.pypi_package_manager.UvPackageManager.is_in_uv_project", + new=property(lambda _: False), +) async def test_manage_script_metadata_uv_deletion( tmp_path: pathlib.Path, mocked_kernel: MockedKernel ) -> None: @@ -90,19 +113,19 @@ async def test_manage_script_metadata_uv_deletion( ) # Add marimo, skip os - k._maybe_register_cell("0", "import marimo as mo\nimport os") + k._maybe_register_cell("0", "import marimo as mo\nimport os", stale=False) with open(filename) as f: # noqa: ASYNC230 contents = f.read() - assert '"marimo",' in contents + assert '"marimo' in contents assert '"os",' not in contents # Add markdown - k._maybe_register_cell("1", "import markdown") + k._maybe_register_cell("1", "import markdown", stale=False) with open(filename) as f: # noqa: ASYNC230 contents = f.read() - assert '"marimo",' in contents + assert '"marimo' in contents assert '"os",' not in contents assert '"markdown==' in contents @@ -111,7 +134,7 @@ async def test_manage_script_metadata_uv_deletion( with open(filename) as f: # noqa: ASYNC230 contents = f.read() - assert '"marimo",' in contents + assert '"marimo' in contents assert '"markdown==' in contents assert '"os",' not in contents @@ -120,7 +143,7 @@ async def test_manage_script_metadata_uv_deletion( with open(filename) as f: # noqa: ASYNC230 contents = f.read() - assert '"marimo",' in contents + assert '"marimo' in contents assert '"markdown==' in contents assert '"os",' not in contents @@ -148,7 +171,7 @@ async def test_manage_script_metadata_uv_off( ) # Add - k._maybe_register_cell("0", "import marimo as mo\nimport os") + k._maybe_register_cell("0", "import marimo as mo\nimport os", stale=False) with open(filename) as f: # noqa: ASYNC230 assert "" == f.read() @@ -176,7 +199,7 @@ async def test_manage_script_metadata_uv_no_filename( ) # Add - k._maybe_register_cell("0", "import marimo as mo\nimport os") + k._maybe_register_cell("0", "import marimo as mo\nimport os", stale=False) with open(filename) as f: # noqa: ASYNC230 assert "" == f.read() @@ -204,7 +227,545 @@ async def test_manage_script_metadata_pip_noop( ) # Add - k._maybe_register_cell("0", "import marimo as mo\nimport os") + k._maybe_register_cell("0", "import marimo as mo\nimport os", stale=False) with open(filename) as f: # noqa: ASYNC230 assert "" == f.read() + + +@mock_pyodide() +async def test_install_missing_packages_micropip( + mocked_kernel: MockedKernel, +) -> None: + k = mocked_kernel.k + + with patch("micropip.install", new_callable=AsyncMock) as mock_install: + await k.packages_callbacks.install_missing_packages( + InstallPackagesCommand( + manager="micropip", + versions={"barbaz": "", "foobar": ""}, + ) + ) + assert mock_install.call_count == 2 + assert mock_install.call_args_list == [ + call(["barbaz"]), + call(["foobar"]), + ] + + +@mock_pyodide() +async def test_install_missing_packages_micropip_with_versions( + mocked_kernel: MockedKernel, +) -> None: + k = mocked_kernel.k + + with patch("micropip.install", new_callable=AsyncMock) as mock_install: + await k.packages_callbacks.install_missing_packages( + InstallPackagesCommand( + manager="micropip", + versions={"numpy": "1.22.0", "pandas": "1.5.0"}, + ) + ) + assert mock_install.call_count == 2 + assert mock_install.call_args_list == [ + call(["numpy==1.22.0"]), + call(["pandas==1.5.0"]), + ] + + +@mock_pyodide(already_installed=Mock()) +async def test_install_missing_packages_micropip_other_modules( + mocked_kernel: MockedKernel, +) -> None: + k = mocked_kernel.k + + k.module_registry.modules = lambda: set( + {"idk", "done", "already_installed"} + ) + + with patch("micropip.install", new_callable=AsyncMock) as mock_install: + await k.packages_callbacks.install_missing_packages( + InstallPackagesCommand( + manager="micropip", + versions={}, + ) + ) + assert mock_install.call_count == 2 + assert mock_install.call_args_list == [ + call(["done"]), + call(["idk"]), + ] + + +@mock_pyodide() +async def test_missing_packages_hook( + mocked_kernel: MockedKernel, +) -> None: + """Test that missing_packages_hook correctly handles missing packages for micropip""" + k = mocked_kernel.k + control_requests: list[CommandMessage] = [] + broadcast_messages: list[ + InstallingPackageAlertNotification | MissingPackageAlertNotification + ] = [] + + def mock_enqueue(request: CommandMessage) -> None: + control_requests.append(request) + + def mock_broadcast( + msg: InstallingPackageAlertNotification + | MissingPackageAlertNotification, + stream: Any = None, + ) -> None: + del stream + broadcast_messages.append(msg) + + k.enqueue_control_request = mock_enqueue + + # Create a mock runner with ModuleNotFoundError + class MockRunner: + def __init__(self) -> None: + self.exceptions = { + "cell1": ModuleNotFoundError( + "No module named 'numpy'", name="numpy" + ), + # Duplicate + "cell2": ModuleNotFoundError( + "No module named 'numpy'", name="numpy" + ), + # Has mapping + "ibis": ModuleNotFoundError( + "No module named 'ibis'", name="ibis" + ), + "cell3": ManyModulesNotFoundError( + package_names=["grouped-one", "grouped-two"], + msg="Missing one and two", + source="kernel", + ), + } + + def reset_package_manager() -> MicropipPackageManager: + k.packages_callbacks.package_manager = create_package_manager( + "micropip" + ) + package_manager = k.packages_callbacks.package_manager + assert isinstance(package_manager, MicropipPackageManager) + return package_manager + + with ( + patch( + "marimo._runtime.callbacks.packages.broadcast_notification", + mock_broadcast, + ), + patch("micropip.install", new_callable=AsyncMock), + ): + runner = cast(cell_runner.Runner, MockRunner()) + + # Case 1: Auto-install disabled + package_manager = reset_package_manager() + control_requests.clear() + broadcast_messages.clear() + k.packages_callbacks.missing_packages_hook(runner) + + # Should broadcast alert instead of installing + assert len(control_requests) == 0 + assert len(broadcast_messages) == 1 + alert = broadcast_messages[0] + assert isinstance(alert, MissingPackageAlertNotification) + assert alert.packages == [ + "grouped-one", + "grouped-two", + "ibis-framework[duckdb]", + "numpy", + ] + assert alert.isolated == is_python_isolated() + + # Case 2: Multiple missing modules + package_manager = reset_package_manager() + control_requests.clear() + broadcast_messages.clear() + k.module_registry.missing_modules = lambda: { + "ibis-framework[duckdb]", + "pandas", + "scipy", + } # type: ignore + k.packages_callbacks.missing_packages_hook(runner) + + # Should create install request with all missing packages + assert len(control_requests) == 0 + assert len(broadcast_messages) == 1 + alert = broadcast_messages[0] + assert isinstance(alert, MissingPackageAlertNotification) + assert alert.packages == [ + "grouped-one", + "grouped-two", + "ibis-framework[duckdb]", + "numpy", + "pandas", + "scipy", + ] + assert alert.isolated == is_python_isolated() + + +def test_missing_packages_hook_pip( + mocked_kernel: MockedKernel, +) -> None: + """Test that missing_packages_hook correctly handles missing packages for pip""" + k = mocked_kernel.k + control_requests: list[CommandMessage] = [] + broadcast_messages: list[ + InstallingPackageAlertNotification | MissingPackageAlertNotification + ] = [] + + def mock_enqueue(request: CommandMessage) -> None: + control_requests.append(request) + + def mock_broadcast( + msg: InstallingPackageAlertNotification + | MissingPackageAlertNotification, + stream: Any = None, + ) -> None: + del stream + broadcast_messages.append(msg) + + k.enqueue_control_request = mock_enqueue + + # Create a mock runner with ModuleNotFoundError + class MockRunner: + def __init__(self) -> None: + self.exceptions = { + "cell1": ModuleNotFoundError( + "No module named 'numpy'", name="numpy" + ), + # Duplicate + "cell2": ModuleNotFoundError( + "No module named 'numpy'", name="numpy" + ), + # Has mapping + "ibis": ModuleNotFoundError( + "No module named 'ibis'", name="ibis" + ), + } + + with ( + patch( + "marimo._runtime.callbacks.packages.broadcast_notification", + mock_broadcast, + ), + ): + k.packages_callbacks.package_manager = create_package_manager("pip") + package_manager = k.packages_callbacks.package_manager + assert isinstance(package_manager, PipPackageManager) + package_manager.install = AsyncMock() + runner = cast(cell_runner.Runner, MockRunner()) + + # Case 1: Missing modules with auto-install disabled + k.module_registry.missing_modules = lambda: {"numpy", "pandas"} # type: ignore + package_manager.should_auto_install = lambda: False # type: ignore + k.packages_callbacks.missing_packages_hook(runner) + + # Should broadcast alert instead of installing + assert len(control_requests) == 0 + assert len(broadcast_messages) == 1 + alert = broadcast_messages[0] + assert isinstance(alert, MissingPackageAlertNotification) + assert alert.packages == ["ibis-framework[duckdb]", "numpy", "pandas"] + assert alert.isolated == is_python_isolated() + + # Case 2: Multiple missing modules with auto-install enabled + control_requests.clear() + broadcast_messages.clear() + k.module_registry.missing_modules = lambda: { + "ibis-framework[duckdb]", + "numpy", + "pandas", + "scipy", + } # type: ignore + package_manager.should_auto_install = lambda: True # type: ignore + k.packages_callbacks.missing_packages_hook(runner) + + # Should create install request with all missing packages + assert len(control_requests) == 1 + request = control_requests[0] + assert isinstance(request, InstallPackagesCommand) + assert request.manager == "pip" + assert request.versions == { + "ibis-framework[duckdb]": "", + "numpy": "", + "pandas": "", + "scipy": "", + } + + +async def test_install_missing_packages_with_streaming_logs( + mocked_kernel: MockedKernel, +) -> None: + """Test that install_missing_packages uses streaming logs functionality.""" + k = mocked_kernel.k + broadcast_messages: list[InstallingPackageAlertNotification] = [] + + def mock_broadcast(msg, stream=None): + """Mock the broadcast_notification function to capture alerts""" + del stream + if isinstance(msg, InstallingPackageAlertNotification): + broadcast_messages.append(msg) + + # Mock package manager + mock_package_manager = Mock(spec=PipPackageManager) + mock_package_manager.name = "pip" + mock_package_manager.is_manager_installed.return_value = True + mock_package_manager.attempted_to_install.return_value = False + mock_package_manager.package_to_module.return_value = "test_module" + + # Mock successful installation with log callback + async def mock_install(pkg: str, version=None, log_callback=None): + del pkg, version + if log_callback: + log_callback("Starting installation...\n") + log_callback("Downloading package...\n") + log_callback("Installing package...\n") + log_callback("Installation complete!\n") + return True + + mock_package_manager.install = AsyncMock(side_effect=mock_install) + + # Set up packages callbacks + k.packages_callbacks.package_manager = mock_package_manager + + with ( + patch( + "marimo._runtime.callbacks.packages.broadcast_notification", + mock_broadcast, + ), + ): + # Create install request + request = InstallPackagesCommand(manager="pip", versions={"numpy": ""}) + + await k.packages_callbacks.install_missing_packages(request) + + # Verify broadcast messages + assert ( + len(broadcast_messages) >= 5 + ) # Initial + start + done + status updates + + # Check that streaming logs were sent + streaming_alerts = [ + msg for msg in broadcast_messages if msg.logs is not None + ] + assert len(streaming_alerts) >= 2 # At least start and done + + # Verify start log + start_alerts = [ + msg for msg in streaming_alerts if msg.log_status == "start" + ] + assert len(start_alerts) == 1 + assert "numpy" in start_alerts[0].logs + assert "Installing numpy" in start_alerts[0].logs["numpy"] + + # Verify done log + done_alerts = [ + msg for msg in streaming_alerts if msg.log_status == "done" + ] + assert len(done_alerts) == 1 + assert "numpy" in done_alerts[0].logs + assert "Successfully installed numpy" in done_alerts[0].logs["numpy"] + + # Verify package manager was called with log callback + mock_package_manager.install.assert_called_once() + call_args = mock_package_manager.install.call_args + assert call_args.kwargs.get("log_callback") is not None + + +async def test_install_missing_packages_streaming_logs_failure( + mocked_kernel: MockedKernel, +) -> None: + """Test streaming logs when package installation fails.""" + k = mocked_kernel.k + broadcast_messages: list[InstallingPackageAlertNotification] = [] + + def mock_broadcast(msg, stream=None): + del stream + if isinstance(msg, InstallingPackageAlertNotification): + broadcast_messages.append(msg) + + # Mock package manager + mock_package_manager = Mock(spec=PipPackageManager) + mock_package_manager.name = "pip" + mock_package_manager.is_manager_installed.return_value = True + mock_package_manager.attempted_to_install.return_value = False + mock_package_manager.package_to_module.return_value = "test_module" + + # Mock failed installation with log callback + async def mock_install_fail(pkg: str, version=None, log_callback=None): + del pkg, version + if log_callback: + log_callback("Starting installation...\n") + log_callback("Error: Package not found\n") + return False # Installation failed + + mock_package_manager.install = AsyncMock(side_effect=mock_install_fail) + k.packages_callbacks.package_manager = mock_package_manager + + with ( + patch( + "marimo._runtime.callbacks.packages.broadcast_notification", + mock_broadcast, + ), + ): + request = InstallPackagesCommand( + manager="pip", versions={"nonexistent-package": ""} + ) + + await k.packages_callbacks.install_missing_packages(request) + + # Verify failure logs were sent + streaming_alerts = [ + msg for msg in broadcast_messages if msg.logs is not None + ] + assert len(streaming_alerts) >= 2 + + # Verify done log with failure message + done_alerts = [ + msg for msg in streaming_alerts if msg.log_status == "done" + ] + assert len(done_alerts) == 1 + assert "nonexistent-package" in done_alerts[0].logs + assert ( + "Failed to install" in done_alerts[0].logs["nonexistent-package"] + ) + + +async def test_install_missing_packages_streaming_logs_multiple_packages( + mocked_kernel: MockedKernel, +) -> None: + """Test streaming logs for multiple packages.""" + k = mocked_kernel.k + broadcast_messages: list[InstallingPackageAlertNotification] = [] + + def mock_broadcast(msg, stream=None): + del stream + if isinstance(msg, InstallingPackageAlertNotification): + broadcast_messages.append(msg) + + # Mock package manager + mock_package_manager = Mock(spec=PipPackageManager) + mock_package_manager.name = "pip" + mock_package_manager.is_manager_installed.return_value = True + mock_package_manager.attempted_to_install.return_value = False + mock_package_manager.package_to_module.side_effect = lambda pkg: ( + pkg.replace("-", "_") + ) + + # Track which packages are being installed + installation_calls = [] + + async def mock_install(pkg: str, version=None, log_callback=None): + del version + installation_calls.append(pkg) + if log_callback: + log_callback(f"Installing {pkg}...\n") + log_callback(f"Successfully installed {pkg}!\n") + return True + + mock_package_manager.install = AsyncMock(side_effect=mock_install) + k.packages_callbacks.package_manager = mock_package_manager + + with ( + patch( + "marimo._runtime.callbacks.packages.broadcast_notification", + mock_broadcast, + ), + ): + request = InstallPackagesCommand( + manager="pip", versions={"numpy": "", "pandas": "", "scipy": ""} + ) + + await k.packages_callbacks.install_missing_packages(request) + + # Verify all packages were processed + assert len(installation_calls) == 3 + assert set(installation_calls) == {"numpy", "pandas", "scipy"} + + # Verify streaming logs for each package + streaming_alerts = [ + msg for msg in broadcast_messages if msg.logs is not None + ] + + # Should have start and done logs for each package + start_alerts = [ + msg for msg in streaming_alerts if msg.log_status == "start" + ] + done_alerts = [ + msg for msg in streaming_alerts if msg.log_status == "done" + ] + + assert len(start_alerts) == 3 + assert len(done_alerts) == 3 + + # Verify each package has its own logs + packages_in_start_logs = set() + for alert in start_alerts: + packages_in_start_logs.update(alert.logs.keys()) + + packages_in_done_logs = set() + for alert in done_alerts: + packages_in_done_logs.update(alert.logs.keys()) + + assert packages_in_start_logs == {"numpy", "pandas", "scipy"} + assert packages_in_done_logs == {"numpy", "pandas", "scipy"} + + +async def test_install_missing_packages_no_logs_backward_compatibility( + mocked_kernel: MockedKernel, +) -> None: + """Test that package installation still works without streaming logs (backward compatibility).""" + k = mocked_kernel.k + broadcast_messages: list[InstallingPackageAlertNotification] = [] + + def mock_broadcast(msg, stream=None): + del stream + if isinstance(msg, InstallingPackageAlertNotification): + broadcast_messages.append(msg) + + # Mock package manager that doesn't use log callbacks + mock_package_manager = Mock(spec=PipPackageManager) + mock_package_manager.name = "pip" + mock_package_manager.is_manager_installed.return_value = True + mock_package_manager.attempted_to_install.return_value = False + mock_package_manager.package_to_module.return_value = "test_module" + + # Mock installation without using log callback parameter + async def mock_install_old_style(pkg: str, version=None, **kwargs: Any): + del version, kwargs, pkg + # Ignore log_callback if provided (simulating old package managers) + return True + + mock_package_manager.install = AsyncMock( + side_effect=mock_install_old_style + ) + k.packages_callbacks.package_manager = mock_package_manager + + with ( + patch( + "marimo._runtime.callbacks.packages.broadcast_notification", + mock_broadcast, + ), + ): + request = InstallPackagesCommand( + manager="pip", versions={"requests": ""} + ) + + await k.packages_callbacks.install_missing_packages(request) + + # Should still work and send basic status updates + status_alerts = [msg for msg in broadcast_messages if msg.logs is None] + assert len(status_alerts) >= 2 # At least installing and installed + + # Verify normal package status progression + package_statuses = [] + for alert in status_alerts: + if "requests" in alert.packages: + package_statuses.append(alert.packages["requests"]) + + # Should have at least installing and installed statuses + assert "installed" in package_statuses + # Note: The exact sequence might vary, but we should have final success diff --git a/tests/_runtime/test_parent_poller.py b/tests/_runtime/test_parent_poller.py new file mode 100644 index 00000000000..ce1aaa038e9 --- /dev/null +++ b/tests/_runtime/test_parent_poller.py @@ -0,0 +1,64 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import os +from unittest import mock + +import pytest + +from marimo._runtime.parent_poller import ParentPollerUnix + + +@pytest.mark.skipif(os.name == "nt", reason="only works on posix") +def test_parent_poller_unix_reparent_to_pid1(): + poller = ParentPollerUnix(parent_pid=221) + + with ( + mock.patch("os.getppid", return_value=1), + mock.patch("os.killpg"), + mock.patch("os._exit", side_effect=SystemExit(1)), + pytest.raises(SystemExit), + ): + poller.run() + + +@pytest.mark.skipif(os.name == "nt", reason="only works on posix") +def test_parent_poller_unix_reparent_not_pid1(): + parent_pid = 221 + poller = ParentPollerUnix(parent_pid=parent_pid) + + with ( + mock.patch("os.getppid", side_effect=[parent_pid, parent_pid - 1]), + mock.patch("os.killpg"), + mock.patch("os._exit", side_effect=SystemExit(1)), + pytest.raises(SystemExit), + ): + poller.run() + + +@pytest.mark.skipif(os.name == "nt", reason="only works on posix") +def test_parent_poller_unix_propagates_getppid_error(): + poller = ParentPollerUnix(parent_pid=221) + + with ( + mock.patch("os.getppid", side_effect=ValueError("boom")), + pytest.raises(ValueError, match="boom"), + ): + poller.run() + + +@pytest.mark.skipif(os.name == "nt", reason="only works on posix") +def test_parent_poller_unix_ancestor_gone(): + # Direct parent alive, but ancestor is gone: ppid-based check can't + # detect this, so the ancestor_pid probe must trigger shutdown. + ppid = os.getppid() + poller = ParentPollerUnix(parent_pid=ppid, ancestor_pid=4242) + + with ( + mock.patch("os.getppid", return_value=ppid), + mock.patch("os.kill", side_effect=ProcessLookupError), + mock.patch("os.killpg"), + mock.patch("os._exit", side_effect=SystemExit(1)), + pytest.raises(SystemExit), + ): + poller.run() diff --git a/tests/_runtime/test_patches.py b/tests/_runtime/test_patches.py index c2ac7f53848..6c2cb56b8f7 100644 --- a/tests/_runtime/test_patches.py +++ b/tests/_runtime/test_patches.py @@ -1,10 +1,24 @@ -# Copyright 2024 Marimo. All rights reserved. +# Copyright 2026 Marimo. All rights reserved. from __future__ import annotations +import io +from contextlib import nullcontext +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from marimo._runtime._wasm._patches import WasmPatchSet +from marimo._runtime._wasm._polars import patch_polars_for_wasm from marimo._runtime.capture import capture_stderr from marimo._runtime.runtime import Kernel from marimo._utils.platform import is_pyodide -from tests.conftest import ExecReqProvider +from tests._messaging.mocks import MockStream +from tests.conftest import ExecReqProvider, mock_pyodide + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path class TestMicropip: @@ -19,6 +33,21 @@ async def test_micropip_available( await executing_kernel.run([exec_req.get("import micropip")]) assert "micropip" in executing_kernel.globals + @staticmethod + async def test_micropip_once( + executing_kernel: Kernel, exec_req: ExecReqProvider + ) -> None: + await executing_kernel.run([exec_req.get("import sys")]) + assert ( + executing_kernel.globals["sys"].meta_path[-1].__class__.__name__ + == "_MicropipFinder" + ) + # Double patched at this point, barring explicit fix. + assert ( + executing_kernel.globals["sys"].meta_path[-2].__class__.__name__ + != "_MicropipFinder" + ) + @staticmethod async def test_micropip_install( executing_kernel: Kernel, exec_req: ExecReqProvider @@ -118,3 +147,366 @@ async def test_micropip_set_index_urls( ] ) TestMicropip._assert_micropip_warning_printed(buf.getvalue()) + + +async def test_webbrowser_injection( + mocked_kernel: Kernel, exec_req: ExecReqProvider +): + await mocked_kernel.k.run( + [ + exec_req.get(""" + import webbrowser + MarimoBrowser = __marimo__._runtime.marimo_browser.build_browser_fallback() + webbrowser.register( + "marimo-output", None, MarimoBrowser(), preferred=True + ) + """), + ] + ) + await mocked_kernel.k.run( + [ + cell := exec_req.get("webbrowser.open('https://marimo.io');"), + ] + ) + assert "webbrowser" in mocked_kernel.k.globals + outputs: list[str] = [] + stream = MockStream(mocked_kernel.stream) + for msg in stream.operations: + if msg["op"] == "cell-op" and msg["output"] is not None: + outputs.append(msg["output"]["data"]) + + assert " Callable[..., object]: + """Build a fallback that ignores its arguments and returns `value`.""" + + def _fb(*_args: object, **_kwargs: object) -> object: + return value + + return _fb + + +class TestWasmPatchSet: + @staticmethod + def test_noop_outside_pyodide() -> None: + import types + + mod = types.SimpleNamespace(fn=lambda x: x + 1) + original = mod.fn + patches = WasmPatchSet() + patches.patch(mod, "fn", _const_fallback(999)) + # Outside pyodide, no patch is installed. + assert mod.fn is original + assert mod.fn(1) == 2 + patches.unpatch_all()() + + @staticmethod + @mock_pyodide() + def test_fallback_on_caught_exception() -> None: + import types + + def divide_by_zero(_x: int) -> float: + return 1 / 0 + + mod = types.SimpleNamespace(fn=divide_by_zero) + patches = WasmPatchSet() + patches.patch( + mod, + "fn", + _const_fallback("fallback"), + catch=(ZeroDivisionError,), + ) + assert mod.fn(0) == "fallback" + + unpatch = patches.unpatch_all() + unpatch() + with pytest.raises(ZeroDivisionError): + mod.fn(0) + + @staticmethod + @mock_pyodide() + def test_fallback_failure_chains_original_error() -> None: + import types + + def boom(*_: object, **__: object) -> object: + raise NameError("primary boom") + + def fallback_fail(*_: object, **__: object) -> object: + raise RuntimeError("fallback boom") + + mod = types.SimpleNamespace(fn=boom) + patches = WasmPatchSet() + patches.patch(mod, "fn", fallback_fail) + + with pytest.raises(NameError, match="primary boom") as exc_info: + mod.fn() + assert isinstance(exc_info.value.__cause__, RuntimeError) + patches.unpatch_all()() + + @staticmethod + @mock_pyodide() + def test_skips_missing_attr() -> None: + import types + + mod = types.SimpleNamespace() + patches = WasmPatchSet() + # Should silently skip without raising. + patches.patch(mod, "missing", _const_fallback(None)) + assert not hasattr(mod, "missing") + patches.unpatch_all()() + + +def test_fetch_url_bytes_forwards_request_and_urlopen_kwargs() -> None: + import urllib.request + + from marimo._runtime._wasm._fetch import fetch_url_bytes + + with patch( + "urllib.request.urlopen", + return_value=nullcontext(io.BytesIO(b"ok")), + ) as urlopen: + assert ( + fetch_url_bytes( + "https://example.com/cars.csv", + request_kwargs={ + "headers": {"User-Agent": "marimo"}, + "method": "GET", + }, + urlopen_kwargs={"timeout": 5}, + ) + == b"ok" + ) + + request = urlopen.call_args.args[0] + assert isinstance(request, urllib.request.Request) + assert request.get_header("User-agent") == "marimo" + assert request.get_method() == "GET" + assert urlopen.call_args.kwargs == {"timeout": 5} + + +@pytest.mark.requires("polars", "pyarrow") +class TestPolarsIoWasmPatch: + @staticmethod + def test_noop_outside_pyodide() -> None: + import polars as pl + + original = pl.read_csv + unpatch = patch_polars_for_wasm() + assert pl.read_csv is original + unpatch() + assert pl.read_csv is original + + @staticmethod + @mock_pyodide() + def test_read_csv_falls_back_on_name_error() -> None: + import polars as pl + + csv_bytes = b"a,b\n1,x\n2,y\n" + with patch( + "polars.read_csv", + side_effect=NameError("simulated wasm failure"), + ): + unpatch = patch_polars_for_wasm() + try: + df = pl.read_csv(io.BytesIO(csv_bytes)) + assert df.shape == (2, 2) + assert df.columns == ["a", "b"] + assert df["b"].to_list() == ["x", "y"] + finally: + unpatch() + + @staticmethod + @mock_pyodide() + def test_read_csv_falls_back_on_generic_exception() -> None: + import polars as pl + + csv_bytes = b"a,b\n1,x\n" + with patch( + "polars.read_csv", + side_effect=RuntimeError("network unavailable"), + ): + unpatch = patch_polars_for_wasm() + try: + df = pl.read_csv(io.BytesIO(csv_bytes)) + assert df.shape == (1, 2) + finally: + unpatch() + + @staticmethod + @mock_pyodide() + def test_scan_csv_returns_lazyframe() -> None: + import polars as pl + + csv_bytes = b"a,b\n1,x\n2,y\n" + with patch( + "polars.scan_csv", + side_effect=NameError("simulated wasm failure"), + ): + unpatch = patch_polars_for_wasm() + try: + lf = pl.scan_csv(io.BytesIO(csv_bytes)) + assert isinstance(lf, pl.LazyFrame) + assert lf.collect().shape == (2, 2) + finally: + unpatch() + + @staticmethod + @mock_pyodide() + def test_read_parquet_falls_back(tmp_path: Path) -> None: + import polars as pl + + df_in = pl.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]}) + path = tmp_path / "test.parquet" + df_in.write_parquet(path) + parquet_bytes = path.read_bytes() + + with patch( + "polars.read_parquet", + side_effect=NameError("simulated wasm failure"), + ): + unpatch = patch_polars_for_wasm() + try: + df = pl.read_parquet(io.BytesIO(parquet_bytes)) + assert df.shape == (3, 2) + assert df["a"].to_list() == [1, 2, 3] + finally: + unpatch() + + @staticmethod + @mock_pyodide() + def test_unpatch_restores_original() -> None: + import polars as pl + + unpatch = patch_polars_for_wasm() + try: + assert pl.read_csv is not None + patched = pl.read_csv + unpatch() + assert pl.read_csv is not patched + finally: + # idempotent — calling again should be safe + unpatch() + + @staticmethod + @mock_pyodide() + def test_unpatch_is_idempotent() -> None: + unpatch = patch_polars_for_wasm() + unpatch() + unpatch() # second call must not raise + + @staticmethod + @mock_pyodide() + def test_fallback_propagates_original_error_when_fallback_fails() -> None: + import polars as pl + + with patch( + "polars.read_csv", + side_effect=NameError("simulated wasm failure"), + ): + unpatch = patch_polars_for_wasm() + try: + # Pass an unsupported source type so the fallback raises. + with pytest.raises(NameError, match="simulated wasm failure"): + pl.read_csv(12345) # type: ignore[arg-type] + finally: + unpatch() + + @staticmethod + @mock_pyodide() + def test_missing_pyarrow_bubbles_module_not_found_error() -> None: + """ModuleNotFoundError must propagate so marimo can prompt to install.""" + import polars as pl + + from marimo._dependencies.dependencies import DependencyManager + + with ( + patch( + "polars.read_csv", + side_effect=NameError("simulated wasm failure"), + ), + patch.object(DependencyManager.pyarrow, "has", return_value=False), + ): + unpatch = patch_polars_for_wasm() + try: + with pytest.raises(ModuleNotFoundError) as exc_info: + pl.read_csv(io.BytesIO(b"a,b\n1,x\n")) + assert exc_info.value.name == "pyarrow" + finally: + unpatch() diff --git a/tests/_runtime/test_primitives.py b/tests/_runtime/test_primitives.py new file mode 100644 index 00000000000..bd41239a879 --- /dev/null +++ b/tests/_runtime/test_primitives.py @@ -0,0 +1,151 @@ +"""Tests for marimo._runtime.primitives module.""" + +import functools +from typing import Any + +import pytest + +from marimo._dependencies.dependencies import DependencyManager +from marimo._runtime.primitives import is_data_primitive, is_pure_function + +HAS_NUMPY = DependencyManager.numpy.has() + + +class TestDataPrimitiveClassification: + @pytest.mark.skipif(not HAS_NUMPY, reason="numpy is required") + def test_rejects_class_like_array_protocol(self) -> None: + """Issue #9563: class-like __array__ refs are not data primitives.""" + import numpy as np + + class _ClassLikeArray: + @classmethod + def __array__(cls, dtype: Any = None) -> Any: + return np.array([object()], dtype=object) + + assert is_data_primitive(_ClassLikeArray) is False + + +class TestWrappedFunctionHandling: + """Test handling of wrapped functions (decorators) in is_pure_function.""" + + def test_wrapped_function_follows_wrapped_object(self): + """Test that is_pure_function follows __wrapped__ attribute to check the underlying function.""" + + def external_function(): + """A function from external module.""" + return 42 + + external_function.__module__ = "external_module" + + # Create a decorator that wraps the function + def decorator(func): + @functools.wraps(func) + def wrapper(): + return func() + + return wrapper + + decorated_function = decorator(external_function) + + # Mock globals dict + defs = {"decorated_function": decorated_function} + cache = {} + + # Should follow the wrapped function and determine purity based on that + result = is_pure_function( + "decorated_function", decorated_function, defs, cache + ) + + # Should be True since the wrapped function is external + assert result is True + + def test_nested_wrapped_functions(self): + """Test handling of functions with multiple layers of wrapping.""" + + def original_function(): + return "original" + + original_function.__module__ = "external_module" + + def decorator1(func: Any) -> Any: + @functools.wraps(func) + def wrapper1(*args: Any, **kwargs: Any): + return func(*args, **kwargs) + + return wrapper1 + + def decorator2(func: Any) -> Any: + @functools.wraps(func) + def wrapper2(*args: Any, **kwargs: Any): + return func(*args, **kwargs) + + return wrapper2 + + # Apply multiple decorators + @decorator2 + @decorator1 + def nested_decorated(): + return original_function() + + defs = {"nested_decorated": nested_decorated} + cache = {} + + # Should handle nested wrapping correctly + result = is_pure_function( + "nested_decorated", nested_decorated, defs, cache + ) + assert isinstance(result, bool) + + def test_wrapped_attribute_is_none(self): + """Test handling when __wrapped__ exists but is None.""" + + def function_with_none_wrapped(): + return 42 + + function_with_none_wrapped.__module__ = "external_module" + + # Set __wrapped__ to None + function_with_none_wrapped.__wrapped__ = None + + defs = {"function_with_none_wrapped": function_with_none_wrapped} + cache = {} + + # Should handle None __wrapped__ gracefully + result = is_pure_function( + "function_with_none_wrapped", + function_with_none_wrapped, + defs, + cache, + ) + assert result is True # External function should be pure + + def test_main_module_wrapped_function(self): + """Test wrapped function from __main__ module.""" + + def internal_function(): + return 42 + + internal_function.__module__ = "__main__" + + def decorator(func): + @functools.wraps(func) + def wrapper(): + return func() + + return wrapper + + decorated_function = decorator(internal_function) + + defs = { + "decorated_function": decorated_function, + "internal_function": internal_function, + } + cache = {} + + # Should follow wrapped function and check if it's pure + result = is_pure_function( + "decorated_function", decorated_function, defs, cache + ) + + # Should be True since the wrapped function is also pure (no external refs) + assert result is True diff --git a/tests/_runtime/test_pytest.py b/tests/_runtime/test_pytest.py deleted file mode 100644 index ff77103657e..00000000000 --- a/tests/_runtime/test_pytest.py +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import annotations - -import pytest - -import marimo - -app = marimo.App() - - -@pytest.mark.xfail( - reason=( - "Invoking a cell is not directly supported, and as such should fail " - "until #2293. However, the decorated function _should_ be picked up " - "by pytest. The hook in conftest.py ensures this." - ), - raises=RuntimeError, - strict=True, -) -@app.cell -def test_cell_is_invoked(): - assert True diff --git a/tests/_runtime/test_pytest_runtime.py b/tests/_runtime/test_pytest_runtime.py new file mode 100644 index 00000000000..f1dce7353b2 --- /dev/null +++ b/tests/_runtime/test_pytest_runtime.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import asyncio +import os +import re +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from tests.mocks import snapshotter + +if TYPE_CHECKING: + from marimo._runtime.pytest import run_pytest as _run_pytest_type + from tests._runtime.script_data.contains_tests import app as _app_type + +snapshot = snapshotter(__file__) + +# Format: (passed, skipped, failed, errors) +_DEF_COUNT = { + # fixtures, not tests + "function_fixture": (0, 0, 0, 0), + "scoped_fixture": (0, 0, 0, 0), + "isolated_fixture": (0, 0, 0, 0), + # tests + "TestParent": (2, 0, 0, 0), + "test_failure": (0, 0, 1, 0), + "test_parameterized": (3, 0, 0, 0), + "test_parameterized_collected": (2, 0, 0, 0), + "test_sanity": (1, 0, 0, 0), + "test_skip": (0, 1, 0, 0), + "test_using_var_in_scope": (3, 0, 0, 0), + "test_using_var_in_toplevel": (3, 0, 0, 0), + # Fixtures - these now work with fixture preservation + "test_uses_scoped_fixture": (1, 0, 0, 0), + "test_parametrize_with_scoped_fixture": (2, 0, 0, 0), + "TestWithClassFixture": (1, 0, 0, 0), + "TestClassDefinitionWithFixtures": (3, 0, 0, 0), + "test_uses_top_level_fixture": (1, 0, 0, 0), + "test_parametrize_with_toplevel_fixture": (2, 0, 0, 0), + "test_uses_function_fixture": (1, 0, 0, 0), + # Fixture dependency chain test + "base_fixture": (0, 0, 0, 0), # fixture, not a test + "dependent_fixture": (0, 0, 0, 0), # fixture, not a test + "test_fixture_dependency_chain": (1, 0, 0, 0), + # Null cases - fixture not in scope / doesn't exist (errors) + "test_cross_cell_fixture_fails": (0, 0, 0, 1), + "test_missing_fixture": (0, 0, 0, 1), +} + +_ISOLATION_DEFS = {"test_cross_cell_fixture_fails", "test_missing_fixture"} + + +@pytest.fixture(scope="module") +def notebook_env() -> tuple[ + type[_app_type], dict[str, object], Path, type[_run_pytest_type], set[str] +]: + from marimo._ast.names import SETUP_CELL_NAME, TOPLEVEL_CELL_PREFIX + from marimo._runtime.pytest import run_pytest + from tests._runtime.script_data.contains_tests import app + + _, lcls = app.run() + lcls = dict(lcls) + path = Path(__file__).parent / "script_data/contains_tests.py" + + # Notebook-global defs (setup + top-level), normally resolved from the live + # kernel graph; the script-mode `app.run()` above has no kernel context, so + # compute them here and thread them through to `run_pytest`. + global_defs: set[str] = set() + for cid, cell in app._cell_manager.valid_cells(): + if str(cid) == SETUP_CELL_NAME or cell._name.startswith( + TOPLEVEL_CELL_PREFIX + ): + global_defs |= cell._cell.defs + + # Turn off for recursion guard + previous = os.environ.get("PYTEST_CURRENT_TEST", "") + os.environ["PYTEST_CURRENT_TEST"] = "" + del os.environ["PYTEST_CURRENT_TEST"] + # Give time for env changes to sync (helps with race conditions on Windows) + asyncio.run(asyncio.sleep(0.1)) + + yield app, lcls, path, run_pytest, global_defs + + if previous: + os.environ["PYTEST_CURRENT_TEST"] = previous + else: + os.environ.pop("PYTEST_CURRENT_TEST", None) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Fails on Windows CI") +def test_batched_cells(notebook_env): + """Batch all non-isolation cells into a single run_pytest call.""" + app, lcls, path, run_pytest, global_defs = notebook_env + + batch_defs: set[str] = set() + batch_expected = [0, 0, 0, 0] # passed, skipped, failed, errors + for cell in app._cell_manager.cells(): + if cell and cell.__test__ and not (cell.defs & _ISOLATION_DEFS): + batch_defs.update(cell.defs) + for d in cell.defs: + for i, v in enumerate(_DEF_COUNT[d]): + batch_expected[i] += v + + response = run_pytest( + defs=batch_defs, + lcls=lcls, + notebook_path=path, + global_defs=global_defs, + ) + assert ( + response.passed, + response.skipped, + response.failed, + response.errors, + ) == tuple(batch_expected), response.output + assert response.total == 28 + + +@pytest.mark.skipif(sys.platform == "win32", reason="Fails on Windows CI") +def test_isolation_cells(notebook_env): + """Isolation tests run separately to verify fixture scoping errors.""" + app, lcls, path, run_pytest, global_defs = notebook_env + + total = 0 + for cell in app._cell_manager.cells(): + if cell and cell.__test__ and (cell.defs & _ISOLATION_DEFS): + response = run_pytest( + defs=cell.defs, + lcls=lcls, + notebook_path=path, + global_defs=global_defs, + ) + expected = tuple( + map( + sum, zip(*[_DEF_COUNT[d] for d in cell.defs], strict=False) + ) + ) + assert ( + response.passed, + response.skipped, + response.failed, + response.errors, + ) == expected, response.output + total += response.total + assert total == 2 + + +@pytest.mark.skipif(sys.platform == "win32", reason="Fails on Windows CI") +def test_live_collection_ignores_stale_disk(tmp_path) -> None: + """Reactive collection reads the kernel's live globals, not the on-disk + notebook, so unsaved edits are picked up without racing the save (#4797): + new parametrize values, added tests, and renamed tests all take effect + while the file on disk is deliberately stale. + """ + import marimo + from marimo._runtime.pytest import run_pytest + + app = marimo.App() + + @app.cell + def _(): + import pytest + + return (pytest,) + + @app.cell + def _(pytest): + # Live: 3 param rows (edited/added a row), an added test, a renamed one. + @pytest.mark.parametrize(("a", "b"), [(1, 2), (3, 4), (9, 9)]) + def test_values(a, b): + assert a <= b + + def test_added(): + assert True + + def test_renamed_target(): + assert True + + return + + _, lcls = app.run() + lcls = dict(lcls) + defs = {"test_values", "test_added", "test_renamed_target"} + + # On-disk file is stale: one param row, the pre-rename name, no added test. + stale = tmp_path / "notebook.py" + stale.write_text( + "import marimo\n" + "app = marimo.App()\n\n" + "@app.cell\n" + "def _():\n" + " import pytest\n" + " return (pytest,)\n\n" + "@app.cell\n" + "def _(pytest):\n" + " @pytest.mark.parametrize(('a', 'b'), [(1, 2)])\n" + " def test_values(a, b):\n" + " assert a <= b\n" + " def test_old_name():\n" + " assert True\n" + " return\n" + ) + + # Recursion guard: we are invoking pytest from within pytest. + previous = os.environ.get("PYTEST_CURRENT_TEST", "") + os.environ.pop("PYTEST_CURRENT_TEST", None) + asyncio.run(asyncio.sleep(0.1)) + try: + result = run_pytest(defs=defs, lcls=lcls, notebook_path=stale) + finally: + if previous: + os.environ["PYTEST_CURRENT_TEST"] = previous + + # 3 live param rows + test_added + test_renamed_target = 5, all passing. + # The stale disk file (one row, test_old_name) is never read. + assert (result.passed, result.failed, result.errors) == (5, 0, 0), ( + result.output + ) + assert "test_renamed_target" in result.output + assert "test_old_name" not in result.output + + +@pytest.mark.skipif(sys.platform == "win32", reason="Fails on Windows CI") +def test_offline_collection_inside_marimo_package() -> None: + """Plain `pytest notebook.py` must collect multi-def cells, classes, and + fixture-using tests even when the notebook lives inside the marimo package. + + `process_for_pytest` injects the generated MarimoTestBlock stub into the + notebook's own module frame; an earlier "first frame not under marimo/" + heuristic skipped that frame for notebooks inside the package (e.g. + marimo/_smoke_tests/*), silently dropping every test in such cells. + """ + import subprocess + + import marimo + + notebook = """ +import marimo +app = marimo.App() + +with app.setup: + import pytest + + +@app.cell +def _(): + @pytest.fixture + def my_fixture(): + return 7 + + def test_uses_fixture(my_fixture): + assert my_fixture == 7 + + @pytest.mark.parametrize("x", [1, 2]) + def test_param(x): + assert x > 0 + return + + +if __name__ == "__main__": + app.run() +""" + # The bug only triggers when the notebook path is inside the marimo package. + pkg_dir = Path(marimo.__file__).parent / "_smoke_tests" + nb = pkg_dir / f"_offline_collect_probe_{os.getpid()}.py" + nb.write_text(notebook) + # Mimic a standalone CLI run: the inherited PYTEST_CURRENT_TEST (set by the + # outer pytest) would otherwise disable marimo's pytest test-rewrite in the + # child during collection. + env = {k: v for k, v in os.environ.items() if k != "PYTEST_CURRENT_TEST"} + try: + proc = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + str(nb), + "--collect-only", + "-q", + "-p", + "no:cacheprovider", + "-p", + "no:inline_snapshot", + ], + capture_output=True, + text=True, + cwd=Path(marimo.__file__).parent.parent, + env=env, + ) + finally: + nb.unlink(missing_ok=True) + + out = proc.stdout + proc.stderr + # Sanitize the pid-suffixed filename and timing, which vary per run. + out = re.sub(r"_offline_collect_probe_\d+", "_offline_collect_probe", out) + out = re.sub(r"in [\d.]+s", "in Ns", out) + snapshot("offline_collection_inside_marimo_package.txt", out) + + +def test_pytest_result_summary_includes_xfail() -> None: + from marimo._runtime.pytest import MarimoPytestResult + + result = MarimoPytestResult( + passed=2, failed=1, errors=0, skipped=1, xfailed=3, xpassed=1 + ) + assert result.total == 8 + assert "XFailed: 3" in result.summary + assert "XPassed: 1" in result.summary + + +def test_pytest_result_summary_omits_zero_xfail() -> None: + from marimo._runtime.pytest import MarimoPytestResult + + result = MarimoPytestResult(passed=5, failed=0, errors=0, skipped=0) + assert "XFailed" not in result.summary + assert "XPassed" not in result.summary diff --git a/tests/_runtime/test_query_params.py b/tests/_runtime/test_query_params.py index 35c7aaf39ea..66f6570916e 100644 --- a/tests/_runtime/test_query_params.py +++ b/tests/_runtime/test_query_params.py @@ -1,17 +1,16 @@ from __future__ import annotations import unittest -from unittest.mock import MagicMock import pytest -from marimo._messaging.types import Stream from marimo._runtime.params import CLIArgs, QueryParams +from tests._messaging.mocks import MockStream class TestQueryParams(unittest.TestCase): def setUp(self) -> None: - self.mock_stream = MagicMock(spec=Stream) + self.mock_stream = MockStream() self.params = QueryParams( {"key1": "value1", "key2": ["value2", "value3"]}, stream=self.mock_stream, @@ -20,6 +19,13 @@ def setUp(self) -> None: def test_get(self) -> None: assert self.params.get("key1") == "value1" assert self.params.get("key2") == ["value2", "value3"] + # Test with fallback parameter + assert self.params.get("non_existent", "fallback") == "fallback" + assert self.params.get("non_existent", ["fallback"]) == ["fallback"] + assert self.params.get("non_existent", None) is None + # Test existing keys with fallback (should return original value) + assert self.params.get("key1", "fallback") == "value1" + assert self.params.get("key2", "fallback") == ["value2", "value3"] def test_get_all(self) -> None: assert self.params.get_all("key1") == ["value1"] @@ -53,60 +59,66 @@ def test_setitem(self) -> None: self.params["key3"] = "value4" assert self.params.get("key3") == "value4" - assert self.mock_stream.write.call_count == 1 - assert self.mock_stream.write.call_args[1] == { + assert len(self.mock_stream.messages) == 1 + assert self.mock_stream.operations[0] == { "op": "query-params-set", - "data": {"key": "key3", "value": "value4"}, + "key": "key3", + "value": "value4", } def test_setitem_null(self) -> None: self.params["key1"] = None # type: ignore assert self.params.get("key1") is None - assert self.mock_stream.write.call_count == 1 - assert self.mock_stream.write.call_args[1] == { + assert len(self.mock_stream.messages) == 1 + assert self.mock_stream.operations[0] == { "op": "query-params-delete", - "data": {"key": "key1", "value": None}, + "key": "key1", + "value": None, } def test_setitem_empty(self) -> None: self.params["key1"] = [] assert self.params.get("key1") is None - assert self.mock_stream.write.call_count == 1 - assert self.mock_stream.write.call_args[1] == { + assert len(self.mock_stream.messages) == 1 + assert self.mock_stream.operations[0] == { "op": "query-params-delete", - "data": {"key": "key1", "value": None}, + "key": "key1", + "value": None, } def test_set(self) -> None: self.params.set("key1", "value5") assert self.params.get("key1") == "value5" - assert self.mock_stream.write.call_count == 1 - assert self.mock_stream.write.call_args[1] == { + assert len(self.mock_stream.messages) == 1 + assert self.mock_stream.operations[0] == { "op": "query-params-set", - "data": {"key": "key1", "value": "value5"}, + "key": "key1", + "value": "value5", } def test_set_null(self) -> None: self.params.set("key1", None) # type: ignore assert self.params.get("key1") is None - assert self.mock_stream.write.call_count == 1 - assert self.mock_stream.write.call_args[1] == { + assert len(self.mock_stream.messages) == 1 + assert self.mock_stream.operations[0] == { "op": "query-params-delete", - "data": {"key": "key1", "value": None}, + "key": "key1", + "value": None, } def test_set_empty(self) -> None: self.params.set("key1", []) assert self.params.get("key1") is None - assert self.mock_stream.write.call_count == 1 - assert self.mock_stream.write.call_args[1] == { + assert len(self.mock_stream.messages) == 1 + assert self.mock_stream.operations[0] == { "op": "query-params-delete", - "data": {"key": "key1", "value": None}, + "key": "key1", + "value": None, } def test_append(self) -> None: @@ -115,14 +127,16 @@ def test_append(self) -> None: self.params.append("key4", "value6") assert self.params.get("key4") == "value6" - assert self.mock_stream.write.call_count == 2 - assert self.mock_stream.write.call_args_list[0][1] == { + assert len(self.mock_stream.messages) == 2 + assert self.mock_stream.operations[0] == { "op": "query-params-append", - "data": {"key": "key1", "value": "value5"}, + "key": "key1", + "value": "value5", } - assert self.mock_stream.write.call_args_list[1][1] == { + assert self.mock_stream.operations[1] == { "op": "query-params-append", - "data": {"key": "key4", "value": "value6"}, + "key": "key4", + "value": "value6", } def test_delete(self) -> None: @@ -141,18 +155,21 @@ def test_delete(self) -> None: assert str(self.params) == "{}" assert repr(self.params) == "QueryParams({})" - assert self.mock_stream.write.call_count == 3 - assert self.mock_stream.write.call_args_list[0][1] == { + assert len(self.mock_stream.messages) == 3 + assert self.mock_stream.operations[0] == { "op": "query-params-delete", - "data": {"key": "key1", "value": None}, + "key": "key1", + "value": None, } - assert self.mock_stream.write.call_args_list[1][1] == { + assert self.mock_stream.operations[1] == { "op": "query-params-append", - "data": {"key": "key2", "value": "value4"}, + "key": "key2", + "value": "value4", } - assert self.mock_stream.write.call_args_list[2][1] == { + assert self.mock_stream.operations[2] == { "op": "query-params-delete", - "data": {"key": "key2", "value": None}, + "key": "key2", + "value": None, } def test_remove(self) -> None: @@ -163,14 +180,16 @@ def test_remove(self) -> None: self.params.remove("key2") assert self.params.get("key2") is None - assert self.mock_stream.write.call_count == 2 - assert self.mock_stream.write.call_args_list[0][1] == { + assert len(self.mock_stream.messages) == 2 + assert self.mock_stream.operations[0] == { "op": "query-params-delete", - "data": {"key": "key2", "value": "value2"}, + "key": "key2", + "value": "value2", } - assert self.mock_stream.write.call_args_list[1][1] == { + assert self.mock_stream.operations[1] == { "op": "query-params-delete", - "data": {"key": "key2", "value": None}, + "key": "key2", + "value": None, } def test_clear(self) -> None: @@ -179,11 +198,8 @@ def test_clear(self) -> None: assert str(self.params) == "{}" assert repr(self.params) == "QueryParams({})" - assert self.mock_stream.write.call_count == 1 - assert self.mock_stream.write.call_args_list[0][1] == { - "op": "query-params-clear", - "data": {}, - } + assert len(self.mock_stream.messages) == 1 + assert self.mock_stream.operations[0] == {"op": "query-params-clear"} class TestCLIArgs(unittest.TestCase): @@ -195,6 +211,13 @@ def setUp(self): def test_get(self): assert self.params.get("key1") == "value1" assert self.params.get("key2") == ["value2", "value3"] + # Test with fallback parameter + assert self.params.get("non_existent", "fallback") == "fallback" + assert self.params.get("non_existent", ["fallback"]) == ["fallback"] + assert self.params.get("non_existent", None) is None + # Test existing keys with fallback (should return original value) + assert self.params.get("key1", "fallback") == "value1" + assert self.params.get("key2", "fallback") == ["value2", "value3"] def test_get_all(self): assert self.params.get_all("key1") == ["value1"] @@ -231,3 +254,17 @@ def test_setitem(self): def test_delete(self): with pytest.raises(TypeError): del self.params["key1"] + + def test_items(self): + items = list(self.params.items()) + assert items == [("key1", "value1"), ("key2", ["value2", "value3"])] + + def test_keys(self): + keys = list(self.params.keys()) + assert keys == ["key1", "key2"] + + def test_dict_generic(self): + assert dict(self.params) == { + "key1": "value1", + "key2": ["value2", "value3"], + } diff --git a/tests/_runtime/test_redirect_streams.py b/tests/_runtime/test_redirect_streams.py new file mode 100644 index 00000000000..1aefe817728 --- /dev/null +++ b/tests/_runtime/test_redirect_streams.py @@ -0,0 +1,19 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from marimo._runtime.redirect_streams import redirect_streams +from marimo._runtime.scratch import SCRATCH_CELL_ID +from marimo._types.ids import CellId_t +from tests.conftest import _MockStream + + +def test_nested_from_scratch_swaps_cell_id() -> None: + """When code_mode runs cells from the scratchpad, the inner cell's + console output must be tagged with its own cell_id, not __scratch__.""" + stream = _MockStream() + inner_id = CellId_t("real_cell") + + with redirect_streams(SCRATCH_CELL_ID, stream, None, None, None): + with redirect_streams(inner_id, stream, None, None, None): + assert stream.cell_id == inner_id + assert stream.cell_id == SCRATCH_CELL_ID diff --git a/tests/_runtime/test_request_router.py b/tests/_runtime/test_request_router.py new file mode 100644 index 00000000000..c77c896194e --- /dev/null +++ b/tests/_runtime/test_request_router.py @@ -0,0 +1,169 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import typing +from typing import TYPE_CHECKING, Any + +import pytest + +from marimo._runtime.callbacks import ( + CacheCallbacks, + DatasetCallbacks, + ExternalStorageCallbacks, + KernelCallback, + PackagesCallbacks, + SecretsCallbacks, + SqlCallbacks, +) +from marimo._runtime.commands import ( + ClearCacheCommand, + CodeCompletionCommand, + CommandMessage, + SetBreakpointsCommand, + StopKernelCommand, +) +from marimo._runtime.kernel_request_handlers import KernelRequestHandlers +from marimo._runtime.request_router import RequestRouter + +if TYPE_CHECKING: + from tests.conftest import MockedKernel + + +# All callback bundles that self-register against a router. Order doesn't +# matter — tests only iterate this list, never index into it. +ALL_CALLBACKS: list[type] = [ + SecretsCallbacks, + DatasetCallbacks, + SqlCallbacks, + CacheCallbacks, + ExternalStorageCallbacks, + PackagesCallbacks, +] + +# Commands that are part of the CommandMessage dispatch surface but are +# intentionally not handled by the kernel's RequestRouter. These are delivered +# on the off-main-loop completion queue and processed by +# start_out_of_band_worker: CodeCompletionCommand (autocomplete) and +# SetBreakpointsCommand (live-debugger breakpoints, so they apply mid-run). +NOT_ROUTED: set[type] = {CodeCompletionCommand, SetBreakpointsCommand} + + +def _all_command_classes() -> set[type]: + """Every concrete command type in the CommandMessage discriminated union.""" + return set(typing.get_args(CommandMessage)) + + +class TestRequestRouter: + async def test_dispatch_invokes_registered_handler(self) -> None: + router = RequestRouter() + seen: list[Any] = [] + + async def handler(request: StopKernelCommand) -> None: + seen.append(request) + + router.register(StopKernelCommand, handler) + cmd = StopKernelCommand() + await router.dispatch(cmd) + assert seen == [cmd] + + async def test_dispatch_unknown_command_raises(self) -> None: + router = RequestRouter() + with pytest.raises(ValueError, match="Unknown request"): + await router.dispatch(StopKernelCommand()) + + async def test_register_overwrites_prior_binding(self) -> None: + router = RequestRouter() + calls: list[str] = [] + + async def first(_: StopKernelCommand) -> None: + calls.append("first") + + async def second(_: StopKernelCommand) -> None: + calls.append("second") + + router.register(StopKernelCommand, first) + router.register(StopKernelCommand, second) + await router.dispatch(StopKernelCommand()) + assert calls == ["second"] + + async def test_dispatch_routes_by_exact_type(self) -> None: + router = RequestRouter() + seen: list[str] = [] + + async def stop_handler(_: StopKernelCommand) -> None: + seen.append("stop") + + async def clear_handler(_: ClearCacheCommand) -> None: + seen.append("clear") + + router.register(StopKernelCommand, stop_handler) + router.register(ClearCacheCommand, clear_handler) + await router.dispatch(ClearCacheCommand()) + await router.dispatch(StopKernelCommand()) + assert seen == ["clear", "stop"] + + +@pytest.mark.parametrize( + "callback_cls", ALL_CALLBACKS, ids=lambda c: c.__name__ +) +def test_callback_implements_kernel_callback_protocol( + callback_cls: type, + mocked_kernel: MockedKernel, +) -> None: + assert isinstance(callback_cls(mocked_kernel.k), KernelCallback) + + +def test_no_two_callbacks_claim_the_same_command( + mocked_kernel: MockedKernel, +) -> None: + """Each callback bundle must own a disjoint slice of the command surface.""" + owner: dict[type, str] = {} + for cb_cls in ALL_CALLBACKS: + router = RequestRouter() + cb_cls(mocked_kernel.k).register(router) + for cmd in router._handlers: + assert cmd not in owner, ( + f"{cmd.__name__} is registered by both " + f"{owner[cmd]} and {cb_cls.__name__}" + ) + owner[cmd] = cb_cls.__name__ + + +def test_kernel_handlers_and_callbacks_partition_the_command_surface( + mocked_kernel: MockedKernel, +) -> None: + """Kernel-owned handlers and callback-owned handlers must not overlap.""" + kernel_router = RequestRouter() + KernelRequestHandlers(mocked_kernel.k).register(kernel_router) + kernel_bound = set(kernel_router._handlers) + + callback_bound: set[type] = set() + for cb_cls in ALL_CALLBACKS: + router = RequestRouter() + cb_cls(mocked_kernel.k).register(router) + callback_bound |= set(router._handlers) + + overlap = kernel_bound & callback_bound + assert not overlap, ( + f"commands handled by both KernelRequestHandlers and a callback: " + f"{sorted(c.__name__ for c in overlap)}" + ) + + +def test_kernel_router_covers_every_dispatchable_command( + mocked_kernel: MockedKernel, +) -> None: + """Every CommandMessage member (minus NOT_ROUTED) has a handler.""" + bound = set(mocked_kernel.k.router._handlers) + expected = _all_command_classes() - NOT_ROUTED + + missing = expected - bound + extra = bound - expected + assert not missing, ( + f"commands with no handler on the kernel router: " + f"{sorted(c.__name__ for c in missing)}" + ) + assert not extra, ( + f"handlers bound for commands not in CommandMessage: " + f"{sorted(c.__name__ for c in extra)}" + ) diff --git a/tests/_runtime/test_requests.py b/tests/_runtime/test_requests.py new file mode 100644 index 00000000000..190c2681d2a --- /dev/null +++ b/tests/_runtime/test_requests.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import Any + +import msgspec +from starlette.authentication import SimpleUser +from starlette.datastructures import URL, Headers, QueryParams +from starlette.requests import HTTPConnection + +from marimo._runtime.commands import HTTPRequest + + +class MockHTTPConnection(HTTPConnection): + def __init__( + self, + url: str = "http://localhost:8000/test?param1=value1¶m2=value2", + headers: dict[str, str] | None = None, + path_params: dict[str, Any] | None = None, + user: Any = None, + ): + url_obj = URL(url) + # Convert headers to list of tuples as expected by Starlette + raw_headers = [(k.lower(), v) for k, v in (headers or {}).items()] + scope: dict[str, Any] = { + "type": "http", + "method": "GET", + "headers": dict(raw_headers), + "path": url_obj.path, + "path_params": path_params or {}, + } + if user is not None: + scope["user"] = user + super().__init__(scope) + self._url = url_obj + self._base_url = URL("http://localhost:8000") + + @property + def url(self) -> URL: + return self._url + + @property + def base_url(self) -> URL: + return self._base_url + + @property + def query_params(self) -> QueryParams: + return QueryParams(self.url.query) + + @property + def headers(self) -> Headers: + return Headers(headers=self.scope["headers"]) + + +def test_http_request_like_basic_mapping(): + request = HTTPRequest( + url={"path": "/test"}, + base_url={"path": "/"}, + headers={"Content-Type": "application/json"}, + query_params=defaultdict(list), + path_params={}, + cookies={}, + user={"is_authenticated": True}, + meta={}, + ) + + assert request["url"] == {"path": "/test"} + assert set(request) == { + "url", + "base_url", + "headers", + "query_params", + "path_params", + "cookies", + "user", + "meta", + } + + +def test_from_request(): + mock_request = MockHTTPConnection( + url="http://localhost:8000/test?param1=value1¶m2=value2", + headers={ + "Content-Type": "application/json", + "Cookie": "session=abc123", + }, + path_params={"id": "123"}, + ) + + request_like = HTTPRequest.from_request(mock_request) + + assert request_like["url"] == { + "path": "/test", + "port": 8000, + "scheme": "http", + "netloc": "localhost:8000", + "query": "param1=value1¶m2=value2", + "hostname": "localhost", + } + + assert request_like["headers"] == { + "content-type": "application/json", + "cookie": "session=abc123", + } + assert request_like["cookies"] == {"session": "abc123"} + assert request_like["path_params"] == {"id": "123"} + + +def test_query_params_filtering(): + mock_request = MockHTTPConnection( + url="http://localhost:8000/test?param1=value1&marimo_param=value2" + ) + + request_like = HTTPRequest.from_request(mock_request) + + # marimo in params is ok + assert dict(request_like["query_params"]) == { + "param1": ["value1"], + "marimo_param": ["value2"], + } + + +def test_header_params_filtering(): + mock_request = MockHTTPConnection( + url="http://localhost:8000/test", + headers={ + "Content-Type": "application/json", + "x-marimo-param": "value1", + "marimo-param": "value2", + }, + ) + + request_like = HTTPRequest.from_request(mock_request) + + assert request_like["headers"] == {"content-type": "application/json"} + + +def test_display(): + request = HTTPRequest( + url={"path": "/test"}, + base_url={"path": "/"}, + headers={}, + query_params=defaultdict(list), + path_params={}, + cookies={}, + user={"is_authenticated": True}, + meta={}, + ) + + display_dict = request._display_() + assert isinstance(display_dict, dict) + assert "url" in display_dict + + +def test_from_request_normalizes_starlette_user(): + """Starlette BaseUser instances must be flattened to a dict so that + HTTPRequest can be msgspec-encoded across the IPC queue boundary.""" + mock_request = MockHTTPConnection(user=SimpleUser("alice")) + request_like = HTTPRequest.from_request(mock_request) + assert request_like["user"] == { + "username": "alice", + "is_authenticated": True, + "display_name": "alice", + } + # Regression for the IPC msgspec path: encoding must not raise. + msgspec.msgpack.encode(request_like["user"]) + + +def test_from_request_user_missing_returns_empty_dict(): + mock_request = MockHTTPConnection() + request_like = HTTPRequest.from_request(mock_request) + assert request_like["user"] == {} + + +def test_from_request_user_already_dict_passthrough(): + mock_request = MockHTTPConnection( + user={"username": "bob", "is_authenticated": True} + ) + request_like = HTTPRequest.from_request(mock_request) + assert request_like["user"] == { + "username": "bob", + "is_authenticated": True, + } + + +def test_command_msgspec_json_decode_command_with_http_request(): + """Regression for the Pyodide path: `parse_dataclass` builds a + `msgspec.json` decoder for any Command containing an `HTTPRequest` + field. msgspec.json rejects unions with two array-like members, so + `Encodable` must avoid e.g. `list | tuple` simultaneously. + """ + from marimo._runtime.commands import ExecuteCellCommand + + # NB: ``Command`` uses ``rename="camel"``, but ``HTTPRequest`` is a + # plain ``@dataclass`` whose fields stay snake_case on the wire. + payload = ( + b'{"type":"execute-cell","cellId":"c1","code":"x=1","request":' + b'{"url":{"path":"/x"},"base_url":{},"headers":{},' + b'"query_params":{},"path_params":{},"cookies":{},"meta":{},' + b'"user":{"username":"u","is_authenticated":true,"display_name":"u"}}}' + ) + cmd = msgspec.json.decode(payload, type=ExecuteCellCommand) + assert cmd.cell_id == "c1" + assert cmd.request is not None + assert cmd.request["user"] == { + "username": "u", + "is_authenticated": True, + "display_name": "u", + } diff --git a/tests/_runtime/test_runtime.py b/tests/_runtime/test_runtime.py index 0f89ea8e87b..71d56d43ae5 100644 --- a/tests/_runtime/test_runtime.py +++ b/tests/_runtime/test_runtime.py @@ -1,50 +1,61 @@ -# Copyright 2024 Marimo. All rights reserved. +# Copyright 2026 Marimo. All rights reserved. from __future__ import annotations +import asyncio +import copy +import pathlib import sys import textwrap -from dataclasses import dataclass -from typing import TYPE_CHECKING, Sequence +from contextlib import ExitStack +from typing import TYPE_CHECKING, Any, cast +from unittest.mock import MagicMock, Mock, patch import pytest +from marimo._ast.variables import is_mangled_local from marimo._config.config import DEFAULT_CONFIG from marimo._dependencies.dependencies import DependencyManager from marimo._messaging.cell_output import CellChannel from marimo._messaging.errors import ( CycleError, - DeleteNonlocalError, Error, + MarimoExceptionRaisedError, + MarimoInternalError, MarimoStrictExecutionError, MarimoSyntaxError, MultipleDefinitionError, ) -from marimo._messaging.ops import CellOp -from marimo._messaging.types import NoopStream +from marimo._messaging.notification import ( + CellNotification, + VariablesNotification, +) +from marimo._messaging.serde import deserialize_kernel_message from marimo._plugins.ui._core.ids import IDProvider from marimo._plugins.ui._core.ui_element import UIElement -from marimo._runtime.context.kernel_context import ( - initialize_kernel_context, +from marimo._runtime.commands import ( + CreateNotebookCommand, + DeleteCellCommand, + ExecuteCellCommand, + UpdateCellConfigCommand, + UpdateUIElementCommand, ) -from marimo._runtime.context.types import teardown_context from marimo._runtime.dataflow import EdgeWithVar -from marimo._runtime.patches import create_main_module -from marimo._runtime.requests import ( - AppMetadata, - CreationRequest, - DeleteCellRequest, - ExecutionRequest, - SetCellConfigRequest, - SetUIElementValueRequest, +from marimo._runtime.runtime import ( + Kernel, + launch_kernel, + notebook_dir, + notebook_location, ) -from marimo._runtime.runtime import Kernel from marimo._runtime.scratch import SCRATCH_CELL_ID -from marimo._server.model import SessionMode +from marimo._types.ids import CellId_t from marimo._utils.parse_dataclass import parse_raw -from tests.conftest import ExecReqProvider, MockedKernel +from tests._messaging.mocks import MockStderr, MockStream +from tests._runtime._helpers.factories import default_app_metadata +from tests._runtime._helpers.session import mocked_kernel_session +from tests.conftest import ExecReqProvider, MockedKernel, mock_pyodide if TYPE_CHECKING: - import pathlib + from collections.abc import Coroutine, Sequence def _check_edges(error: Error, expected_edges: Sequence[EdgeWithVar]) -> None: @@ -62,7 +73,7 @@ async def test_expected_gloals(self, any_kernel: Kernel): k = any_kernel await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="0", code="assert __file__; success = 1" ) ] @@ -87,9 +98,9 @@ async def test_triangle(self, any_kernel: Kernel) -> None: # x, y --> z await k.run( [ - ExecutionRequest(cell_id="0", code="x = 1"), - er1 := ExecutionRequest(cell_id="1", code="y = x + 1"), - er2 := ExecutionRequest(cell_id="2", code="z = x + y"), + ExecuteCellCommand(cell_id="0", code="x = 1"), + er1 := ExecuteCellCommand(cell_id="1", code="y = x + 1"), + er2 := ExecuteCellCommand(cell_id="2", code="z = x + y"), ] ) @@ -98,7 +109,7 @@ async def test_triangle(self, any_kernel: Kernel) -> None: assert k.globals["y"] == 2 assert k.globals["z"] == 3 - await k.run([ExecutionRequest(cell_id="0", code="x = 2")]) + await k.run([ExecuteCellCommand(cell_id="0", code="x = 2")]) assert not k.graph.cells["0"].stale assert k.globals["x"] == 2 if k.lazy(): @@ -118,7 +129,7 @@ async def test_triangle(self, any_kernel: Kernel) -> None: assert not k.graph.cells["1"].stale assert not k.graph.cells["2"].stale - await k.run([ExecutionRequest(cell_id="1", code="y = 0")]) + await k.run([ExecuteCellCommand(cell_id="1", code="y = 0")]) assert not k.graph.cells["0"].stale assert not k.graph.cells["1"].stale assert k.globals["x"] == 2 @@ -128,7 +139,7 @@ async def test_triangle(self, any_kernel: Kernel) -> None: await k.run([er2]) assert k.globals["z"] == 2 - await k.delete_cell(DeleteCellRequest(cell_id="1")) + await k.delete_cell(DeleteCellCommand(cell_id="1")) assert k.globals["x"] == 2 assert "y" not in k.globals if k.lazy(): @@ -136,11 +147,27 @@ async def test_triangle(self, any_kernel: Kernel) -> None: await k.run([er2]) assert "z" not in k.globals - await k.delete_cell(DeleteCellRequest(cell_id="0")) + await k.delete_cell(DeleteCellCommand(cell_id="0")) assert "x" not in k.globals assert "y" not in k.globals assert "z" not in k.globals + async def test_delete_cell_restores_doc(self, any_kernel: Kernel) -> None: + k = any_kernel + # Simulate a notebook with a docstring in its header + k.app_metadata.docstring = "Notebook docstring" + k.globals["__doc__"] = "Notebook docstring" + + await k.run( + [ + ExecuteCellCommand(cell_id="0", code='__doc__ = "cell doc"'), + ] + ) + assert k.globals["__doc__"] == "cell doc" + + await k.delete_cell(DeleteCellCommand(cell_id="0")) + assert k.globals["__doc__"] == "Notebook docstring" + async def test_run_referrers_not_stale(self, any_kernel: Kernel) -> None: k = any_kernel graph = k.graph @@ -149,9 +176,9 @@ async def test_run_referrers_not_stale(self, any_kernel: Kernel) -> None: # as stale await k.run( [ - ExecutionRequest(cell_id="0", code="x = 1"), - er1 := ExecutionRequest(cell_id="1", code="x"), - er2 := ExecutionRequest(cell_id="2", code="x"), + ExecuteCellCommand(cell_id="0", code="x = 1"), + er1 := ExecuteCellCommand(cell_id="1", code="x"), + er2 := ExecuteCellCommand(cell_id="2", code="x"), ] ) assert not graph.get_stale() @@ -165,25 +192,25 @@ async def test_run_referrers_not_stale(self, any_kernel: Kernel) -> None: async def test_set_ui_element_value(self, any_kernel: Kernel) -> None: k = any_kernel await k.run( - [ExecutionRequest(cell_id="0", code="import marimo as mo")] + [ExecuteCellCommand(cell_id="0", code="import marimo as mo")] ) await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="1", code="s = mo.ui.slider(0, 10, value=1); s" ) ] ) await k.run( - [er2 := ExecutionRequest(cell_id="2", code="x = s.value + 1")] + [er2 := ExecuteCellCommand(cell_id="2", code="x = s.value + 1")] ) assert k.globals["x"] == 2 element_id = k.globals["s"]._id await k.set_ui_element_value( - SetUIElementValueRequest.from_ids_and_values([(element_id, 5)]) + UpdateUIElementCommand.from_ids_and_values([(element_id, 5)]), + notify_frontend=False, ) - assert k.globals["s"].value == 5 if k.reactive_execution_mode == "lazy": assert k.graph.cells["2"].stale @@ -224,7 +251,8 @@ def on_change(v): # Set a child of the array to 5 ... child_id = k.globals["array"][0]._id await k.set_ui_element_value( - SetUIElementValueRequest.from_ids_and_values([(child_id, 5)]) + UpdateUIElementCommand.from_ids_and_values([(child_id, 5)]), + notify_frontend=False, ) # Make sure the array and its child are updated @@ -262,9 +290,8 @@ async def test_set_ui_element_value_lensed_bound_child( array_id = k.globals["array"]._id await k.set_ui_element_value( - SetUIElementValueRequest.from_ids_and_values( - [(array_id, {"0": 5})] - ) + UpdateUIElementCommand.from_ids_and_values([(array_id, {"0": 5})]), + notify_frontend=False, ) assert k.globals["array"].value == [5] if k.lazy(): @@ -295,7 +322,8 @@ async def test_set_ui_element_value_lensed_with_state( # called child_id = k.globals["array"][0]._id await k.set_ui_element_value( - SetUIElementValueRequest.from_ids_and_values([(child_id, 5)]) + UpdateUIElementCommand.from_ids_and_values([(child_id, 5)]), + notify_frontend=False, ) if k.lazy(): assert k.graph.cells[er.cell_id].stale @@ -304,39 +332,23 @@ async def test_set_ui_element_value_lensed_with_state( # Make sure the array and its child are updated assert k.globals["state"] == 5 - async def test_set_local_var_ui_element_value( - self, any_kernel: Kernel - ) -> None: - k = any_kernel - await k.run([ExecutionRequest("0", "import marimo as mo")]) - await k.run( - [ExecutionRequest("1", "_s = mo.ui.slider(0, 10, value=1); _s")] - ) - # _s's name is mangled to _cell_1_s because it is local - assert k.globals["_cell_1_s"].value == 1 - - element_id = k.globals["_cell_1_s"]._id - # This shouldn't crash the kernel, and s's value should still be - # updated - await k.set_ui_element_value( - SetUIElementValueRequest.from_ids_and_values([(element_id, 5)]) - ) - assert k.globals["_cell_1_s"].value == 5 - async def test_creation_with_ui_element_value( self, any_kernel: Kernel ) -> None: k = any_kernel id_provider = IDProvider(prefix="1") await k.instantiate( - CreationRequest( + CreateNotebookCommand( execution_requests=( - ExecutionRequest(cell_id="0", code="import marimo as mo"), - ExecutionRequest( + ExecuteCellCommand( + cell_id="0", code="import marimo as mo" + ), + ExecuteCellCommand( cell_id="1", code="s = mo.ui.slider(0, 10, value=1)" ), ), - set_ui_element_value_request=SetUIElementValueRequest.from_ids_and_values( + cell_ids=("0", "1"), + set_ui_element_value_request=UpdateUIElementCommand.from_ids_and_values( [(id_provider.take_id(), 2)] ), auto_run=True, @@ -347,12 +359,78 @@ async def test_creation_with_ui_element_value( async def test_instantiate_autorun_false(self, any_kernel: Kernel) -> None: k = any_kernel await k.instantiate( - CreationRequest( + CreateNotebookCommand( + execution_requests=( + ExecuteCellCommand(cell_id="0", code="x=0"), + er1 := ExecuteCellCommand(cell_id="1", code="y=x+1"), + er2 := ExecuteCellCommand(cell_id="2", code="z=x+2"), + ), + cell_ids=("0", "1", "2"), + set_ui_element_value_request=UpdateUIElementCommand.from_ids_and_values( + [] + ), + auto_run=False, + ) + ) + assert not k.errors + assert "x" not in k.globals + assert "y" not in k.globals + assert len(k._uninstantiated_execution_requests) == 3 + + # Expect the first cell to implicitly be included in the run ... + await k.run([er1]) + assert k.globals["y"] == 1 + + # But z should still not be defined + assert "z" not in k.globals + assert len(k._uninstantiated_execution_requests) == 1 + + # After running er2, no cells should be left uninstantiated + await k.run([er2]) + assert k.globals["z"] == 2 + assert not k._uninstantiated_execution_requests + + async def test_instantiate_autorun_false_run_stale( + self, any_kernel: Kernel + ) -> None: + k = any_kernel + await k.instantiate( + CreateNotebookCommand( + execution_requests=( + ExecuteCellCommand(cell_id="0", code="x=0"), + ExecuteCellCommand(cell_id="1", code="y=x+1"), + ExecuteCellCommand(cell_id="2", code="z=x+2"), + ), + cell_ids=("0", "1", "2"), + set_ui_element_value_request=UpdateUIElementCommand.from_ids_and_values( + [] + ), + auto_run=False, + ) + ) + assert not k.errors + assert "x" not in k.globals + assert "y" not in k.globals + assert len(k._uninstantiated_execution_requests) == 3 + + await k.run_stale_cells() + assert k.globals["y"] == 1 + assert k.globals["z"] == 2 + assert not k._uninstantiated_execution_requests + + async def test_instantiate_autorun_false_run_all( + self, any_kernel: Kernel + ) -> None: + k = any_kernel + await k.instantiate( + CreateNotebookCommand( execution_requests=( - ExecutionRequest(cell_id="0", code="x=0"), - ExecutionRequest(cell_id="1", code="y=x+1"), + er1 := ExecuteCellCommand(cell_id="0", code="x=0"), + er2 := ExecuteCellCommand(cell_id="1", code="y=x+1"), + er3 := ExecuteCellCommand(cell_id="2", code="z=x+2"), ), - set_ui_element_value_request=SetUIElementValueRequest.from_ids_and_values( + cell_ids=("0", "1", "2"), + set_ui_element_value_request=UpdateUIElementCommand.from_ids_and_values( [] ), auto_run=False, @@ -361,10 +439,166 @@ async def test_instantiate_autorun_false(self, any_kernel: Kernel) -> None: assert not k.errors assert "x" not in k.globals assert "y" not in k.globals + assert len(k._uninstantiated_execution_requests) == 3 - # Expect the first cell to implicitly be included in the run - await k.run([ExecutionRequest(cell_id="1", code="y=x+1")]) + await k.run([er1, er2, er3]) assert k.globals["y"] == 1 + assert k.globals["z"] == 2 + assert not k._uninstantiated_execution_requests + + async def test_instantiate_autorun_false_set_not_stale( + self, any_kernel: Kernel + ) -> None: + """Tests that cells are set to not stale before they start running.""" + k = any_kernel + await k.instantiate( + CreateNotebookCommand( + execution_requests=( + er1 := ExecuteCellCommand(cell_id="0", code="x=0"), + ExecuteCellCommand(cell_id="1", code="y=x+1"), + ExecuteCellCommand(cell_id="2", code="z=x+2"), + ), + cell_ids=("0", "1", "2"), + set_ui_element_value_request=UpdateUIElementCommand.from_ids_and_values( + [] + ), + auto_run=False, + ) + ) + assert not k.errors + assert "x" not in k.globals + assert "y" not in k.globals + assert len(k._uninstantiated_execution_requests) == 3 + + await k.run([er1]) + stream = MockStream(k.stream) + cell_notifications = [ + deserialize_kernel_message(msg) for msg in stream.messages + ] + cell_notifications = [ + op for op in cell_notifications if isinstance(op, CellNotification) + ] + er1_set_not_stale_before_run = False + for op in cell_notifications: + if op.cell_id == er1.cell_id and op.status == "running": + break + if ( + op.cell_id == er1.cell_id + and op.stale_inputs is not None + and not op.stale_inputs + ): + er1_set_not_stale_before_run = True + assert er1_set_not_stale_before_run + + async def test_instantiate_autorun_false_delete_cells( + self, any_kernel: Kernel + ) -> None: + k = any_kernel + await k.instantiate( + CreateNotebookCommand( + execution_requests=( + ExecuteCellCommand(cell_id="0", code="x=0"), + ExecuteCellCommand(cell_id="1", code="y=x+1"), + ExecuteCellCommand(cell_id="2", code="z=x+2"), + ), + cell_ids=("0", "1", "2"), + set_ui_element_value_request=UpdateUIElementCommand.from_ids_and_values( + [] + ), + auto_run=False, + ) + ) + assert len(k._uninstantiated_execution_requests) == 3 + + await k.delete_cell(DeleteCellCommand(cell_id="0")) + assert len(k._uninstantiated_execution_requests) == 2 + + await k.delete_cell(DeleteCellCommand(cell_id="1")) + assert len(k._uninstantiated_execution_requests) == 1 + + await k.delete_cell(DeleteCellCommand(cell_id="2")) + assert not k._uninstantiated_execution_requests + + async def test_instantiate_autorun_false_run_different_code( + self, any_kernel: Kernel + ) -> None: + k = any_kernel + await k.instantiate( + CreateNotebookCommand( + execution_requests=( + ExecuteCellCommand(cell_id="0", code="x=0"), + er1 := ExecuteCellCommand(cell_id="1", code="y=x+1"), + er2 := ExecuteCellCommand(cell_id="2", code="z=x+2"), + ), + cell_ids=("0", "1", "2"), + set_ui_element_value_request=UpdateUIElementCommand.from_ids_and_values( + [] + ), + auto_run=False, + ) + ) + assert len(k._uninstantiated_execution_requests) == 3 + + # modify an uninstantiated cell before running it; make sure the old + # er gets evicted. + await k.run([ExecuteCellCommand(cell_id="0", code="x = 1")]) + assert len(k._uninstantiated_execution_requests) == 2 + assert k.globals["x"] == 1 + + await k.run([er1]) + assert len(k._uninstantiated_execution_requests) == 1 + assert k.globals["y"] == 2 + + await k.run([er2]) + assert not k._uninstantiated_execution_requests + assert k.globals["z"] == 3 + + async def test_instantiate_autorun_false_empty_cells_not_stale( + self, any_kernel: Kernel + ) -> None: + """Tests that empty cells are not marked as stale during instantiation.""" + k = any_kernel + await k.instantiate( + CreateNotebookCommand( + execution_requests=( + ExecuteCellCommand(cell_id="0", code="x=0"), + ExecuteCellCommand(cell_id="1", code=""), + ExecuteCellCommand(cell_id="2", code=" \n "), + ExecuteCellCommand(cell_id="3", code="y=x+1"), + ), + cell_ids=("0", "1", "2", "3"), + set_ui_element_value_request=UpdateUIElementCommand.from_ids_and_values( + [] + ), + auto_run=False, + ) + ) + + # Check that all cells are in uninstantiated requests + assert len(k._uninstantiated_execution_requests) == 4 + + # Check the stream for stale broadcasts + stream = MockStream(k.stream) + cell_notifications = [ + op + for op in stream.parsed_operations + if isinstance(op, CellNotification) + ] + + # Filter for stale broadcasts + stale_broadcasts = [ + op + for op in cell_notifications + if op.stale_inputs is not None and op.stale_inputs + ] + + # Only cells 0 and 3 should be marked as stale (non-empty cells) + stale_cell_ids = {op.cell_id for op in stale_broadcasts} + assert stale_cell_ids == {"0", "3"} + + # Cells 1 and 2 (empty/whitespace) should not be marked as stale + assert "1" not in stale_cell_ids + assert "2" not in stale_cell_ids # Test errors in marimo semantics async def test_kernel_simultaneous_multiple_definition_error( @@ -374,8 +608,8 @@ async def test_kernel_simultaneous_multiple_definition_error( k = any_kernel await k.run( [ - ExecutionRequest(cell_id="0", code="x=0"), - ExecutionRequest(cell_id="1", code="x=1"), + ExecuteCellCommand(cell_id="0", code="x=0"), + ExecuteCellCommand(cell_id="1", code="x=1"), ] ) @@ -389,18 +623,18 @@ async def test_kernel_new_multiple_definition_does_not_invalidate( any_kernel: Kernel, ) -> None: k = any_kernel - await k.run([ExecutionRequest(cell_id="0", code="x=0")]) + await k.run([ExecuteCellCommand(cell_id="0", code="x=0")]) assert k.globals["x"] == 0 assert not k.errors # cell 0 should not be invalidated by the introduction of cell 1 - await k.run([ExecutionRequest(cell_id="1", code="x=0")]) + await k.run([ExecuteCellCommand(cell_id="1", code="x=0")]) assert k.globals["x"] == 0 assert set(k.errors.keys()) == {"1"} assert k.errors["1"] == (MultipleDefinitionError("x", ("0",)),) # re-running cell 0 should invalidate it - await k.run([ExecutionRequest(cell_id="0", code="x=0")]) + await k.run([ExecuteCellCommand(cell_id="0", code="x=0")]) assert "x" not in k.globals assert set(k.errors.keys()) == {"0", "1"} assert k.errors["0"] == (MultipleDefinitionError("x", ("1",)),) @@ -412,8 +646,8 @@ async def test_clear_multiple_definition_error( k = any_kernel await k.run( [ - er := ExecutionRequest(cell_id="0", code="x=0"), - ExecutionRequest(cell_id="1", code="x=1"), + er := ExecuteCellCommand(cell_id="0", code="x=0"), + ExecuteCellCommand(cell_id="1", code="x=1"), ] ) assert "x" not in k.globals @@ -423,7 +657,7 @@ async def test_clear_multiple_definition_error( # Rename second occurrence of x to y; should eliminate error and run # both cells - await k.run([ExecutionRequest(cell_id="1", code="y=1")]) + await k.run([ExecuteCellCommand(cell_id="1", code="y=1")]) assert k.globals["y"] == 1 if k.lazy(): assert k.graph.cells["0"].stale @@ -438,8 +672,8 @@ async def test_clear_multiple_definition_error_with_delete( k = any_kernel await k.run( [ - er := ExecutionRequest(cell_id="0", code="x=0"), - ExecutionRequest(cell_id="1", code="x=1"), + er := ExecuteCellCommand(cell_id="0", code="x=0"), + ExecuteCellCommand(cell_id="1", code="x=1"), ] ) assert "x" not in k.globals @@ -448,7 +682,7 @@ async def test_clear_multiple_definition_error_with_delete( assert k.errors["1"] == (MultipleDefinitionError("x", ("0",)),) # issue delete request for cell 1 to clear error and run cell 0 - await k.delete_cell(DeleteCellRequest(cell_id="1")) + await k.delete_cell(DeleteCellCommand(cell_id="1")) if k.lazy(): assert k.graph.cells[er.cell_id].stale await k.run([er]) @@ -460,13 +694,13 @@ async def test_new_errors_update_old_ones( self, any_kernel: Kernel ) -> None: k = any_kernel - await k.run([ExecutionRequest(cell_id="0", code="x=0")]) - await k.run([ExecutionRequest(cell_id="1", code="x, y = 1, 2")]) + await k.run([ExecuteCellCommand(cell_id="0", code="x=0")]) + await k.run([ExecuteCellCommand(cell_id="1", code="x, y = 1, 2")]) assert set(k.errors.keys()) == {"1"} assert k.errors["1"] == (MultipleDefinitionError("x", ("0",)),) # errors propagated back to cell 1, even though we are not running it - await k.run([ExecutionRequest(cell_id="2", code="x, y = 3, 4")]) + await k.run([ExecuteCellCommand(cell_id="2", code="x, y = 3, 4")]) assert set(k.errors.keys()) == {"1", "2"} assert k.errors["1"] == ( MultipleDefinitionError("x", ("0", "2")), @@ -483,9 +717,9 @@ async def test_cycle_error(self, any_kernel: Kernel) -> None: k = any_kernel await k.run( [ - ExecutionRequest(cell_id="0", code="x=y"), - ExecutionRequest(cell_id="1", code="y=x"), - er := ExecutionRequest(cell_id="2", code="z = x + 1"), + ExecuteCellCommand(cell_id="0", code="x=y"), + ExecuteCellCommand(cell_id="1", code="y=x"), + er := ExecuteCellCommand(cell_id="2", code="z = x + 1"), ] ) assert "x" not in k.globals @@ -500,11 +734,15 @@ async def test_cycle_error(self, any_kernel: Kernel) -> None: assert len(k.errors["1"]) == 1 if k.execution_type == "strict": assert len(k.errors["2"]) == 1 - _check_edges(k.errors["0"][0], [("0", ["x"], "1"), ("1", ["y"], "0")]) - _check_edges(k.errors["1"][0], [("0", ["x"], "1"), ("1", ["y"], "0")]) + _check_edges( + k.errors["0"][0], [("0", ("x",), "1"), ("1", ("y",), "0")] + ) + _check_edges( + k.errors["1"][0], [("0", ("x",), "1"), ("1", ("y",), "0")] + ) # break cycle by modifying cell - await k.run([ExecutionRequest(cell_id="1", code="y=1")]) + await k.run([ExecuteCellCommand(cell_id="1", code="y=1")]) if k.lazy(): assert k.graph.cells["0"].stale assert k.graph.cells["2"].stale @@ -523,8 +761,8 @@ async def test_break_cycle_error_with_delete( k = any_kernel await k.run( [ - ExecutionRequest(cell_id="0", code="x=y"), - ExecutionRequest(cell_id="1", code="y=x"), + ExecuteCellCommand(cell_id="0", code="x=y"), + ExecuteCellCommand(cell_id="1", code="y=x"), ] ) assert "x" not in k.globals @@ -532,10 +770,12 @@ async def test_break_cycle_error_with_delete( assert set(k.errors.keys()) == {"0", "1"} assert len(k.errors["0"]) == 1 assert len(k.errors["1"]) == 1 - _check_edges(k.errors["0"][0], [("0", ["x"], "1"), ("1", ["y"], "0")]) + _check_edges( + k.errors["0"][0], [("0", ("x",), "1"), ("1", ("y",), "0")] + ) # break cycle by deleting cell - await k.delete_cell(DeleteCellRequest(cell_id="1")) + await k.delete_cell(DeleteCellCommand(cell_id="1")) if k.execution_type == "strict": # Still invalid in strict mode because y is missing. assert set(k.errors.keys()) == {"0"} @@ -543,34 +783,79 @@ async def test_break_cycle_error_with_delete( else: assert not k.errors - async def test_delete_nonlocal_error(self, any_kernel: Kernel) -> None: + async def test_delete_nonlocal_ok(self, any_kernel: Kernel) -> None: k = any_kernel + if k.execution_type == "strict": + # `x` does not get removed from memory in strict mode; strict + # execution is experimental and not widely used nor maintained, so + # we're okay with this not working. + pytest.skip( + "Deleting variables does not work in strict execution." + ) await k.run( [ - ExecutionRequest(cell_id="0", code="x=0"), - ExecutionRequest(cell_id="1", code="del x; y = 1"), - er := ExecutionRequest(cell_id="2", code="z = y + 1"), + ExecuteCellCommand(cell_id="0", code="x=0"), + ExecuteCellCommand(cell_id="1", code="del x; y = 1"), + er := ExecuteCellCommand(cell_id="2", code="z = y + 1"), ] ) - assert "y" not in k.globals - assert "z" not in k.globals - if k.execution_type == "strict": - assert set(k.errors.keys()) == {"1", "2"} - else: - assert set(k.errors.keys()) == {"1"} - assert k.errors["1"] == (DeleteNonlocalError("x", ("0",)),) + assert "x" not in k.globals + assert "y" in k.globals + assert "z" in k.globals + assert not k.errors - # fix cell 1, should run cell 1 and 2 - await k.run([ExecutionRequest(cell_id="1", code="y=1")]) - if k.lazy(): - assert k.graph.cells[er.cell_id].stale - await k.run([er]) + async def test_delete_nonlocal_multiple_not_ok(self, k: Kernel) -> None: + await k.run( + [ + ExecuteCellCommand(cell_id="0", code="del x"), + ExecuteCellCommand(cell_id="1", code="del x"), + ] + ) + assert set(k.errors.keys()) == {"0", "1"} + _check_edges( + k.errors["0"][0], [("0", ("x",), "1"), ("1", ("x",), "0")] + ) + _check_edges( + k.errors["1"][0], [("0", ("x",), "1"), ("1", ("x",), "0")] + ) - assert not k.graph.cells[er.cell_id].stale - assert k.globals["y"] == 1 - assert k.globals["z"] == 2 + async def test_delete_nonlocal_incremental_ref_raises_name_error( + self, k: Kernel + ) -> None: + await k.run( + [ + er := ExecuteCellCommand(cell_id="0", code="x=0"), + ExecuteCellCommand(cell_id="1", code="del x; y = 1"), + ] + ) + assert "x" not in k.globals + assert "y" in k.globals assert not k.errors + await k.run( + [ + ExecuteCellCommand( + cell_id="2", + code=""" +try: + z = x + 1 + name_error = False +except NameError: + name_error = True +""", + ) + ] + ) + assert "z" not in k.globals + assert k.globals["name_error"] + + # Run x's defining cell. This time, cell "2" will run before cell "1". + await k.run([er]) + assert "x" not in k.globals + assert "y" in k.globals + assert k.globals["z"] == 1 + assert not k.globals["name_error"] + async def test_import_module_as_local_var( self, any_kernel: Kernel ) -> None: @@ -578,7 +863,7 @@ async def test_import_module_as_local_var( k = any_kernel await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="0", code="import sys as _sys; msize = _sys.maxsize", ), @@ -588,43 +873,126 @@ async def test_import_module_as_local_var( assert "_sys" not in k.globals assert k.globals["msize"] == sys.maxsize - async def test_defs_with_no_definers_are_removed_from_cell( + async def test_underscore_prefixed_import_in_cell( self, any_kernel: Kernel ) -> None: + # An underscore-prefixed `as` alias is cell-local (mangled), but + # must still resolve when used within the same cell, including in + # a nested decorator/body scope. k = any_kernel await k.run( [ - ExecutionRequest(cell_id="0", code="x=0"), - ExecutionRequest(cell_id="1", code="del x"), + ExecuteCellCommand( + cell_id="0", + code=( + "import marimo as _mo\n" + "@_mo.cache\n" + "def f(x):\n" + " return _mo.md(str(x))\n" + "msg = f(1)" + ), + ), ] ) - assert set(k.errors.keys()) == {"1"} - assert k.errors["1"] == (DeleteNonlocalError("x", ("0",)),) + assert not k.errors, k.errors + # The alias is cell-local, so it never leaks into globals. + assert "_mo" not in k.globals + assert "1" in k.globals["msg"].text - # Delete the cell that defines x. There shouldn't be any more errors - # because x no longer exists. - await k.delete_cell(DeleteCellRequest(cell_id="0")) - if k.execution_type != "strict": - assert not k.errors + async def test_underscore_prefixed_import_across_cells_no_conflict( + self, k: Kernel + ) -> None: + # Underscore-prefixed `as` aliases are cell-local/private: two + # cells may each `import sys as _sys` without triggering a + # MultipleDefinitionError. Each cell sees its own mangled binding. + await k.run( + [ + ExecuteCellCommand( + cell_id="0", + code="import sys as _sys; a = _sys.maxsize", + ), + ExecuteCellCommand( + cell_id="1", + code="import sys as _sys; b = _sys.maxsize", + ), + ] + ) + assert not k.errors, k.errors + assert k.globals["a"] == sys.maxsize + assert k.globals["b"] == sys.maxsize + # The private alias is not promoted to a graph def. + assert "_sys" not in k.globals - # Add x back in. - await k.run([ExecutionRequest(cell_id="2", code="x=0")]) - assert set(k.errors.keys()) == {"1"} - assert k.errors["1"] == (DeleteNonlocalError("x", ("2",)),) + async def test_no_alias_underscore_import_nested_scope( + self, any_kernel: Kernel + ) -> None: + # Regression for #9151 (MO-5835): a no-alias underscore import + # (`from pkg import _name`) keeps its raw name — the user cannot + # control the package's symbol name — so references to it in + # nested scopes (function bodies, decorators, class bases) must + # stay raw as well, not be mangled to `_cell__name`. + k = any_kernel + await k.run( + [ + ExecuteCellCommand( + cell_id="0", + code=( + "from marimo import _ast\n" + "top = _ast.__name__\n" + "def g():\n" + " return _ast.__name__\n" + "nested = g()" + ), + ), + ] + ) + assert not k.errors, k.errors + assert k.globals["top"] == "marimo._ast" + assert k.globals["nested"] == "marimo._ast" - # Repair graph - await k.run([er := ExecutionRequest(cell_id="1", code="y = x + 1")]) - assert not k.errors - assert k.globals["y"] == 1 + async def test_no_alias_underscore_import_is_cell_local( + self, k: Kernel + ) -> None: + # MO-5949: a no-alias underscore import keeps its raw name but is + # still cell-local — it never becomes a graph definition, so other + # cells cannot read it. + await k.run( + [ + ExecuteCellCommand( + cell_id="0", + code="from marimo import _ast\na = _ast.__name__", + ), + ExecuteCellCommand( + cell_id="1", + code="b = _ast.__name__", + ), + ] + ) + assert k.globals["a"] == "marimo._ast" + # The import is not shared: the other cell fails with a NameError. + assert "b" not in k.globals - # Make sure graph is tracking x again and update propagates - await k.run([ExecutionRequest(cell_id="2", code="x = 1")]) - assert not k.errors - if k.lazy(): - assert k.graph.cells[er.cell_id].stale - await k.run([er]) - assert not k.graph.cells[er.cell_id].stale - assert k.globals["y"] == 2 + async def test_underscore_import_public_alias_is_shared( + self, k: Kernel + ) -> None: + # MO-5949: aliasing an underscore-prefixed import to a name + # without a leading underscore makes it a regular, shared + # definition usable across cells. + await k.run( + [ + ExecuteCellCommand( + cell_id="0", + code="from marimo import _ast as ast_mod", + ), + ExecuteCellCommand( + cell_id="1", + code="c = ast_mod.__name__", + ), + ] + ) + assert not k.errors, k.errors + assert k.globals["c"] == "marimo._ast" + assert "ast_mod" in k.globals async def test_cell_transitioned_to_error_is_not_stale( self, lazy_kernel: Kernel @@ -632,22 +1000,22 @@ async def test_cell_transitioned_to_error_is_not_stale( k = lazy_kernel await k.run( [ - ExecutionRequest(cell_id="0", code="x=0"), - ExecutionRequest(cell_id="1", code="x"), + ExecuteCellCommand(cell_id="0", code="x=0"), + ExecuteCellCommand(cell_id="1", code="x"), ] ) # make cell 1 stale await k.run( [ - ExecutionRequest(cell_id="0", code="x=1"), + ExecuteCellCommand(cell_id="0", code="x=1"), ] ) # introduce an error to cell 1; it shouldn't be stale await k.run( [ - ExecutionRequest(cell_id="1", code="x=0"), + ExecuteCellCommand(cell_id="1", code="x=0"), ] ) assert set(k.errors.keys()) == {"1"} @@ -659,15 +1027,15 @@ async def test_cell_transitioned_to_syntax_error_is_not_stale( k = lazy_kernel await k.run( [ - ExecutionRequest(cell_id="0", code="x=0"), - ExecutionRequest(cell_id="1", code="x"), + ExecuteCellCommand(cell_id="0", code="x=0"), + ExecuteCellCommand(cell_id="1", code="x"), ] ) # make cell 1 stale await k.run( [ - ExecutionRequest(cell_id="0", code="x=1"), + ExecuteCellCommand(cell_id="0", code="x=1"), ] ) cell = k.graph.cells["1"] @@ -676,74 +1044,201 @@ async def test_cell_transitioned_to_syntax_error_is_not_stale( # introduce a syntax error to cell 1; it shouldn't be stale await k.run( [ - ExecutionRequest(cell_id="1", code="x ^ !"), + ExecuteCellCommand(cell_id="1", code="x ^ !"), ] ) assert set(k.errors.keys()) == {"1"} assert isinstance(k.errors["1"][0], MarimoSyntaxError) + assert k.errors["1"][0].lineno is not None + assert k.errors["1"][0].lineno == 1 assert not cell.stale - async def test_child_of_errored_cell_with_error_not_stale( - self, - any_kernel: Kernel, + async def test_syntax_error_multiline_code( + self, lazy_kernel: Kernel ) -> None: - k = any_kernel + """Test that syntax errors on different lines report correct line numbers""" + k = lazy_kernel + + # Syntax error on line 3 of multiline code await k.run( [ - ExecutionRequest(cell_id="0", code="x=0"), + ExecuteCellCommand( + cell_id="0", code="x = 1\ny = 2\nz ^ !\nw = 4" + ), ] ) + assert set(k.errors.keys()) == {"0"} + assert isinstance(k.errors["0"][0], MarimoSyntaxError) + assert k.errors["0"][0].lineno is not None + assert k.errors["0"][0].lineno == 3 - # multiple definition error + async def test_syntax_error_line_zero(self, lazy_kernel: Kernel) -> None: + """Test edge case where syntax error might be on line 0""" + k = lazy_kernel + + # Single line syntax error (should be line 1) await k.run( [ - ExecutionRequest(cell_id="1", code="y; x=1"), + ExecuteCellCommand(cell_id="0", code="!"), ] ) + assert set(k.errors.keys()) == {"0"} + assert isinstance(k.errors["0"][0], MarimoSyntaxError) + assert k.errors["0"][0].lineno is not None + # Python reports line numbers starting from 1 + assert k.errors["0"][0].lineno >= 1 - # 0 also has a multiple definition error; 1 now depends on 0, but it - # is errored and its error is up-to-date, so don't mark it as stale. + async def test_syntax_error_hint_pip_install( + self, any_kernel: Kernel + ) -> None: + """Test that !pip commands show package manager hint""" + k = any_kernel await k.run( [ - ExecutionRequest(cell_id="0", code="y = 0; x=1"), + ExecuteCellCommand(cell_id="0", code="!pip install pandas"), ] ) + assert set(k.errors.keys()) == {"0"} + assert isinstance(k.errors["0"][0], MarimoSyntaxError) + assert "package manager panel" in k.errors["0"][0].msg - assert "x" not in k.globals - assert set(k.errors.keys()) == {"0", "1"} - assert not k.graph.cells["1"].stale - - async def test_syntax_error(self, any_kernel: Kernel) -> None: + async def test_syntax_error_hint_shell_command( + self, any_kernel: Kernel + ) -> None: + """Test that shell commands (not pip) show os.subprocess hint""" k = any_kernel await k.run( [ - ExecutionRequest(cell_id="0", code="x=0"), - er := ExecutionRequest(cell_id="1", code="x; y = 1"), + ExecuteCellCommand(cell_id="0", code="!ls -la"), ] ) - assert not k.graph.get_stale() - assert k.globals["x"] == 0 - assert k.globals["y"] == 1 - assert not k.errors + assert set(k.errors.keys()) == {"0"} + assert isinstance(k.errors["0"][0], MarimoSyntaxError) + assert "os.subprocess" in k.errors["0"][0].msg - await k.run([ExecutionRequest(cell_id="0", code="x=")]) - assert "0" not in k.graph.cells + async def test_syntax_error_hint_ipython_magic( + self, any_kernel: Kernel + ) -> None: + """Test that IPython magic commands show unsupported hint""" + k = any_kernel + await k.run( + [ + ExecuteCellCommand(cell_id="0", code="%timeit x = 1"), + ] + ) + assert set(k.errors.keys()) == {"0"} + assert isinstance(k.errors["0"][0], MarimoSyntaxError) + assert "IPython magic commands" in k.errors["0"][0].msg + assert "not supported" in k.errors["0"][0].msg + + async def test_child_of_errored_cell_with_error_not_stale( + self, + any_kernel: Kernel, + ) -> None: + k = any_kernel + await k.run( + [ + ExecuteCellCommand(cell_id="0", code="x=0"), + ] + ) + + # multiple definition error + await k.run( + [ + ExecuteCellCommand(cell_id="1", code="y; x=1"), + ] + ) + + # 0 also has a multiple definition error; 1 now depends on 0, but it + # is errored and its error is up-to-date, so don't mark it as stale. + await k.run( + [ + ExecuteCellCommand(cell_id="0", code="y = 0; x=1"), + ] + ) + + assert "x" not in k.globals + assert set(k.errors.keys()) == {"0", "1"} + assert not k.graph.cells["1"].stale + + async def test_setup_runs(self, any_kernel: Kernel) -> None: + k = any_kernel + from marimo._ast.names import SETUP_CELL_NAME + + await k.run( + [ + ExecuteCellCommand(cell_id=SETUP_CELL_NAME, code="x=0"), + # NB. no explicit tie from setup to er. + er := ExecuteCellCommand(cell_id="1", code="y=1"), + ] + ) + assert not k.graph.get_stale() + assert k.globals["x"] == 0 + assert k.globals["y"] == 1 + assert not k.errors + + await k.run([ExecuteCellCommand(cell_id=SETUP_CELL_NAME, code="x=")]) + assert SETUP_CELL_NAME not in k.graph.cells + assert "1" in k.graph.cells + assert "x" not in k.globals + if k.lazy(): + # Opinionated - but because setup is not a true root, it should not + # invalidate other cells unless there is explicitly a tie. + assert k.graph.get_stale() == set() + await k.run([er]) + assert not k.graph.get_stale() + assert "y" in k.globals + + # fix syntax error + await k.run([ExecuteCellCommand(cell_id=SETUP_CELL_NAME, code="x=0")]) + assert k.globals["x"] == 0 + assert SETUP_CELL_NAME in k.graph.cells + assert "1" in k.graph.cells + assert not k.errors + if k.lazy(): + # Wasn't stale previously + assert k.graph.get_stale() == set() + await k.run([er]) + assert not k.graph.get_stale() + assert k.globals["y"] == 1 + + # set setup to stale + k.graph.cells[SETUP_CELL_NAME].set_stale(True) + await k.run([er]) + # Should have run! + assert not k.graph.get_stale() + + async def test_syntax_error(self, any_kernel: Kernel) -> None: + k = any_kernel + await k.run( + [ + ExecuteCellCommand(cell_id="0", code="x=0"), + er := ExecuteCellCommand(cell_id="1", code="x; y = 1"), + ] + ) + assert not k.graph.get_stale() + assert k.globals["x"] == 0 + assert k.globals["y"] == 1 + assert not k.errors + + await k.run([ExecuteCellCommand(cell_id="0", code="x=")]) + assert "0" not in k.graph.cells assert "1" in k.graph.cells assert "x" not in k.globals if k.lazy(): - assert k.graph.get_stale() == set([er.cell_id]) + assert k.graph.get_stale() == {er.cell_id} await k.run([er]) assert not k.graph.get_stale() assert "y" not in k.globals # fix syntax error - await k.run([ExecutionRequest(cell_id="0", code="x=0")]) + await k.run([ExecuteCellCommand(cell_id="0", code="x=0")]) assert k.globals["x"] == 0 assert "0" in k.graph.cells assert "1" in k.graph.cells assert not k.errors if k.lazy(): - assert k.graph.get_stale() == set([er.cell_id]) + assert k.graph.get_stale() == {er.cell_id} await k.run([er]) assert not k.graph.get_stale() assert k.globals["y"] == 1 @@ -765,10 +1260,10 @@ async def test_cell_state_invalidated( # "y" should not be computed, and its global state should have been # invalidated await k.run( - [ExecutionRequest(er_1.cell_id, "x = 0; raise RuntimeError")] + [ExecuteCellCommand(er_1.cell_id, "x = 0; raise RuntimeError")] ) if k.lazy(): - assert graph.get_stale() == set([er_2.cell_id]) + assert graph.get_stale() == {er_2.cell_id} # running er_2 will redefine y; this is different from the # behavior of a non-lazy kernel, which doesn't run er_2 # but instead invalidates it on exception raised @@ -811,11 +1306,12 @@ class namespace: element_id = k.globals["defs"]["slider"]._id await k.set_ui_element_value( - SetUIElementValueRequest.from_ids_and_values([(element_id, 5)]) + UpdateUIElementCommand.from_ids_and_values([(element_id, 5)]), + notify_frontend=False, ) assert k.globals["defs"]["slider"].value == 5 if k.lazy(): - assert graph.get_stale() == set([er.cell_id]) + assert graph.get_stale() == {er.cell_id} await k.run([er]) assert not graph.get_stale() assert k.globals["slider_value"] == 6 @@ -830,9 +1326,10 @@ async def test_set_ui_element_value_not_found_doesnt_fail( # smoke test -- this shouldn't raise an exception k = any_kernel await k.set_ui_element_value( - SetUIElementValueRequest.from_ids_and_values( + UpdateUIElementCommand.from_ids_and_values( [("does not exist", None)] - ) + ), + notify_frontend=False, ) async def test_interrupt( @@ -921,44 +1418,65 @@ async def test_notebook_dir( ] ) assert "x" in k.globals - assert k.globals["x"] is None + assert k.globals["x"] is not None + + async def test_notebook_location( + self, any_kernel: Kernel, exec_req: ExecReqProvider + ) -> None: + k = any_kernel + await k.run( + [ + exec_req.get("import marimo as mo"), + exec_req.get("loc = mo.notebook_location()"), + exec_req.get("dir = mo.notebook_dir()"), + ] + ) + assert "loc" in k.globals + assert k.globals["loc"] is not None + assert "dir" in k.globals + assert k.globals["dir"] is not None + + @pytest.mark.skipif( + sys.platform == "win32", reason="Windows paths behave differently" + ) + @mock_pyodide( + js=Mock( + location="https://marimo-team.github.io/marimo-gh-pages-template/notebooks/assets/worker-BxJ8HeOy.js" + ), + ) + async def test_notebook_location_for_pyodide( + self, any_kernel: Kernel, exec_req: ExecReqProvider + ) -> None: + k = any_kernel + + await k.run( + [exec_req.get("import marimo as mo; loc = mo.notebook_location()")] + ) + assert ( + str(k.globals["loc"]) + == "https://marimo-team.github.io/marimo-gh-pages-template/notebooks" + ) + assert ( + str(k.globals["loc"] / "public" / "data.csv") + == "https://marimo-team.github.io/marimo-gh-pages-template/notebooks/public/data.csv" + ) async def test_notebook_dir_for_unnamed_notebook( self, tmp_path: pathlib.Path, exec_req: ExecReqProvider ) -> None: + filename = str(tmp_path / "notebook.py") try: - filename = str(tmp_path / "notebook.py") - k = Kernel( - stream=NoopStream(), - stdout=None, - stderr=None, - stdin=None, - cell_configs={}, - user_config=DEFAULT_CONFIG, - app_metadata=AppMetadata( - query_params={}, filename=filename, cli_args={} - ), - enqueue_control_request=lambda _: None, - module=create_main_module(None, None), - ) - initialize_kernel_context( - kernel=k, - stream=k.stream, - stdout=k.stdout, - stderr=k.stderr, - virtual_files_supported=True, - mode=SessionMode.EDIT, - ) - - await k.run( - [ - exec_req.get("import marimo as mo"), - exec_req.get("x = mo.notebook_dir() / 'foo.csv'"), - ] - ) - assert str(k.globals["x"]).endswith("foo.csv") + with mocked_kernel_session( + app_metadata=default_app_metadata(filename=filename), + ) as tk: + await tk.kernel.run( + [ + exec_req.get("import marimo as mo"), + exec_req.get("x = mo.notebook_dir() / 'foo.csv'"), + ] + ) + assert str(tk.kernel.globals["x"]).endswith("foo.csv") finally: - teardown_context() if str(tmp_path) in sys.path: sys.path.remove(str(tmp_path)) @@ -987,26 +1505,82 @@ def foo(): assert k.globals["pickle_output"] is not None def test_sys_path_updated(self, tmp_path: pathlib.Path) -> None: + filename = str(tmp_path / "notebook.py") try: - filename = str(tmp_path / "notebook.py") - Kernel( - stream=NoopStream(), - stdout=None, - stderr=None, - stdin=None, - cell_configs={}, - user_config=DEFAULT_CONFIG, - app_metadata=AppMetadata( - query_params={}, filename=filename, cli_args={} + with mocked_kernel_session( + app_metadata=default_app_metadata(filename=filename), + ): + assert str(tmp_path) in sys.path + assert str(tmp_path) == sys.path[0] + finally: + if str(tmp_path) in sys.path: + sys.path.remove(str(tmp_path)) + + def test_sys_argv_updated(self, tmp_path: pathlib.Path) -> None: + old_argv = sys.argv + filename = str(tmp_path / "notebook.py") + try: + with mocked_kernel_session( + app_metadata=default_app_metadata( + filename=filename, argv=["foo", "bar"] ), - enqueue_control_request=lambda _: None, - module=create_main_module(None, None), - ) - assert str(tmp_path) in sys.path - assert str(tmp_path) == sys.path[0] + ): + assert len(sys.argv) == 3 + assert filename == sys.argv[0] + assert sys.argv[1] == "foo" + assert sys.argv[2] == "bar" + finally: + sys.argv = old_argv + if str(tmp_path) in sys.path: + sys.path.remove(str(tmp_path)) + + def test_sys_argv_not_updated_when_none( + self, tmp_path: pathlib.Path + ) -> None: + argv = sys.argv + filename = str(tmp_path / "notebook.py") + try: + with mocked_kernel_session( + app_metadata=default_app_metadata(filename=filename), + ): + assert argv == sys.argv + finally: + # restore argv in case test failed or accidentally mutated it + sys.argv = argv + if str(tmp_path) in sys.path: + sys.path.remove(str(tmp_path)) + + async def test_sys_path_updated_with_exec_req( + self, tmp_path: pathlib.Path, exec_req: ExecReqProvider + ) -> None: + custom_path = pathlib.Path("some") / "path" + filename = tmp_path / "notebook.py" + + try: + user_config = { + **DEFAULT_CONFIG, + "runtime": { + **DEFAULT_CONFIG["runtime"], + "pythonpath": [str(custom_path)], + }, + } + with mocked_kernel_session( + app_metadata=default_app_metadata(filename=str(filename)), + user_config=user_config, # type: ignore[arg-type] + ) as tk: + await tk.kernel.run( + [ + exec_req.get("import sys"), + exec_req.get("paths = list(sys.path)"), + ] + ) + assert str(custom_path) in tk.kernel.globals["paths"] + assert str(filename.parent) in tk.kernel.globals["paths"] finally: if str(tmp_path) in sys.path: sys.path.remove(str(tmp_path)) + if str(custom_path) in sys.path: + sys.path.remove(str(custom_path)) async def test_set_config_before_registering_cell( self, any_kernel: Kernel, exec_req: ExecReqProvider @@ -1014,7 +1588,7 @@ async def test_set_config_before_registering_cell( k = any_kernel er_1 = exec_req.get("x = 0") await k.set_cell_config( - SetCellConfigRequest(configs={er_1.cell_id: {"disabled": True}}) + UpdateCellConfigCommand(configs={er_1.cell_id: {"disabled": True}}) ) await k.run([er_1]) assert k.graph.cells[er_1.cell_id].config.disabled @@ -1089,19 +1663,19 @@ async def test_runtime_name_error_reference_caught( k = execution_kernel await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="0", code=textwrap.dedent( """ try: R = R # Causes error since no def - C = 0 # Unaccessible + C = 0 # Inaccessible except: pass """ ), ), - ExecutionRequest( + ExecuteCellCommand( cell_id="1", code=textwrap.dedent( """ @@ -1113,22 +1687,24 @@ async def test_runtime_name_error_reference_caught( ) # Runtime error expected- since not a kernel error check stderr assert "C" not in k.globals + stream = MockStream(k.stream) + stderr = MockStderr(k.stderr) if k.execution_type == "strict": assert ( "name `R` is referenced before definition." - in k.stream.messages[-4][1]["output"]["data"][0]["msg"] + in stream.operations[-4]["output"]["data"][0]["msg"] ) assert ( "This cell wasn't run" - in k.stream.messages[-1][1]["output"]["data"][0]["msg"] + in stream.operations[-1]["output"]["data"][0]["msg"] ) else: assert ( - "marimo came across the undefined variable `C` during runtime." - in k.stream.messages[-2][1]["output"]["data"][0]["msg"] + "Name `C` is not defined. It was expected to be defined in" + in stream.operations[-2]["output"]["data"][0]["msg"] ) - assert "NameError" in k.stderr.messages[0] - assert "NameError" in k.stderr.messages[-1] + assert "NameError" in stderr.messages[0] + assert "NameError" in stderr.messages[-1] @staticmethod async def test_run_scratch(mocked_kernel: MockedKernel) -> None: @@ -1136,17 +1712,15 @@ async def test_run_scratch(mocked_kernel: MockedKernel) -> None: await k.run_scratchpad("x = 1; x") # Has no errors assert not k.errors - messages = mocked_kernel.stream.messages - (m1, m2, m3, m4) = messages - assert all(m[0] == "cell-op" for m in messages) - assert all(m[1]["cell_id"] == SCRATCH_CELL_ID for m in messages) - assert m1[1]["status"] == "queued" - assert m2[1]["status"] == "running" - assert m3[1]["status"] is None - assert ( - m3[1]["output"]["data"] == "
1
" - ) - assert m4[1]["status"] == "idle" + stream = MockStream(mocked_kernel.stream) + (m1, m2, m3, m4) = stream.operations + assert all(m["op"] == "cell-op" for m in stream.operations) + assert all(m["cell_id"] == SCRATCH_CELL_ID for m in stream.operations) + assert m1["status"] == "queued" + assert m2["status"] == "running" + assert m3["status"] is None + assert m3["output"]["data"] == "
1
" + assert m4["status"] == "idle" # Does not pollute globals assert "x" not in k.globals @@ -1168,11 +1742,11 @@ async def test_run_scratch_with_other_globals( await k.run_scratchpad("y = z * 2; y") # Has no errors assert not k.errors - messages = mocked_kernel.stream.messages + stream = MockStream(mocked_kernel.stream) + messages = stream.operations output_message = messages[-2] assert ( - output_message[1]["output"]["data"] - == "
20
" + output_message["output"]["data"] == "
20
" ) assert "z" in k.globals # Does not pollute globals @@ -1196,16 +1770,81 @@ async def test_run_scratch_can_temporarily_overwrite_globals( await k.run_scratchpad("z = 20; z") # Has no errors assert not k.errors - messages = mocked_kernel.stream.messages + stream = MockStream(mocked_kernel.stream) + messages = stream.operations output_message = messages[-2] assert ( - output_message[1]["output"]["data"] - == "
20
" + output_message["output"]["data"] == "
20
" ) assert "z" in k.globals # Does not pollute globals, reverts back to 10 assert k.globals["z"] == 10 + @staticmethod + async def test_run_scratch_with_mo_cache_decorator( + mocked_kernel: MockedKernel, + ) -> None: + """Regression test: @mo.cache decoration inside the scratchpad must + not raise `KeyError: '__scratch__'`. + + Before the fix that registers SCRATCH_CELL_ID in the kernel's main + graph during run_scratchpad, the cache decorator's `_set_context` + crashed at `graph.cells[cell_id]` because `__scratch__` lived only + in the Runner's local graph, never in `self.graph`. + """ + k = mocked_kernel.k + await k.run_scratchpad( + "import marimo as mo\n@mo.cache\ndef f(x): return x * 2\nf(3)" + ) + # No KeyError leaked + assert not any( + "__scratch__" in m for m in mocked_kernel.stderr.messages + ) + # __scratch__ does not linger in the main graph after teardown + assert SCRATCH_CELL_ID not in k.graph.cells + # Scratchpad does not pollute globals + assert "f" not in k.globals + + @staticmethod + async def test_run_scratch_with_persistent_cache_context( + mocked_kernel: MockedKernel, + tmp_path: pathlib.Path, + ) -> None: + """Regression test: `with mo.persistent_cache(...)` in scratchpad + must not raise CacheException via the parallel + `_cache_context.trace` code path. + """ + k = mocked_kernel.k + await k.run_scratchpad( + "import marimo as mo\n" + "from pathlib import Path\n" + f"with mo.persistent_cache('scratch_test', save_path=Path({str(tmp_path)!r})):\n" + " x = sum(range(100))\n" + "x" + ) + assert not any( + "CacheException" in m or "Could not resolve cell" in m + for m in mocked_kernel.stderr.messages + ) + assert SCRATCH_CELL_ID not in k.graph.cells + + @staticmethod + async def test_run_scratch_with_mo_cache_cleans_up_after_crash( + mocked_kernel: MockedKernel, + ) -> None: + """Regression test: if the scratchpad raises AFTER decorating, + `__scratch__` is still unregistered from the kernel graph (the + `try/finally` correctness guard). + """ + k = mocked_kernel.k + await k.run_scratchpad( + "import marimo as mo\n" + "@mo.cache\n" + "def f(x): return x * 2\n" + "raise RuntimeError('intentional')" + ) + assert SCRATCH_CELL_ID not in k.graph.cells + async def test_rename( self, any_kernel: Kernel, exec_req: ExecReqProvider ) -> None: @@ -1215,7 +1854,7 @@ async def test_rename( await k.rename_file("foo") if k.lazy(): assert "pytest" in k.globals["x"] - assert k.graph.get_stale() == set([er.cell_id]) + assert k.graph.get_stale() == {er.cell_id} await k.run([er]) assert k.globals["x"] == "foo" @@ -1223,111 +1862,526 @@ async def test_temporaries_deleted( self, k: Kernel, exec_req: ExecReqProvider ) -> None: await k.run([er := exec_req.get("_x = 1")]) - assert k.globals[f"_cell_{er.cell_id}_x"] == 1 - await k.run([ExecutionRequest(er.cell_id, "None")]) - assert f"_cell_{er.cell_id}_x" not in k.globals + assert not any(is_mangled_local(name) for name in k.globals) + async def test_temporary_closed_over_by_function_not_deleted( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + await k.run([exec_req.get("_x = 1\ndef fn():\n return _x")]) + assert k.globals["fn"]() == 1 -class TestStrictExecution: - @staticmethod - async def test_cell_lambda( - strict_kernel: Kernel, exec_req: ExecReqProvider + async def test_temporary_closed_over_by_lambda_not_deleted( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + await k.run([exec_req.get("_x = 1\nlam = lambda: _x")]) + assert k.globals["lam"]() == 1 + + async def test_temporary_closed_over_by_class_not_deleted( + self, k: Kernel, exec_req: ExecReqProvider ) -> None: - k = strict_kernel await k.run( [ - exec_req.get("""Y = 1"""), - exec_req.get( - """ - _x = 1 - X = 1 - L = lambda x: x + _x + X + Y - """ - ), exec_req.get( - """ - V = L(1) - V - """ - ), + "_x = 1\nclass C:\n def m(self):\n return _x" + ) ] ) - assert not k.errors - assert "X" in k.globals - assert "Y" in k.globals - assert "L" in k.globals - assert "V" in k.globals - assert k.globals["V"] == 4 + assert k.globals["C"]().m() == 1 - @staticmethod - async def test_cell_indirect_lambda( - strict_kernel: Kernel, exec_req: ExecReqProvider + async def test_transitive_temporary_closed_over_not_deleted( + self, k: Kernel, exec_req: ExecReqProvider ) -> None: - k = strict_kernel + # `foo` closes over the private helper `_helper`, which in turn closes + # over `_data`; both temporaries must survive for `foo` to be callable. await k.run( [ - exec_req.get("""Y = 1"""), exec_req.get( - """ - _x = 1 - X = 1 - L = [lambda x: x + _x + X + Y] - """ - ), - exec_req.get("V = L[0](1)"), + "_data = 1\n" + "def _helper():\n return _data\n" + "def foo():\n return _helper()" + ) ] ) - assert not k.errors - assert "X" in k.globals - assert "Y" in k.globals - assert "L" in k.globals - assert "V" in k.globals - assert k.globals["V"] == 4 + assert k.globals["foo"]() == 1 - @staticmethod - async def test_cell_indirect_private( - strict_kernel: Kernel, exec_req: ExecReqProvider + async def test_temporary_last_expression_retained_as_output( + self, k: Kernel ) -> None: - k = strict_kernel + # A cell whose last expression is a temporary UI element: the temporary + # is deleted from globals, but the kernel should hang on to a reference. + # This is needed for RPCs in particular. + await k.run( + [ExecuteCellCommand(cell_id="0", code="import marimo as mo")] + ) await k.run( [ - exec_req.get( + ExecuteCellCommand( + cell_id="1", + code="_s = mo.ui.slider(0, 10, value=1); _s", + ) + ] + ) + # The temporary is gone from globals ... + assert not any(is_mangled_local(name) for name in k.globals) + # ... but the kernel retains it as the cell's output. + output = k.graph.cells["1"].output + assert isinstance(output, UIElement) + assert output.value == 1 + + async def test_private_recursive_function( + self, any_kernel: Kernel, exec_req: ExecReqProvider + ) -> None: + """Regression test for MO-5426: underscore-prefixed recursive function + should be able to call itself within the same cell.""" + k = any_kernel + await k.run( + [ + er := exec_req.get( """ - Y = 1 - _y = 1 - def f(x): - return x + _y - """ - ), - exec_req.get( + def _recurse(n): + if n > 0: + return 1 + _recurse(n - 1) + else: + return 0 + + result = _recurse(3) """ - _x = 1 - X = 1 - L = [lambda x: f(x + _x + X + Y)] - """ - ), - exec_req.get("V = L[0](1)"), + ) ] ) - assert not k.errors - assert "X" in k.globals - assert "Y" in k.globals - assert "L" in k.globals - assert "V" in k.globals - assert "f" in k.globals - assert k.globals["V"] == 5 + cell = k.graph.cells[er.cell_id] + assert cell.exception is None + assert k.globals["result"] == 3 - @staticmethod - async def test_cell_copy_works( - strict_kernel: Kernel, exec_req: ExecReqProvider + async def test_private_recursive_function_local_shadow( + self, any_kernel: Kernel, exec_req: ExecReqProvider ) -> None: - k = strict_kernel + """A local reassignment inside the function body should shadow the + outer definition. Result must differ from the real fib to confirm + the shadow is actually used.""" + k = any_kernel await k.run( [ - exec_req.get( + er := exec_req.get( """ - class namespace: - ... + def _recurse(n): + _recurse = lambda x: x + if n <= 1: + return n + return _recurse(n - 1) + _recurse(n - 2) + + result = _recurse(5) + """ + ) + ] + ) + cell = k.graph.cells[er.cell_id] + assert cell.exception is None + # _recurse = lambda x: x (identity) shadows the recursive definition, + # so _recurse(n-1) + _recurse(n-2) = (n-1) + (n-2) = 2n-3 + # _recurse(5) = (5-1) + (5-2) = 4 + 3 = 7, not fib(5)=5 + assert k.globals["result"] == 7 + + async def test_has_run_id( + self, mocked_kernel: MockedKernel, exec_req: ExecReqProvider + ) -> None: + k = mocked_kernel.k + await k.run([exec_req.get("print(2)")]) + + stream = MockStream(mocked_kernel.stream) + cell_notifications = [ + parse_raw(op_data, CellNotification) + for op_data in stream.operations + if op_data["op"] == "cell-op" + ] + + assert ( + len(cell_notifications) == 4 + ) # queued -> running -> output -> idle + for cell_notification in cell_notifications: + if cell_notification.status == "idle": + assert cell_notification.run_id is None + else: + assert cell_notification.run_id is not None + + async def test_serialization_hint_cleared_only_on_demotion( + self, mocked_kernel: MockedKernel, exec_req: ExecReqProvider + ) -> None: + import msgspec + + k = mocked_kernel.k + + def serialization_hints() -> list[str | None]: + stream = MockStream(mocked_kernel.stream) + return [ + cn.serialization + for op in stream.operations + if op["op"] == "cell-op" + for cn in [parse_raw(op, CellNotification)] + if cn.serialization is not msgspec.UNSET + ] + + # A top-level definition advertises its reusability hint. + req = exec_req.get("def foo():\n return 1") + await k.run([req]) + assert serialization_hints() == ["Valid"] + + # Editing it into a plain assignment clears the hint exactly once + # (an explicit None, not an omitted/UNSET field). + await k.run([exec_req.get_with_id(req.cell_id, "x = 1")]) + assert serialization_hints() == ["Valid", None] + + # Re-running the now-ordinary cell emits no further serialization op. + await k.run([exec_req.get_with_id(req.cell_id, "x = 2")]) + assert serialization_hints() == ["Valid", None] + + async def test_sync_graph_basic(self, execution_kernel: Kernel) -> None: + """Test basic synchronization: file changes cell B in A→B→C chain. + + Uses execution_kernel (reactive mode) to test cascade behavior. + """ + k = execution_kernel + # Setup: Create initial graph state with dependencies + await k.run( + [ + ExecuteCellCommand(cell_id="0", code="x = 1"), + ExecuteCellCommand(cell_id="1", code="y = x + 1"), + ExecuteCellCommand(cell_id="2", code="z = y + 1"), + ] + ) + assert k.globals["x"] == 1 + assert k.globals["y"] == 2 + assert k.globals["z"] == 3 + assert len(k.graph.cells) == 3 + + # Action: Sync with cell 1 changed + await k.sync_graph( + cells={"0": "x = 1", "1": "y = x + 10", "2": "z = y + 1"}, + run_ids=["1"], + delete_ids=[], + ) + + # Verify: Cell 1 and its descendant (2) are re-executed + assert k.globals["x"] == 1 # unchanged + assert k.globals["y"] == 11 # updated + assert k.globals["z"] == 12 # cascaded from y + + async def test_sync_graph_orphaned_cells(self, any_kernel: Kernel) -> None: + """Test orphaned cell detection: cell in kernel but not file manager.""" + k = any_kernel + # Setup: Create 3 cells + await k.run( + [ + ExecuteCellCommand(cell_id="0", code="x = 1"), + ExecuteCellCommand(cell_id="1", code="y = x + 1"), + ExecuteCellCommand(cell_id="2", code="z = y + 1"), + ] + ) + assert "z" in k.globals + assert len(k.graph.cells) == 3 + + # Action: Sync with file manager only knowing about cells 0 and 1 + # Cell 2 is orphaned (deleted from file) + await k.sync_graph( + cells={"0": "x = 1", "1": "y = x + 1"}, + run_ids=[], + delete_ids=[], + ) + + # Verify: Cell 2 is automatically deleted + assert len(k.graph.cells) == 2 + assert "z" not in k.globals + assert "0" in k.graph.cells + assert "1" in k.graph.cells + assert "2" not in k.graph.cells + + async def test_sync_graph_combined_orphan_and_explicit_delete( + self, any_kernel: Kernel + ) -> None: + """Test both orphaned and explicitly deleted cells.""" + k = any_kernel + # Setup: Kernel has A, B, C, D + await k.run( + [ + ExecuteCellCommand(cell_id="0", code="a = 1"), + ExecuteCellCommand(cell_id="1", code="b = 2"), + ExecuteCellCommand(cell_id="2", code="c = 3"), + ExecuteCellCommand(cell_id="3", code="d = 4"), + ] + ) + assert len(k.graph.cells) == 4 + + # Action: File manager knows A and C, explicitly deletes B + # D is orphaned (not in file), B is explicitly deleted + await k.sync_graph( + cells={"0": "a = 1", "2": "c = 3"}, + run_ids=[], + delete_ids=["1"], + ) + + # Verify: Both B and D are deleted, only A and C remain + assert len(k.graph.cells) == 2 + assert "0" in k.graph.cells + assert "2" in k.graph.cells + assert "1" not in k.graph.cells + assert "3" not in k.graph.cells + assert "a" in k.globals + assert "b" not in k.globals + assert "c" in k.globals + assert "d" not in k.globals + + async def test_sync_graph_uninstantiated_request_cleanup( + self, any_kernel: Kernel + ) -> None: + """Test cleanup of uninstantiated requests when cells are deleted.""" + k = any_kernel + # Setup: Instantiate with autorun=False, then run some cells + await k.instantiate( + CreateNotebookCommand( + execution_requests=( + ExecuteCellCommand(cell_id="0", code="x = 0"), + ExecuteCellCommand(cell_id="1", code="y = 1"), + ExecuteCellCommand(cell_id="2", code="z = 2"), + ), + cell_ids=("0", "1", "2"), + set_ui_element_value_request=UpdateUIElementCommand.from_ids_and_values( + [] + ), + auto_run=False, + ) + ) + assert len(k._uninstantiated_execution_requests) == 3 + + # Run cells 0 and 1 to add them to graph, keeping cell 2 uninstantiated + await k.run( + [ + ExecuteCellCommand(cell_id="0", code="x = 0"), + ExecuteCellCommand(cell_id="1", code="y = 1"), + ] + ) + # Cell 2 is still uninstantiated, cells 0 and 1 are in graph + assert len(k.graph.cells) == 2 + assert "2" in k._uninstantiated_execution_requests + + # Action: Sync with only cell 0, cell 1 is orphaned (deleted from file) + await k.sync_graph( + cells={"0": "x = 0"}, + run_ids=[], + delete_ids=[], + ) + + # Verify: Uninstantiated request and graph entry for cell 1 are cleaned up + assert len(k.graph.cells) == 1 + assert "0" in k.graph.cells + assert "1" not in k.graph.cells + # Cell 1's uninstantiated request should be cleaned up if it existed + assert "1" not in k._uninstantiated_execution_requests + # Cell 2 still has uninstantiated request (it was never in the graph) + assert "2" in k._uninstantiated_execution_requests + + async def test_sync_graph_run_and_delete_combined( + self, any_kernel: Kernel + ) -> None: + """Test simultaneous run and delete in single sync operation.""" + k = any_kernel + # Setup: Create A→B→C dependency chain + await k.run( + [ + ExecuteCellCommand(cell_id="0", code="x = 1"), + ExecuteCellCommand(cell_id="1", code="y = x + 1"), + ExecuteCellCommand(cell_id="2", code="z = y + 1"), + ] + ) + assert k.globals["z"] == 3 + assert len(k.graph.cells) == 3 + + # Action: Delete B, update A and C (C no longer depends on B) + await k.sync_graph( + cells={"0": "x = 10", "2": "z = x + 5"}, + run_ids=["0", "2"], + delete_ids=["1"], + ) + + # Verify: B deleted, A and C updated + assert len(k.graph.cells) == 2 + assert "0" in k.graph.cells + assert "2" in k.graph.cells + assert "1" not in k.graph.cells + assert k.globals["x"] == 10 + assert "y" not in k.globals + assert k.globals["z"] == 15 + + async def test_sync_graph_error_propagation( + self, any_kernel: Kernel + ) -> None: + """Test error propagation when sync introduces errors.""" + k = any_kernel + # Setup: Cell 0 defines x + await k.run( + [ + ExecuteCellCommand(cell_id="0", code="x = 1"), + ] + ) + assert "x" in k.globals + assert not k.errors + + # Action: Sync with new cell 1 that also defines x (multiple definition) + # Running both cells to trigger the error in both + await k.sync_graph( + cells={"0": "x = 1", "1": "x = 2"}, + run_ids=["0", "1"], + delete_ids=[], + ) + + # Verify: Multiple definition error in both cells + assert "x" not in k.globals + assert set(k.errors.keys()) == {"0", "1"} + assert isinstance(k.errors["0"][0], MultipleDefinitionError) + assert isinstance(k.errors["1"][0], MultipleDefinitionError) + + async def test_sync_graph_empty_sync(self, any_kernel: Kernel) -> None: + """Test no-op sync when already in sync.""" + k = any_kernel + # Setup: Create cells + await k.run( + [ + ExecuteCellCommand(cell_id="0", code="x = 1"), + ExecuteCellCommand(cell_id="1", code="y = 2"), + ] + ) + assert k.globals["x"] == 1 + assert k.globals["y"] == 2 + + # Action: Sync with same state (no changes) + await k.sync_graph( + cells={"0": "x = 1", "1": "y = 2"}, + run_ids=[], + delete_ids=[], + ) + + # Verify: Nothing changed + assert len(k.graph.cells) == 2 + assert k.globals["x"] == 1 + assert k.globals["y"] == 2 + assert not k.errors + + async def test_missing_module_detected(self, any_kernel: Kernel) -> None: + k = any_kernel + await k.run( + [er := ExecuteCellCommand(cell_id="0", code="import foobar")] + ) + cell = k.graph.cells[er.cell_id] + assert cell.exception is not None + assert isinstance(cell.exception, ModuleNotFoundError) + assert cell.exception.name == "foobar" + + await k.run( + [er := ExecuteCellCommand(cell_id="0", code="import marimo")] + ) + cell = k.graph.cells[er.cell_id] + assert cell.exception is None + + +class TestStrictExecution: + @staticmethod + async def test_cell_lambda( + strict_kernel: Kernel, exec_req: ExecReqProvider + ) -> None: + k = strict_kernel + await k.run( + [ + exec_req.get("""Y = 1"""), + exec_req.get( + """ + _x = 1 + X = 1 + L = lambda x: x + _x + X + Y + """ + ), + exec_req.get( + """ + V = L(1) + V + """ + ), + ] + ) + assert not k.errors + assert "X" in k.globals + assert "Y" in k.globals + assert "L" in k.globals + assert "V" in k.globals + assert k.globals["V"] == 4 + + @staticmethod + async def test_cell_indirect_lambda( + strict_kernel: Kernel, exec_req: ExecReqProvider + ) -> None: + k = strict_kernel + await k.run( + [ + exec_req.get("""Y = 1"""), + exec_req.get( + """ + _x = 1 + X = 1 + L = [lambda x: x + _x + X + Y] + """ + ), + exec_req.get("V = L[0](1)"), + ] + ) + assert not k.errors + assert "X" in k.globals + assert "Y" in k.globals + assert "L" in k.globals + assert "V" in k.globals + assert k.globals["V"] == 4 + + @staticmethod + async def test_cell_indirect_private( + strict_kernel: Kernel, exec_req: ExecReqProvider + ) -> None: + k = strict_kernel + await k.run( + [ + exec_req.get( + """ + Y = 1 + _y = 1 + def f(x): + return x + _y + """ + ), + exec_req.get( + """ + _x = 1 + X = 1 + L = [lambda x: f(x + _x + X + Y)] + """ + ), + exec_req.get("V = L[0](1)"), + ] + ) + assert not k.errors + assert "X" in k.globals + assert "Y" in k.globals + assert "L" in k.globals + assert "V" in k.globals + assert "f" in k.globals + assert k.globals["V"] == 5 + + @staticmethod + async def test_cell_copy_works( + strict_kernel: Kernel, exec_req: ExecReqProvider + ) -> None: + k = strict_kernel + await k.run( + [ + exec_req.get( + """ + class namespace: + ... X = namespace() X.count = 1 """ @@ -1355,6 +2409,10 @@ class namespace: assert k.globals["V1"] == 11 @staticmethod + @pytest.mark.xfail( + sys.version_info >= (3, 13), + reason="Namespace handling changes in Python 3.13", + ) async def test_cell_zero_copy_works( strict_kernel: Kernel, exec_req: ExecReqProvider ) -> None: @@ -1401,7 +2459,7 @@ async def test_wont_execute_bad_ref(execution_kernel: Kernel) -> None: k = execution_kernel await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="0", code=textwrap.dedent( """ @@ -1434,7 +2492,7 @@ async def test_runtime_failure(strict_kernel: Kernel) -> None: # succeed await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="0", code=textwrap.dedent( """ @@ -1446,7 +2504,7 @@ async def test_runtime_failure(strict_kernel: Kernel) -> None: """ ), ), - ExecutionRequest( + ExecuteCellCommand( cell_id="1", code=textwrap.dedent( """ @@ -1468,7 +2526,7 @@ async def test_runtime_resolution_private( # succeed await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="0", code=textwrap.dedent( """ @@ -1480,7 +2538,7 @@ async def test_runtime_resolution_private( """ ), ), - ExecutionRequest( + ExecuteCellCommand( cell_id="1", code=textwrap.dedent( """ @@ -1528,7 +2586,7 @@ async def test_incremental_import_doesnt_trigger_execution( # of already imported modules await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id=er.cell_id, code="import random; import time" ) ] @@ -1547,7 +2605,7 @@ async def test_transition_out_of_error_triggers_run( ) assert "x" not in k.globals - await k.delete_cell(DeleteCellRequest(cell_id=er.cell_id)) + await k.delete_cell(DeleteCellCommand(cell_id=er.cell_id)) assert "x" in k.globals async def test_different_import_same_def( @@ -1561,7 +2619,7 @@ async def test_different_import_same_def( # er.cell_id is still an import block, still defines random, # but brings random from another place; descendant should run await k.run( - [ExecutionRequest(er.cell_id, code="from random import random")] + [ExecuteCellCommand(er.cell_id, code="from random import random")] ) assert "random" in k.globals # randint is on toplevel random, not random.random @@ -1578,7 +2636,7 @@ async def test_after_import_error( ) assert "x" not in k.globals - await k.run([ExecutionRequest(er.cell_id, code="import time")]) + await k.run([ExecuteCellCommand(er.cell_id, code="import time")]) assert k.globals["x"] == 1 @@ -1674,24 +2732,130 @@ async def test_cell_output_cleared_on_rerun( cell = k.graph.cells[er.cell_id] assert cell.output is None - -class TestDisable: - async def test_disable_and_reenable_not_stale( - self, any_kernel: Kernel, exec_req: ExecReqProvider + async def test_formatter_import_error_handled( + self, mocked_kernel: MockedKernel, exec_req: ExecReqProvider ) -> None: - k = any_kernel - graph = k.graph + """Test that ImportError in formatter is handled gracefully. + + When a formatter (like _repr_html_) raises an ImportError, the cell + should still execute successfully, but the exception should be stored + in runner.exceptions for the missing_packages_hook to detect. + """ + k = mocked_kernel.k + assert k.packages_callbacks.package_manager is not None + await k.run( [ - exec_req.get( + er := exec_req.get( """ - import weakref - class namespace: - ... - ns = namespace() - ns.count = 0 - ref = weakref.ref(ns) - """ + class CustomObject: + def _repr_html_(self): + import missing_package + + obj = CustomObject() + obj + """ + ) + ] + ) + + # Cell should execute successfully (exception is in formatter, not execution) + cell = k.graph.cells[er.cell_id] + assert cell.exception is None + assert "obj" in k.globals + assert "CustomObject" in k.globals + + # Check that the output was still broadcast (even if formatting failed) + stream = MockStream(mocked_kernel.stream) + cell_notifications = stream.cell_notifications + output_ops = [op for op in cell_notifications if op.output is not None] + # Should have output, even if formatter failed (falls back to plain formatter) + assert len(output_ops) > 0 + op_names = [op.get("op") for op in stream.operations] + assert "missing-package-alert" in op_names + + async def test_formatter_module_not_found_error_handled( + self, mocked_kernel: MockedKernel, exec_req: ExecReqProvider + ) -> None: + """Test that ModuleNotFoundError in formatter is handled gracefully. + + When a formatter (like _repr_html_) raises a ModuleNotFoundError, the cell + should still execute successfully, but the exception should be stored + in runner.exceptions for the missing_packages_hook to detect. + """ + k = mocked_kernel.k + assert k.packages_callbacks.package_manager is not None + + await k.run( + [ + er := exec_req.get( + """ + class CustomObject: + def _repr_html_(self): + raise ModuleNotFoundError("another_missing_package", name="another_missing_package") + + obj = CustomObject() + obj + """ + ) + ] + ) + + # Cell should execute successfully (exception is in formatter, not execution) + cell = k.graph.cells[er.cell_id] + assert cell.exception is None + assert "obj" in k.globals + assert "CustomObject" in k.globals + + # Check that the output was still broadcast (even if formatting failed) + stream = MockStream(mocked_kernel.stream) + cell_notifications = stream.cell_notifications + output_ops = [op for op in cell_notifications if op.output is not None] + # Should have output, even if formatter failed (falls back to plain formatter) + assert len(output_ops) > 0 + op_names = [op.get("op") for op in stream.operations] + assert "missing-package-alert" in op_names + + async def test_marimo_submodule_not_reported_as_missing( + self, mocked_kernel: MockedKernel, exec_req: ExecReqProvider + ) -> None: + """A failed `import marimo.` must not raise a missing-package alert. + + marimo is always installed; a missing submodule can't be fixed by + installing marimo, so we never nudge callers (e.g. code_mode) to + install it. + """ + k = mocked_kernel.k + assert k.packages_callbacks.package_manager is not None + + await k.run( + [ + exec_req.get("import marimo.this_submodule_does_not_exist"), + ] + ) + + stream = MockStream(mocked_kernel.stream) + op_names = [op.get("op") for op in stream.operations] + assert "missing-package-alert" not in op_names + + +class TestDisable: + async def test_disable_and_reenable_not_stale( + self, any_kernel: Kernel, exec_req: ExecReqProvider + ) -> None: + k = any_kernel + graph = k.graph + await k.run( + [ + exec_req.get( + """ + import weakref + class namespace: + ... + ns = namespace() + ns.count = 0 + ref = weakref.ref(ns) + """ ), er_2 := exec_req.get("ref().count += 1"), ] @@ -1703,14 +2867,16 @@ class namespace: # shouldn't have passed through stale status # disable cell 2 await k.set_cell_config( - SetCellConfigRequest(configs={er_2.cell_id: {"disabled": True}}) + UpdateCellConfigCommand(configs={er_2.cell_id: {"disabled": True}}) ) assert k.globals["ns"].count == 1 assert not graph.get_stale() # re-enable cell 2 await k.set_cell_config( - SetCellConfigRequest(configs={er_2.cell_id: {"disabled": False}}) + UpdateCellConfigCommand( + configs={er_2.cell_id: {"disabled": False}} + ) ) # cell 2 should **not** have re-run assert k.globals["ns"].count == 1 @@ -1742,7 +2908,7 @@ class namespace: # disable and re-enable cell 2, making it stale in between; # cell 2 should re-run on enable await k.set_cell_config( - SetCellConfigRequest(configs={er_2.cell_id: {"disabled": True}}) + UpdateCellConfigCommand(configs={er_2.cell_id: {"disabled": True}}) ) await k.run( [ @@ -1761,14 +2927,16 @@ class namespace: ) assert k.globals["ns"].count == 10 - assert k.graph.get_stale() == set([er_2.cell_id]) + assert k.graph.get_stale() == {er_2.cell_id} # re-enable cell 2 await k.set_cell_config( - SetCellConfigRequest(configs={er_2.cell_id: {"disabled": False}}) + UpdateCellConfigCommand( + configs={er_2.cell_id: {"disabled": False}} + ) ) if k.lazy(): - assert k.graph.get_stale() == set([er_2.cell_id]) + assert k.graph.get_stale() == {er_2.cell_id} await k.run([er_2]) assert not k.graph.get_stale() # cell 2 should have re-run @@ -1800,31 +2968,36 @@ async def test_disable_and_reenable_tree( # disable cell 2 await k.set_cell_config( - SetCellConfigRequest(configs={er_2.cell_id: {"disabled": True}}) + UpdateCellConfigCommand(configs={er_2.cell_id: {"disabled": True}}) ) - await k.run([ExecutionRequest(cell_id=er_1.cell_id, code="x = 2")]) + await k.run([ExecuteCellCommand(cell_id=er_1.cell_id, code="x = 2")]) assert k.globals["x"] == 2 if k.lazy(): - assert graph.get_stale() == set( - [er_2.cell_id, er_3.cell_id, er_4.cell_id, er_5.cell_id] - ) + assert graph.get_stale() == { + er_2.cell_id, + er_3.cell_id, + er_4.cell_id, + er_5.cell_id, + } await k.run([er_5]) - assert graph.get_stale() == set( - [er_2.cell_id, er_3.cell_id, er_4.cell_id] - ) + assert graph.get_stale() == {er_2.cell_id, er_3.cell_id, er_4.cell_id} assert k.globals["zzz"] == 3 # enable cell 2: should run stale cells as a side-effect await k.set_cell_config( - SetCellConfigRequest(configs={er_2.cell_id: {"disabled": False}}) + UpdateCellConfigCommand( + configs={er_2.cell_id: {"disabled": False}} + ) ) assert k.globals["x"] == 2 assert k.globals["zzz"] == 3 if k.lazy(): - assert graph.get_stale() == set( - [er_2.cell_id, er_3.cell_id, er_4.cell_id] - ) + assert graph.get_stale() == { + er_2.cell_id, + er_3.cell_id, + er_4.cell_id, + } # runs er_3 and er_2, which are stale ancestors await k.run([er_4]) # stale cells **should have** updated @@ -1850,32 +3023,36 @@ async def test_disable_consecutive( # disable both cells await k.set_cell_config( - SetCellConfigRequest(configs={er_1.cell_id: {"disabled": True}}) + UpdateCellConfigCommand(configs={er_1.cell_id: {"disabled": True}}) ) await k.set_cell_config( - SetCellConfigRequest(configs={er_2.cell_id: {"disabled": True}}) + UpdateCellConfigCommand(configs={er_2.cell_id: {"disabled": True}}) ) # update the code of cell 1 -- both cells stale await k.run([er_1 := exec_req.get_with_id(er_1.cell_id, "x = 2")]) - assert graph.get_stale() == set([er_1.cell_id, er_2.cell_id]) + assert graph.get_stale() == {er_1.cell_id, er_2.cell_id} # enable cell 1, but 2 still disabled await k.set_cell_config( - SetCellConfigRequest(configs={er_1.cell_id: {"disabled": False}}) + UpdateCellConfigCommand( + configs={er_1.cell_id: {"disabled": False}} + ) ) if k.lazy(): - assert graph.get_stale() == set([er_1.cell_id, er_2.cell_id]) + assert graph.get_stale() == {er_1.cell_id, er_2.cell_id} await k.run([er_1]) assert k.globals["x"] == 2 - assert graph.get_stale() == set([er_2.cell_id]) + assert graph.get_stale() == {er_2.cell_id} # enable cell 2 await k.set_cell_config( - SetCellConfigRequest(configs={er_2.cell_id: {"disabled": False}}) + UpdateCellConfigCommand( + configs={er_2.cell_id: {"disabled": False}} + ) ) if k.lazy(): - assert graph.get_stale() == set([er_2.cell_id]) + assert graph.get_stale() == {er_2.cell_id} await k.run([er_2]) assert not graph.get_stale() @@ -1896,7 +3073,7 @@ async def test_disable_syntax_error( # disable cell await k.set_cell_config( - SetCellConfigRequest(configs={er_1.cell_id: {"disabled": True}}) + UpdateCellConfigCommand(configs={er_1.cell_id: {"disabled": True}}) ) # add a syntax error @@ -1910,7 +3087,9 @@ async def test_disable_syntax_error( # enable: should run await k.set_cell_config( - SetCellConfigRequest(configs={er_1.cell_id: {"disabled": False}}) + UpdateCellConfigCommand( + configs={er_1.cell_id: {"disabled": False}} + ) ) if k.lazy(): assert graph.cells[er_1.cell_id].stale @@ -1930,16 +3109,18 @@ async def test_disable_cycle( ] ) await k.set_cell_config( - SetCellConfigRequest(configs={er_1.cell_id: {"disabled": True}}) + UpdateCellConfigCommand(configs={er_1.cell_id: {"disabled": True}}) ) assert k.graph.cells[er_1.cell_id].config.disabled assert k.graph.cells[er_2.cell_id].disabled_transitively await k.set_cell_config( - SetCellConfigRequest(configs={er_2.cell_id: {"disabled": True}}) + UpdateCellConfigCommand(configs={er_2.cell_id: {"disabled": True}}) ) await k.set_cell_config( - SetCellConfigRequest(configs={er_1.cell_id: {"disabled": False}}) + UpdateCellConfigCommand( + configs={er_1.cell_id: {"disabled": False}} + ) ) assert k.graph.cells[er_1.cell_id].disabled_transitively assert k.graph.cells[er_2.cell_id].config.disabled @@ -1950,7 +3131,7 @@ async def test_disable_cycle( assert k.graph.cells[er_2.cell_id].config.disabled # breaking the cycle should re-enable - await k.run([ExecutionRequest(cell_id=er_1.cell_id, code="a = 0")]) + await k.run([ExecuteCellCommand(cell_id=er_1.cell_id, code="a = 0")]) assert not k.graph.cells[er_1.cell_id].stale assert not k.graph.cells[er_1.cell_id].disabled_transitively @@ -1965,7 +3146,7 @@ async def test_disable_cycle_incremental( k = any_kernel await k.run([er_1 := exec_req.get("a = b")]) await k.set_cell_config( - SetCellConfigRequest(configs={er_1.cell_id: {"disabled": True}}) + UpdateCellConfigCommand(configs={er_1.cell_id: {"disabled": True}}) ) assert k.graph.cells[er_1.cell_id].config.disabled @@ -1983,13 +3164,15 @@ async def test_enable_cycle_incremental( ] ) await k.set_cell_config( - SetCellConfigRequest(configs={er_1.cell_id: {"disabled": True}}) + UpdateCellConfigCommand(configs={er_1.cell_id: {"disabled": True}}) ) assert k.graph.cells[er_1.cell_id].config.disabled assert k.graph.cells[er_2.cell_id].disabled_transitively await k.set_cell_config( - SetCellConfigRequest(configs={er_1.cell_id: {"disabled": False}}) + UpdateCellConfigCommand( + configs={er_1.cell_id: {"disabled": False}} + ) ) assert not k.graph.cells[er_1.cell_id].config.disabled assert not k.graph.cells[er_2.cell_id].disabled_transitively @@ -2042,8 +3225,6 @@ async def f(): async def test_wait_for( any_kernel: Kernel, exec_req: ExecReqProvider ) -> None: - import asyncio - k = any_kernel await k.run( @@ -2180,10 +3361,13 @@ def blocking(): @staticmethod @pytest.mark.xfail( - condition=sys.platform == "win32" or sys.platform == "darwin", + condition=sys.platform == "win32" + or sys.platform == "darwin" + or sys.version_info >= (3, 14), reason=( "Bug in interaction with multiprocessing on Windows, macOS; " - "doesn't work in Jupyter either." + "doesn't work in Jupyter either. Seems to have issue in 3.14 " + "as well (pool doesn't copy vars from patched module correctly)." ), ) async def test_run_in_processpool_executor( @@ -2214,6 +3398,8 @@ def blocking(): ] ) assert not k.errors + assert not k.stderr.messages, k.stderr + assert not k.stdout.messages, k.stdout assert k.globals["res"] == "done" @@ -2222,11 +3408,11 @@ class TestSQL: async def test_sql_table(self, k: Kernel) -> None: await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="0", code="import marimo as mo", ), - ExecutionRequest( + ExecuteCellCommand( cell_id="1", code="df = mo.sql('SELECT * from t1')" ), ] @@ -2235,15 +3421,15 @@ async def test_sql_table(self, k: Kernel) -> None: await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="2", - code="import polars as pl; t1_df = pl.from_dict({'a': [42]})", # noqa: E501 + code="import polars as pl; t1_df = pl.from_dict({'a': [42]})", ), # cell 1 should automatically execute due to the definition of # t1 - ExecutionRequest( + ExecuteCellCommand( cell_id="3", - code="mo.sql('CREATE OR REPLACE TABLE t1 as SELECT * FROM t1_df')", # noqa: E501 + code="mo.sql('CREATE OR REPLACE TABLE t1 as SELECT * FROM t1_df')", ), ] ) @@ -2251,7 +3437,7 @@ async def test_sql_table(self, k: Kernel) -> None: # make sure cell 1 executed, defining df assert k.globals["t1_df"].to_dict(as_series=False) == {"a": [42]} - await k.delete_cell(DeleteCellRequest(cell_id="3")) + await k.delete_cell(DeleteCellCommand(cell_id="3")) # t1 should be dropped since it's an in-memory table; # cell 1 should re-run but will fail to find t1 assert "df" not in k.globals @@ -2259,11 +3445,11 @@ async def test_sql_table(self, k: Kernel) -> None: async def test_sql_table_with_duckdb(self, k: Kernel) -> None: await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="0", code="import marimo as mo", ), - ExecutionRequest( + ExecuteCellCommand( cell_id="1", code="df = duckdb.sql('SELECT * from t1')" ), ] @@ -2272,15 +3458,15 @@ async def test_sql_table_with_duckdb(self, k: Kernel) -> None: await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="2", - code="import polars as pl; t1_df = pl.from_dict({'a': [42]})", # noqa: E501 + code="import polars as pl; t1_df = pl.from_dict({'a': [42]})", ), # cell 1 should automatically execute due to the definition of # t1 - ExecutionRequest( + ExecuteCellCommand( cell_id="3", - code="duckdb.sql('CREATE OR REPLACE TABLE t1 as SELECT * FROM t1_df')", # noqa: E501 + code="duckdb.sql('CREATE OR REPLACE TABLE t1 as SELECT * FROM t1_df')", ), ] ) @@ -2288,7 +3474,7 @@ async def test_sql_table_with_duckdb(self, k: Kernel) -> None: # make sure cell 1 executed, defining df assert k.globals["t1_df"].to_dict(as_series=False) == {"a": [42]} - await k.delete_cell(DeleteCellRequest(cell_id="3")) + await k.delete_cell(DeleteCellCommand(cell_id="3")) # t1 should be dropped since it's an in-memory table; # cell 1 should re-run but will fail to find t1 assert "df" not in k.globals @@ -2296,11 +3482,11 @@ async def test_sql_table_with_duckdb(self, k: Kernel) -> None: async def test_sql_view(self, k: Kernel) -> None: await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="0", code="import marimo as mo", ), - ExecutionRequest( + ExecuteCellCommand( cell_id="1", code="df = mo.sql('SELECT * from view')" ), ] @@ -2311,9 +3497,9 @@ async def test_sql_view(self, k: Kernel) -> None: [ # cell 1 should automatically execute due to the definition of # t1 - ExecutionRequest( + ExecuteCellCommand( cell_id="2", - code="mo.sql('CREATE OR REPLACE VIEW view as SELECT 42')", # noqa: E501 + code="mo.sql('CREATE OR REPLACE VIEW view as SELECT 42')", ), ] ) @@ -2322,11 +3508,230 @@ async def test_sql_view(self, k: Kernel) -> None: # make sure cell 1 executed, defining df assert "df" in k.globals - await k.delete_cell(DeleteCellRequest(cell_id="2")) + await k.delete_cell(DeleteCellCommand(cell_id="2")) # view should be dropped since it's an in-memory table; # cell 1 should re-run but will fail to find t1 assert "df" not in k.globals + @pytest.mark.parametrize( + ("catalog", "schema"), + [ + (None, None), + (None, "main"), + (None, "custom-schema"), + ("memory", "main"), + ("memory", "custom-schema"), + # A schema containing a literal "." must not be confused with + # a catalog/schema separator when resolving the qualified name + # to drop. + (None, "my.schema"), + ], + ids=[ + "unqualified", + "explicit-schema", + "custom-schema", + "explicit-catalog-and-schema", + "explicit-catalog-and-custom-schema", + "dotted-schema", + ], + ) + async def test_sql_table_with_special_char_name_is_dropped( + self, k: Kernel, catalog: str | None, schema: str | None + ) -> None: + # Regression test for #10338: an in-memory table whose name needs + # quoting (e.g. a hyphen) must still be cleaned up. + import duckdb + + resolved_schema = schema or "main" + if resolved_schema != "main": + duckdb.execute(f'CREATE SCHEMA IF NOT EXISTS "{resolved_schema}"') + + def table_exists() -> bool: + row = duckdb.execute( + "SELECT count(*) FROM information_schema.tables " + "WHERE table_catalog = 'memory' AND table_schema = ? " + "AND table_name = 'manual-holdings'", + [resolved_schema], + ).fetchone() + assert row is not None + return bool(row[0] > 0) + + qualified_name = ".".join( + f'"{part}"' + for part in (catalog, schema, "manual-holdings") + if part + ) + + await k.run( + [ + ExecuteCellCommand( + cell_id=CellId_t("0"), code="import marimo as mo" + ), + ExecuteCellCommand( + cell_id=CellId_t("1"), + code=( + f"mo.sql('CREATE OR REPLACE TABLE {qualified_name} " + "AS SELECT 1 AS a')" + ), + ), + ] + ) + assert not k.errors + assert table_exists() + + # Deleting the defining cell triggers cleanup of the in-memory table. + await k.delete_cell(DeleteCellCommand(cell_id=CellId_t("1"))) + assert not table_exists() + + async def test_sql_table_on_attached_catalog_is_not_dropped( + self, k: Kernel + ) -> None: + """ + `CREATE TABLE .
- - + + - - + + - - + + - - + +
- Tutorial + Tutorial - Inputs + Inputs - Plots + Plots - Layout + Layout
- - + + Open in molab + + Open in molab - - + + Open in molab - - + + Open in molab
arg1``str Description of arg1.
arg2``int Description of arg2.
` is shorthand for + `.main.
`, which is ambiguous with `.
` + in the default "memory" catalog. Cleanup must resolve this against + the real attached catalogs, so it neither leaves the attached + table undropped-but-mistaken-for-memory, nor drops an unrelated, + same-named table living in a "memory" schema of the same name. + """ + import duckdb + + duckdb.execute("ATTACH ':memory:' AS other_db") + try: + + def attached_table_exists() -> bool: + row = duckdb.execute( + """ + SELECT count(*) FROM information_schema.tables + WHERE table_catalog = 'other_db' + AND table_schema = 'main' + AND table_name = 'holdings' + """ + ).fetchone() + assert row is not None + return bool(row[0] > 0) + + def memory_schema_table_exists() -> bool: + row = duckdb.execute( + """ + SELECT count(*) FROM information_schema.tables + WHERE table_catalog = 'memory' + AND table_schema = 'other_db' + AND table_name = 'holdings' + """ + ).fetchone() + assert row is not None + return bool(row[0] > 0) + + await k.run( + [ + ExecuteCellCommand( + cell_id=CellId_t("0"), code="import marimo as mo" + ), + ExecuteCellCommand( + cell_id=CellId_t("1"), + code=( + "mo.sql('CREATE OR REPLACE TABLE other_db.holdings " + "AS SELECT 1 AS a')" + ), + ), + ] + ) + assert not k.errors + assert attached_table_exists() + + # An unrelated table happens to be created afterwards in a + # "memory" schema with the same name as the attached catalog. + # Cleanup must not confuse the two. + duckdb.execute('CREATE SCHEMA IF NOT EXISTS "other_db"') + duckdb.execute( + 'CREATE TABLE memory."other_db".holdings AS SELECT 2 AS a' + ) + + # Deleting the defining cell must not drop the attached table, + # and must not touch the unrelated table in memory."other_db". + await k.delete_cell(DeleteCellCommand(cell_id=CellId_t("1"))) + assert attached_table_exists() + assert memory_schema_table_exists() + finally: + duckdb.execute("DETACH other_db") + duckdb.execute('DROP SCHEMA IF EXISTS memory."other_db" CASCADE') + + async def test_sql_table_qualified_with_default_catalog_is_dropped( + self, k: Kernel + ) -> None: + # `CREATE TABLE memory.holdings ...` is a two-part + # name whose qualifier happens to be "memory", the default catalog's + # own name. DuckDB resolves this to `memory.main.holdings`, not a + # schema literally named "memory"; cleanup must match. + import duckdb + + def table_exists() -> bool: + row = duckdb.execute( + "SELECT count(*) FROM information_schema.tables " + "WHERE table_catalog = 'memory' AND table_schema = 'main' " + "AND table_name = 'holdings'" + ).fetchone() + assert row is not None + return bool(row[0] > 0) + + await k.run( + [ + ExecuteCellCommand( + cell_id=CellId_t("0"), code="import marimo as mo" + ), + ExecuteCellCommand( + cell_id=CellId_t("1"), + code=( + "mo.sql('CREATE OR REPLACE TABLE memory.holdings " + "AS SELECT 1 AS a')" + ), + ), + ] + ) + assert not k.errors + assert table_exists() + + await k.delete_cell(DeleteCellCommand(cell_id=CellId_t("1"))) + assert not table_exists() + + async def test_sql_query_as_local_df(self, k: Kernel) -> None: + await k.run( + [ + ExecuteCellCommand( + cell_id="0", + code="import marimo as mo; import polars as pl", + ), + ExecuteCellCommand( + cell_id="1", + code="source_df = pl.DataFrame({'val': [42]})", + ), + ExecuteCellCommand( + cell_id="2", + code="df = mo.sql('SELECT * FROM source_df')", + ), + ] + ) + assert not k.errors + assert k.globals["df"].to_dict(as_series=False) == {"val": [42]} + + await k.run( + [ + ExecuteCellCommand( + cell_id="3", + code=""" +import duckdb +conn = duckdb.connect()""", + ), + ExecuteCellCommand( + cell_id="4", + code="df2 = mo.sql('SELECT * FROM source_df', engine=conn)", + ), + ] + ) + assert not k.errors + assert k.globals["df2"].to_dict(as_series=False) == {"val": [42]} + class TestStateTransitions: async def test_statuses_not_repeated_ok_run( @@ -2339,19 +3744,18 @@ async def test_statuses_not_repeated_ok_run( ] ) - cell_ops = [ - parse_raw(op_data, CellOp) - for op_name, op_data in mocked_kernel.stream.messages - if op_name == "cell-op" - ] + stream = MockStream(mocked_kernel.stream) + cell_notifications = stream.cell_notifications - n_queued = sum([1 for op in cell_ops if op.status == "queued"]) + n_queued = sum(1 for op in cell_notifications if op.status == "queued") assert n_queued == 1 - n_running = sum([1 for op in cell_ops if op.status == "running"]) + n_running = sum( + 1 for op in cell_notifications if op.status == "running" + ) assert n_running == 1 - n_idle = sum([1 for op in cell_ops if op.status == "idle"]) + n_idle = sum(1 for op in cell_notifications if op.status == "idle") assert n_idle == 1 async def test_statuses_not_repeated_on_stop( @@ -2364,19 +3768,17 @@ async def test_statuses_not_repeated_on_stop( ] ) - cell_ops = [ - parse_raw(op_data, CellOp) - for op_name, op_data in mocked_kernel.stream.messages - if op_name == "cell-op" - ] + cell_notifications = mocked_kernel.stream.cell_notifications - n_queued = sum([1 for op in cell_ops if op.status == "queued"]) + n_queued = sum(1 for op in cell_notifications if op.status == "queued") assert n_queued == 1 - n_running = sum([1 for op in cell_ops if op.status == "running"]) + n_running = sum( + 1 for op in cell_notifications if op.status == "running" + ) assert n_running == 1 - n_idle = sum([1 for op in cell_ops if op.status == "idle"]) + n_idle = sum(1 for op in cell_notifications if op.status == "idle") assert n_idle == 1 async def test_statuses_not_repeated_on_interruption( @@ -2386,24 +3788,23 @@ async def test_statuses_not_repeated_on_interruption( await k.run( [ exec_req.get( - "from marimo._runtime.control_flow import MarimoInterrupt; raise MarimoInterrupt()" # noqa: E501 + "from marimo._runtime.control_flow import MarimoInterrupt; raise MarimoInterrupt()" ), ] ) - cell_ops = [ - parse_raw(op_data, CellOp) - for op_name, op_data in mocked_kernel.stream.messages - if op_name == "cell-op" - ] + stream = MockStream(mocked_kernel.stream) + cell_notifications = stream.cell_notifications - n_queued = sum([1 for op in cell_ops if op.status == "queued"]) + n_queued = sum(1 for op in cell_notifications if op.status == "queued") assert n_queued == 1 - n_running = sum([1 for op in cell_ops if op.status == "running"]) + n_running = sum( + 1 for op in cell_notifications if op.status == "running" + ) assert n_running == 1 - n_idle = sum([1 for op in cell_ops if op.status == "idle"]) + n_idle = sum(1 for op in cell_notifications if op.status == "idle") assert n_idle == 1 async def test_statuses_not_repeated_on_exception( @@ -2416,19 +3817,18 @@ async def test_statuses_not_repeated_on_exception( ] ) - cell_ops = [ - parse_raw(op_data, CellOp) - for op_name, op_data in mocked_kernel.stream.messages - if op_name == "cell-op" - ] + stream = MockStream(mocked_kernel.stream) + cell_notifications = stream.cell_notifications - n_queued = sum([1 for op in cell_ops if op.status == "queued"]) + n_queued = sum(1 for op in cell_notifications if op.status == "queued") assert n_queued == 1 - n_running = sum([1 for op in cell_ops if op.status == "running"]) + n_running = sum( + 1 for op in cell_notifications if op.status == "running" + ) assert n_running == 1 - n_idle = sum([1 for op in cell_ops if op.status == "idle"]) + n_idle = sum(1 for op in cell_notifications if op.status == "idle") assert n_idle == 1 async def test_descendant_status_reset_to_idle_on_error( @@ -2442,22 +3842,21 @@ async def test_descendant_status_reset_to_idle_on_error( ] ) - cell_ops = [ - parse_raw(op_data, CellOp) - for op_name, op_data in mocked_kernel.stream.messages - if op_name == "cell-op" - ] + stream = MockStream(mocked_kernel.stream) + cell_notifications = stream.cell_notifications # er_1 and er_2 - n_queued = sum([1 for op in cell_ops if op.status == "queued"]) + n_queued = sum(1 for op in cell_notifications if op.status == "queued") assert n_queued == 2 # only er_1 runs - n_running = sum([1 for op in cell_ops if op.status == "running"]) + n_running = sum( + 1 for op in cell_notifications if op.status == "running" + ) assert n_running == 1 # er_1 and er_2 - n_idle = sum([1 for op in cell_ops if op.status == "idle"]) + n_idle = sum(1 for op in cell_notifications if op.status == "idle") assert n_idle == 2 assert k.graph.cells[er_1.cell_id].runtime_state == "idle" @@ -2481,29 +3880,28 @@ async def test_descendant_status_reset_to_idle_on_interrupt( ] ) - cell_ops = [ - parse_raw(op_data, CellOp) - for op_name, op_data in mocked_kernel.stream.messages - if op_name == "cell-op" - ] + stream = MockStream(mocked_kernel.stream) + cell_notifications = stream.cell_notifications # er_1 and er_2 - n_queued = sum([1 for op in cell_ops if op.status == "queued"]) + n_queued = sum(1 for op in cell_notifications if op.status == "queued") assert n_queued == 2 # only er_1 runs - n_running = sum([1 for op in cell_ops if op.status == "running"]) + n_running = sum( + 1 for op in cell_notifications if op.status == "running" + ) assert n_running == 1 # er_1 and er_2 - n_idle = sum([1 for op in cell_ops if op.status == "idle"]) + n_idle = sum(1 for op in cell_notifications if op.status == "idle") assert n_idle == 2 assert k.graph.cells[er_1.cell_id].runtime_state == "idle" assert k.graph.cells[er_2.cell_id].runtime_state == "idle" @staticmethod - async def test_variables_broadcast_only_on_change( + async def test_variables_broadcast_always( mocked_kernel: MockedKernel, exec_req: ExecReqProvider ) -> None: k = mocked_kernel.k @@ -2513,36 +3911,129 @@ async def test_variables_broadcast_only_on_change( er = exec_req.get("x = 1") await k.run([er]) initial_messages = len( - [m for m in stream.messages if m[0] == "variables"] + [ + m + for m in stream.operations + if isinstance(m, VariablesNotification) + ] ) assert initial_messages == 1 - # Re-running same cell shouldn't broadcast Variables + # Re-running same cell should now broadcast Variables stream.messages.clear() await k.run([er]) - assert not any(m[0] == "variables" for m in stream.messages) + assert ( + sum( + 1 + for m in stream.operations + if isinstance(m, VariablesNotification) + ) + == 1 + ) # Adding a new variable should broadcast Variables stream.messages.clear() er_2 = exec_req.get("y = 1") await k.run([er_2]) - assert sum(1 for m in stream.messages if m[0] == "variables") == 1 + assert ( + sum( + 1 + for m in stream.operations + if isinstance(m, VariablesNotification) + ) + == 1 + ) # Adding a new edge should broadcast Variables stream.messages.clear() await k.run([exec_req.get("z = y")]) - assert sum(1 for m in stream.messages if m[0] == "variables") == 1 + assert ( + sum( + 1 + for m in stream.operations + if isinstance(m, VariablesNotification) + ) + == 1 + ) - # Modifying value without changing edges/defs shouldn't broadcast + # Modifying value without changing edges/defs should now broadcast stream.messages.clear() er_2.code = "y = 2" await k.run([exec_req.get_with_id(er_2.cell_id, er_2.code)]) - assert not any(m[0] == "variables" for m in stream.messages) + assert ( + sum( + 1 + for m in stream.operations + if isinstance(m, VariablesNotification) + ) + == 1 + ) # Deleting a cell should broadcast Variables stream.messages.clear() - await k.delete_cell(DeleteCellRequest(cell_id=er_2.cell_id)) - assert sum(1 for m in stream.messages if m[0] == "variables") == 1 + await k.delete_cell(DeleteCellCommand(cell_id=er_2.cell_id)) + assert ( + sum( + 1 + for m in stream.operations + if isinstance(m, VariablesNotification) + ) + == 1 + ) + + @staticmethod + async def test_variables_broadcast_on_usage_change( + mocked_kernel: MockedKernel, exec_req: ExecReqProvider + ) -> None: + """Test that changing which variables a cell uses triggers a broadcast. + + This captures the bug where editing a cell to use fewer variables + from an imported module wouldn't update the frontend's dependency info. + """ + k = mocked_kernel.k + stream = MockStream(mocked_kernel.stream) + + # Cell 1: Define multiple variables + await k.run([exec_req.get("a = 1\nb = 2")]) + stream.messages.clear() + + # Cell 2: Use both variables + er_2 = exec_req.get("print(a)\nprint(b)") + await k.run([er_2]) + variables_msg_count = sum( + 1 for m in stream.operations if m["op"] == "variables" + ) + assert variables_msg_count == 1 + + # Check that both a and b show cell 2 in their used_by + variables_msg = next( + m for m in stream.operations if m["op"] == "variables" + ) + variables = variables_msg["variables"] + var_a = next(v for v in variables if v["name"] == "a") + var_b = next(v for v in variables if v["name"] == "b") + assert er_2.cell_id in var_a["used_by"] + assert er_2.cell_id in var_b["used_by"] + + # Edit cell 2 to only use variable 'a' + stream.messages.clear() + await k.run([exec_req.get_with_id(er_2.cell_id, "print(a)")]) + + # Should broadcast Variables because usage changed + variables_msg_count = sum( + 1 for m in stream.operations if m["op"] == "variables" + ) + assert variables_msg_count == 1 + + # Check that only a shows cell 2 in used_by now + variables_msg = next( + m for m in stream.operations if m["op"] == "variables" + ) + variables = variables_msg["variables"] + var_a = next(v for v in variables if v["name"] == "a") + var_b = next(v for v in variables if v["name"] == "b") + assert er_2.cell_id in var_a["used_by"] + assert er_2.cell_id not in var_b["used_by"] class TestErrorHandling: @@ -2551,32 +4042,153 @@ async def test_error_handling( ) -> None: k = mocked_kernel.k await k.run([exec_req.get("raise ValueError('some secret error')")]) - cell_ops = mocked_kernel.stream.cell_ops - error_cell_op = _filter_to_error_ops(cell_ops) - assert len(error_cell_op) == 1 - errors = _parse_error_output(error_cell_op[0]) + cell_notifications = mocked_kernel.stream.cell_notifications + error_cell_notification = _filter_to_error_ops(cell_notifications) + assert len(error_cell_notification) == 1 + errors = _parse_error_output(error_cell_notification[0]) assert len(errors) == 1 - assert errors[0].type == "exception" - assert ( - errors[0].msg - == "This cell raised an exception: ValueError('some secret error')" - ) + assert isinstance(errors[0], MarimoExceptionRaisedError) + assert errors[0].msg == "some secret error" + assert errors[0].exception_type == "ValueError" async def test_error_handling_in_run_mode( self, run_mode_kernel: MockedKernel, exec_req: ExecReqProvider ) -> None: k = run_mode_kernel.k await k.run([exec_req.get("raise ValueError('some secret error')")]) - cell_ops = run_mode_kernel.stream.cell_ops - error_cell_op = _filter_to_error_ops(cell_ops) - assert len(error_cell_op) == 1 - errors = _parse_error_output(error_cell_op[0]) + cell_notifications = run_mode_kernel.stream.cell_notifications + error_cell_notification = _filter_to_error_ops(cell_notifications) + assert len(error_cell_notification) == 1 + errors = _parse_error_output(error_cell_notification[0]) assert len(errors) == 1 - assert errors[0].type == "internal" + assert isinstance(errors[0], MarimoInternalError) assert errors[0].msg.startswith("An internal error occurred: ") + # Verify no traceback leaks via console output + for op in cell_notifications: + if op.console is not None: + console_list = ( + [op.console] + if not isinstance(op.console, list) + else op.console + ) + for console_output in console_list: + assert "some secret error" not in str( + console_output.data + ), "Traceback leaked to console in run mode" + + async def test_error_handling_in_run_mode_with_show_tracebacks( + self, run_mode_kernel: MockedKernel, exec_req: ExecReqProvider + ) -> None: + """When show_tracebacks is enabled, exceptions should not be + sanitized and should include a formatted traceback.""" + k = run_mode_kernel.k + k.user_config = copy.deepcopy(k.user_config) + k.user_config["runtime"]["show_tracebacks"] = True + await k.run([exec_req.get("raise ValueError('some secret error')")]) + cell_notifications = run_mode_kernel.stream.cell_notifications + error_cell_notification = _filter_to_error_ops(cell_notifications) + assert len(error_cell_notification) == 1 + errors = _parse_error_output(error_cell_notification[0]) + + assert len(errors) == 1 + assert isinstance(errors[0], MarimoExceptionRaisedError) + assert errors[0].msg == "some secret error" + assert errors[0].exception_type == "ValueError" + assert errors[0].traceback is not None + assert "ValueError" in errors[0].traceback + + async def test_name_error_includes_suggestion( + self, mocked_kernel: MockedKernel, exec_req: ExecReqProvider + ) -> None: + """A NameError's "Did you mean: ..." suggestion should not be + dropped from the error message (regression test).""" + k = mocked_kernel.k + await k.run( + [ + exec_req.get("aaa = 1"), + exec_req.get("print(aa)"), + ] + ) + cell_notifications = mocked_kernel.stream.cell_notifications + error_cell_notification = _filter_to_error_ops(cell_notifications) + assert len(error_cell_notification) == 1 + errors = _parse_error_output(error_cell_notification[0]) + + assert len(errors) == 1 + assert isinstance(errors[0], MarimoExceptionRaisedError) + assert errors[0].exception_type == "NameError" + # The base message is stable across all supported Python versions. + assert errors[0].msg.startswith("name 'aa' is not defined") + # Python 3.13 was the first release where `TracebackException` + # exposes the "Did you mean: ..." hint via `format_exception_only`, + # which is what the runtime uses to build the message. On 3.10-3.12 + # the helper degrades to the base message; see test_tracebacks.py. + if sys.version_info >= (3, 13): + assert "Did you mean: 'aaa'?" in errors[0].msg + + async def test_name_error_private_import_hint( + self, mocked_kernel: MockedKernel, exec_req: ExecReqProvider + ) -> None: + # MO-6804 / #10223: referencing another cell's underscore import + # (`from ibis import _`) fails because such names are cell-local. + # The bare Python NameError is misleading; marimo should explain + # the privacy rule and suggest aliasing the import. + k = mocked_kernel.k + await k.run( + [ + exec_req.get( + "import sys, types\n" + "_mod = types.ModuleType('fakelib')\n" + "_mod._ = 42\n" + "sys.modules['fakelib'] = _mod\n" + "ready = True" + ), + exec_req.get("ready\nfrom fakelib import _"), + exec_req.get("res = _ + 1"), + ] + ) + assert "res" not in k.globals + cell_notifications = mocked_kernel.stream.cell_notifications + error_cell_notification = _filter_to_error_ops(cell_notifications) + assert len(error_cell_notification) == 1 + errors = _parse_error_output(error_cell_notification[0]) + assert len(errors) == 1 + assert isinstance(errors[0], MarimoExceptionRaisedError) + assert errors[0].exception_type == "NameError" + assert "local to the cell" in errors[0].msg + assert "`from fakelib import _ as lib`" in errors[0].msg + # Blames the importing cell. + assert errors[0].raising_cell is not None + + async def test_name_error_private_variable_hint( + self, mocked_kernel: MockedKernel, exec_req: ExecReqProvider + ) -> None: + # Referencing another cell's private (underscore-prefixed) + # variable produces a hint about the privacy rule rather than a + # bare NameError with a mangled name. + k = mocked_kernel.k + await k.run( + [ + exec_req.get("_x = 1"), + exec_req.get("y = _x"), + ] + ) + assert "y" not in k.globals + cell_notifications = mocked_kernel.stream.cell_notifications + error_cell_notification = _filter_to_error_ops(cell_notifications) + assert len(error_cell_notification) == 1 + errors = _parse_error_output(error_cell_notification[0]) + assert len(errors) == 1 + assert isinstance(errors[0], MarimoExceptionRaisedError) + assert errors[0].exception_type == "NameError" + # The message shows the name as written, not the mangled form. + assert "Name `_x` is not defined" in errors[0].msg + assert "remove the leading underscore" in errors[0].msg + assert errors[0].raising_cell is not None + async def test_error_handling_in_run_mode_stop( self, run_mode_kernel: MockedKernel, exec_req: ExecReqProvider ) -> None: @@ -2587,34 +4199,565 @@ async def test_error_handling_in_run_mode_stop( exec_req.get("x = 20"), ] ) - cell_ops = run_mode_kernel.stream.cell_ops - error_cell_op = _filter_to_error_ops(cell_ops) - assert len(error_cell_op) == 2 - for op in error_cell_op: + cell_notifications = run_mode_kernel.stream.cell_notifications + error_cell_notification = _filter_to_error_ops(cell_notifications) + assert len(error_cell_notification) == 2 + for op in error_cell_notification: errors = _parse_error_output(op) assert len(errors) == 1 - assert errors[0].type == "internal" + assert isinstance(errors[0], MarimoInternalError) assert errors[0].msg.startswith("An internal error occurred: ") -def _parse_error_output(cell_op: CellOp) -> list[Error]: - error_output = cell_op.output +def test_notebook_dir_in_non_notebook_mode() -> None: + assert notebook_dir() == pathlib.Path().absolute() + assert notebook_location() == pathlib.Path().absolute() + + +async def test_future_annotations_not_inherited( + k: Kernel, exec_req: ExecReqProvider +) -> None: + await k.run( + [ + exec_req.get( + """ + class A: pass + def foo() -> A: + ... + anno = foo.__annotations__ + """ + ) + ] + ) + assert not k.errors + assert k.globals["A"] == k.globals["anno"]["return"] + + +class TestMarkdownHandling: + """Test markdown cell handling during kernel instantiation.""" + + async def test_markdown_cells_rendered_on_instantiate( + self, mocked_kernel: MockedKernel + ) -> None: + """Test that markdown cells are rendered and marked as completed on instantiate.""" + k = mocked_kernel.k + stream = mocked_kernel.stream + + # Create execution requests with markdown and regular cells + markdown_cell_code = ( + 'mo.md("# Hello World\\n\\nThis is **markdown**.")' + ) + regular_cell_code = "x = 1" + + execution_requests = [ + ExecuteCellCommand(cell_id="md_cell", code=markdown_cell_code), + ExecuteCellCommand(cell_id="regular_cell", code=regular_cell_code), + ] + + # Clear stream before instantiate + stream.messages.clear() + + # Add a cell that exports 'mo' to enable markdown processing + # This simulates the scenario where marimo has been imported + execution_requests.append( + ExecuteCellCommand(cell_id="mo_import", code="import marimo as mo") + ) + + # Create a creation request with auto_run=False to trigger the markdown handling + creation_request = CreateNotebookCommand( + execution_requests=execution_requests, + cell_ids=tuple(cell.cell_id for cell in execution_requests), + auto_run=False, + set_ui_element_value_request=UpdateUIElementCommand( + object_ids=[], + values=[], + ), + ) + + # Instantiate the kernel + await k.instantiate(creation_request) + + # Check that markdown cell was removed from uninstantiated requests + assert "md_cell" not in k._uninstantiated_execution_requests + # Regular cell should still be there + assert "regular_cell" in k._uninstantiated_execution_requests + + # Check that the markdown cell output was broadcast + cell_notifications = [ + deserialize_kernel_message(msg) for msg in stream.messages + ] + cell_notifications = [ + op for op in cell_notifications if isinstance(op, CellNotification) + ] + + # Find operations for the markdown cell + md_cell_notifications = [ + op for op in cell_notifications if op.cell_id == "md_cell" + ] + + # Should have at least one output operation and one stale operation + assert len(md_cell_notifications) >= 2 + + # Check that there's an output operation with HTML content + output_ops = [ + op for op in md_cell_notifications if op.output is not None + ] + assert len(output_ops) == 1 + + output_op = output_ops[0] + assert output_op.output.channel == CellChannel.OUTPUT + assert output_op.output.mimetype == "text/markdown" + assert "Hello World" in output_op.output.data + assert " None: + """Test that non-markdown cells are not affected by markdown handling.""" + k = mocked_kernel.k + stream = mocked_kernel.stream + + # Create execution requests with markdown and regular cells + r_markdown_cell_code = "mo.md(r'\\n')" + f_markdown_cell_code = "mo.md(f'{10*10}')" + t_markdown_cell_code = "mo.md(t'{10*10}')" + f_markdown_cell_code_escaped = "mo.md(f'{{mo}}')" + t_markdown_cell_code_escaped = "mo.md(t'{{mo}}')" + + execution_requests = [ + ExecuteCellCommand(cell_id="md_cell_r", code=r_markdown_cell_code), + ExecuteCellCommand(cell_id="md_cell_f", code=f_markdown_cell_code), + ExecuteCellCommand(cell_id="md_cell_t", code=t_markdown_cell_code), + ExecuteCellCommand( + cell_id="md_cell_fe", code=f_markdown_cell_code_escaped + ), + ExecuteCellCommand( + cell_id="md_cell_te", code=t_markdown_cell_code_escaped + ), + ExecuteCellCommand( + cell_id="mo_import", code="import marimo as mo" + ), + ] + + creation_request = CreateNotebookCommand( + execution_requests=execution_requests, + cell_ids=tuple(cell.cell_id for cell in execution_requests), + auto_run=False, + set_ui_element_value_request=UpdateUIElementCommand( + object_ids=[], + values=[], + ), + ) + + # Instantiate the kernel + await k.instantiate(creation_request) + + # Check that interpolated markdown cells are not matched + assert "md_cell_f" in k._uninstantiated_execution_requests + assert "md_cell_t" in k._uninstantiated_execution_requests + + # Without any interpolation can match for rendering + assert "md_cell_fe" not in k._uninstantiated_execution_requests + assert "md_cell_te" not in k._uninstantiated_execution_requests + assert "md_cell_r" not in k._uninstantiated_execution_requests + + async def test_non_markdown_cells_single_call( + self, mocked_kernel: MockedKernel + ) -> None: + """Test that non-markdown cells are not affected by markdown handling.""" + k = mocked_kernel.k + stream = mocked_kernel.stream + + # Create execution requests with markdown and regular cells + markdown_cell_code = "mo.md(mo.ui)" + regular_cell_code = "x = 0" + + execution_requests = [ + ExecuteCellCommand(cell_id="md_cell", code=markdown_cell_code), + ExecuteCellCommand(cell_id="regular_cell", code=regular_cell_code), + ExecuteCellCommand( + cell_id="mo_import", code="import marimo as mo" + ), + ] + + creation_request = CreateNotebookCommand( + execution_requests=execution_requests, + cell_ids=tuple(cell.cell_id for cell in execution_requests), + auto_run=False, + set_ui_element_value_request=UpdateUIElementCommand( + object_ids=[], + values=[], + ), + ) + + # Instantiate the kernel + await k.instantiate(creation_request) + + # Check that markdown cell is also in uninstantiated requests- + # since it does not contain a string + assert "md_cell" in k._uninstantiated_execution_requests + # Regular cell should still be there + assert "regular_cell" in k._uninstantiated_execution_requests + + async def test_non_markdown_cells_not_affected( + self, mocked_kernel: MockedKernel + ) -> None: + """Test that non-markdown cells are not affected by markdown handling.""" + k = mocked_kernel.k + stream = mocked_kernel.stream + + # Create execution requests with only regular cells + execution_requests = [ + ExecuteCellCommand(cell_id="cell1", code="x = 1"), + ExecuteCellCommand(cell_id="cell2", code="y = 2"), + ] + + creation_request = CreateNotebookCommand( + execution_requests=execution_requests, + cell_ids=tuple(cell.cell_id for cell in execution_requests), + auto_run=False, + set_ui_element_value_request=UpdateUIElementCommand( + object_ids=[], + values=[], + ), + ) + + stream.messages.clear() + await k.instantiate(creation_request) + + # All cells should remain in uninstantiated requests + assert "cell1" in k._uninstantiated_execution_requests + assert "cell2" in k._uninstantiated_execution_requests + + # Check that all cells were marked as stale + cell_notifications = [ + deserialize_kernel_message(msg) for msg in stream.messages + ] + cell_notifications = [ + op for op in cell_notifications if isinstance(op, CellNotification) + ] + + for cell_id in ["cell1", "cell2"]: + cell_notifications_for_id = [ + op for op in cell_notifications if op.cell_id == cell_id + ] + stale_ops = [ + op + for op in cell_notifications_for_id + if op.stale_inputs is not None + ] + assert len(stale_ops) == 1 + assert stale_ops[0].stale_inputs is True + + async def test_malformed_markdown_cells_marked_stale( + self, mocked_kernel: MockedKernel + ) -> None: + """Test that cells with syntax errors are marked as stale, not processed.""" + k = mocked_kernel.k + stream = mocked_kernel.stream + + # Create execution requests with malformed markdown cell + execution_requests = [ + ExecuteCellCommand( + cell_id="bad_cell", code="mo.md(" + ), # Syntax error + ExecuteCellCommand(cell_id="good_md", code='mo.md("# Good")'), + ] + + stream.messages.clear() + + # Add a cell that exports 'mo' to enable markdown processing + # This simulates the scenario where marimo has been imported + execution_requests.append( + ExecuteCellCommand(cell_id="mo_import", code="import marimo as mo") + ) + + creation_request = CreateNotebookCommand( + execution_requests=execution_requests, + cell_ids=tuple(cell.cell_id for cell in execution_requests), + auto_run=False, + set_ui_element_value_request=UpdateUIElementCommand( + object_ids=[], + values=[], + ), + ) + + await k.instantiate(creation_request) + + # Bad cell should remain in uninstantiated requests + assert "bad_cell" in k._uninstantiated_execution_requests + # Good markdown cell should be removed + assert "good_md" not in k._uninstantiated_execution_requests + + # Check operations + cell_notifications = [ + deserialize_kernel_message(msg) for msg in stream.messages + ] + cell_notifications = [ + op for op in cell_notifications if isinstance(op, CellNotification) + ] + + # Bad cell should be marked as stale + bad_cell_notifications = [ + op for op in cell_notifications if op.cell_id == "bad_cell" + ] + bad_stale_ops = [ + op for op in bad_cell_notifications if op.stale_inputs is not None + ] + assert len(bad_stale_ops) == 1 + assert bad_stale_ops[0].stale_inputs is True + + # Good cell should have output and be marked as not stale + good_cell_notifications = [ + op for op in cell_notifications if op.cell_id == "good_md" + ] + good_output_ops = [ + op for op in good_cell_notifications if op.output is not None + ] + assert len(good_output_ops) == 1 + assert "Good" in good_output_ops[0].output.data + + async def test_no_mo_available_all_cells_stale( + self, mocked_kernel: MockedKernel + ) -> None: + """Test that when 'mo' is not available, all cells are marked as stale.""" + k = mocked_kernel.k + stream = mocked_kernel.stream + + # Create execution requests with markdown cells + execution_requests = [ + ExecuteCellCommand(cell_id="md_cell1", code='mo.md("# Hello")'), + ExecuteCellCommand(cell_id="md_cell2", code='mo.md("## World")'), + ExecuteCellCommand(cell_id="regular_cell", code="x = 1"), + ] + + creation_request = CreateNotebookCommand( + execution_requests=execution_requests, + cell_ids=tuple(cell.cell_id for cell in execution_requests), + auto_run=False, + set_ui_element_value_request=UpdateUIElementCommand( + object_ids=[], + values=[], + ), + ) + + stream.messages.clear() + + # Ensure 'mo' is not in graph definitions by starting with empty graph + assert "mo" not in k.graph.definitions + + await k.instantiate(creation_request) + + # All cells should remain in uninstantiated requests since mo is not available + assert "md_cell1" in k._uninstantiated_execution_requests + assert "md_cell2" in k._uninstantiated_execution_requests + assert "regular_cell" in k._uninstantiated_execution_requests + + # Check that all cells were marked as stale + cell_notifications = [ + deserialize_kernel_message(msg) for msg in stream.messages + ] + cell_notifications = [ + op for op in cell_notifications if isinstance(op, CellNotification) + ] + + for cell_id in ["md_cell1", "md_cell2", "regular_cell"]: + cell_notifications_for_id = [ + op for op in cell_notifications if op.cell_id == cell_id + ] + stale_ops = [ + op + for op in cell_notifications_for_id + if op.stale_inputs is not None + ] + assert len(stale_ops) == 1 + assert stale_ops[0].stale_inputs is True + + # No cells should have output operations + output_ops = [op for op in cell_notifications if op.output is not None] + assert len(output_ops) == 0 + + +def _parse_error_output(cell_notification: CellNotification) -> list[Error]: + error_output = cell_notification.output assert error_output is not None assert error_output.channel == CellChannel.MARIMO_ERROR assert error_output.mimetype == "application/vnd.marimo+error" data = error_output.data + return cast(list[Error], data) - @dataclass - class Container: - errors: list[Error] - return parse_raw({"errors": data}, Container).errors - - -def _filter_to_error_ops(cell_ops: list[CellOp]) -> list[CellOp]: +def _filter_to_error_ops( + cell_notifications: list[CellNotification], +) -> list[CellNotification]: return [ op - for op in cell_ops + for op in cell_notifications if op.output is not None and op.output.channel == CellChannel.MARIMO_ERROR ] + + +class TestLaunchKernelEventLoop: + """Event-loop policy / factory selection in launch_kernel. + + The kernel subprocess must run on the Windows ProactorEventLoop so + user code can use asyncio.create_subprocess_exec() and other APIs + the SelectorEventLoop does not implement. The server keeps the + SelectorEventLoop because ConnectionDistributor relies on + loop.add_reader(). + + Each test exercises a single (platform, python-version) branch and + skips when the current runner doesn't match it. CI runs across all + major operating systems, so every branch is covered somewhere. + """ + + _HEAVY_DEPENDENCY_TARGETS = [ + "marimo._runtime.runtime.restore_signals", + "marimo._runtime.runtime.ThreadSafeStream", + "marimo._runtime.runtime.ThreadSafeStdout", + "marimo._runtime.runtime.ThreadSafeStderr", + "marimo._runtime.runtime.ThreadSafeStdin", + "marimo._runtime.runtime.marimo_pdb.MarimoPdb", + "marimo._runtime.runtime.Kernel", + "marimo._runtime.kernel_lifecycle.initialize_kernel_context", + "marimo._runtime.runtime.patches.patch_main_module", + "marimo._output.formatters.formatters.register_formatters", + ] + + class _StopAfterAsyncioRun(Exception): + """Sentinel raised from the mocked asyncio.run so we skip the + post-run teardown path (which touches a runtime context we + haven't initialized).""" + + @classmethod + def _fake_asyncio_run( + cls, coro: Coroutine[Any, Any, Any], **_kwargs: Any + ) -> None: + # Close the never-awaited coroutine to suppress the + # RuntimeWarning, then bail so we don't execute the post-run + # teardown. + coro.close() + raise cls._StopAfterAsyncioRun + + @classmethod + def _call_launch_kernel(cls, *, is_edit_mode: bool) -> None: + with pytest.raises(cls._StopAfterAsyncioRun): + launch_kernel( + control_queue=MagicMock(), + set_ui_element_queue=MagicMock(), + completion_queue=MagicMock(), + input_queue=MagicMock(), + stream_queue=MagicMock(), + socket_addr=None, + is_edit_mode=is_edit_mode, + configs={}, + app_metadata=default_app_metadata(), + user_config=DEFAULT_CONFIG, + virtual_file_storage=None, + redirect_console_to_browser=False, + ) + + @pytest.fixture + def harness(self): + """Neutralize launch_kernel's heavy dependencies so the test + only observes the event-loop policy / loop_factory decision.""" + with ExitStack() as stack: + for target in self._HEAVY_DEPENDENCY_TARGETS: + stack.enter_context(patch(target)) + # Swap the whole signal module ref to avoid registering real + # SIGINT/SIGTERM/SIGBREAK handlers in the test process. + stack.enter_context( + patch("marimo._runtime.runtime.signal", new=MagicMock()) + ) + run_mock = MagicMock(side_effect=self._fake_asyncio_run) + stack.enter_context(patch("asyncio.run", run_mock)) + yield run_mock + + @pytest.mark.skipif( + sys.platform == "win32", + reason="exercises the non-Windows branch", + ) + def test_non_windows_does_not_change_event_loop_policy(self, harness): + with patch.object(asyncio, "set_event_loop_policy") as set_policy: + self._call_launch_kernel(is_edit_mode=True) + + set_policy.assert_not_called() + assert harness.call_count == 1 + assert "loop_factory" not in harness.call_args.kwargs + + @pytest.mark.skipif( + sys.platform != "win32" or sys.version_info >= (3, 14), + reason="exercises the Windows pre-3.14 branch", + ) + def test_windows_pre_314_installs_proactor_event_loop_policy( + self, harness + ): + with ( + patch.object( + asyncio, "WindowsProactorEventLoopPolicy" + ) as policy_cls, + patch.object(asyncio, "set_event_loop_policy") as set_policy, + ): + self._call_launch_kernel(is_edit_mode=True) + + policy_cls.assert_called_once_with() + set_policy.assert_called_once_with(policy_cls.return_value) + # Pre-3.14 uses the policy API, not loop_factory. + assert "loop_factory" not in harness.call_args.kwargs + + @pytest.mark.skipif( + sys.platform != "win32" or sys.version_info < (3, 14), + reason="exercises the Windows 3.14+ branch", + ) + def test_windows_314_plus_uses_proactor_loop_factory(self, harness): + # Event loop policies are deprecated in 3.14; launch_kernel must + # pass ProactorEventLoop as the loop_factory to asyncio.run + # instead of mutating the global policy. + with ( + patch.object(asyncio, "ProactorEventLoop") as proactor_cls, + patch.object(asyncio, "set_event_loop_policy") as set_policy, + ): + self._call_launch_kernel(is_edit_mode=True) + + set_policy.assert_not_called() + assert harness.call_args.kwargs.get("loop_factory") is proactor_cls + + @pytest.mark.skipif( + sys.platform != "win32", + reason="run-mode guard is only meaningful on Windows", + ) + def test_run_mode_on_windows_does_not_touch_event_loop_policy( + self, harness + ): + # Run mode (not edit, not IPC) runs in-process on the server's + # loop and must NOT mutate the event loop policy — the server + # uses the Selector loop for ConnectionDistributor.add_reader(). + with patch.object(asyncio, "set_event_loop_policy") as set_policy: + self._call_launch_kernel(is_edit_mode=False) + + set_policy.assert_not_called() + assert "loop_factory" not in harness.call_args.kwargs diff --git a/tests/_runtime/test_runtime_cache.py b/tests/_runtime/test_runtime_cache.py new file mode 100644 index 00000000000..a0c12196359 --- /dev/null +++ b/tests/_runtime/test_runtime_cache.py @@ -0,0 +1,54 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from typing import TYPE_CHECKING + +from marimo._messaging.notification import CacheClearedNotification +from marimo._runtime.commands import ClearCacheCommand +from marimo._save.cache import CacheContext +from marimo._save.loaders.memory import MemoryLoader + +if TYPE_CHECKING: + from tests.conftest import MockedKernel + + +class _RecordingMemoryLoader(MemoryLoader): + def __init__(self) -> None: + super().__init__("test") + self.cleared = False + + def clear(self) -> None: + self.cleared = True + super().clear() + + +class _StubCacheContext(CacheContext): + def __init__(self, loader: MemoryLoader) -> None: + # CacheContext.loader calls self._loader(); wrap in a zero-arg callable. + self._loader = lambda: loader # type: ignore[assignment] + + @property + def last_hash(self) -> str | None: + return None + + +class TestClearCache: + async def test_clears_memory_loaders( + self, mocked_kernel: MockedKernel + ) -> None: + """Regression: clear_cache must purge non-persistence loaders too.""" + k = mocked_kernel.k + stream = mocked_kernel.stream + + memory_loader = _RecordingMemoryLoader() + k.globals["_cache_a"] = _StubCacheContext(memory_loader) + + await k.handle_message(ClearCacheCommand()) + + assert memory_loader.cleared is True + notifications = [ + op + for op in stream.operations + if isinstance(op, CacheClearedNotification) + ] + assert len(notifications) == 1 diff --git a/tests/_runtime/test_runtime_data_source_discovery.py b/tests/_runtime/test_runtime_data_source_discovery.py new file mode 100644 index 00000000000..d5281ae236a --- /dev/null +++ b/tests/_runtime/test_runtime_data_source_discovery.py @@ -0,0 +1,57 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import os +from unittest.mock import patch + +from marimo._messaging.notification import ( + DataSourceDiscoveryResultNotification, +) +from marimo._runtime.commands import DiscoverDataSourcesCommand +from marimo._types.ids import RequestId +from tests.conftest import MockedKernel + + +async def test_discovery_reads_live_kernel_environment( + mocked_kernel: MockedKernel, +) -> None: + kernel = mocked_kernel.k + stream = mocked_kernel.stream + + with ( + patch.dict( + os.environ, + { + "PGHOST": "host", + "PGUSER": "user", + "PGDATABASE": "database", + }, + clear=True, + ), + patch( + "marimo._data.data_source_discovery.plugins.pyiceberg." + "_load_resolved_catalogs", + return_value={}, + ) as load_resolved_catalogs, + ): + await kernel.handle_message( + DiscoverDataSourcesCommand(request_id=RequestId("discovery")) + ) + + results = [ + operation + for operation in stream.operations + if isinstance(operation, DataSourceDiscoveryResultNotification) + ] + assert len(results) == 1 + assert results[0].request_id == RequestId("discovery") + assert [source.integration for source in results[0].sources] == [ + "postgres" + ] + load_resolved_catalogs.assert_called_once_with( + environment={ + "PGHOST": "host", + "PGUSER": "user", + "PGDATABASE": "database", + } + ) diff --git a/tests/_runtime/test_runtime_datasets.py b/tests/_runtime/test_runtime_datasets.py new file mode 100644 index 00000000000..720d0e057cb --- /dev/null +++ b/tests/_runtime/test_runtime_datasets.py @@ -0,0 +1,746 @@ +# Copyright 2026 Marimo. All rights reserved. + +from __future__ import annotations + +import pytest + +from marimo._dependencies.dependencies import DependencyManager +from marimo._messaging.notification import ( + DataSourceConnectionsNotification, + SQLDatabaseMetadata, + SQLMetadata, + SQLSchemaListPreviewNotification, + SQLTableListPreviewNotification, + SQLTablePreviewNotification, + ValidateSQLResultNotification, +) +from marimo._runtime.commands import ( + ExecuteCellCommand, + ListDataSourceConnectionCommand, + ListSQLSchemasCommand, + ListSQLTablesCommand, + PreviewSQLTableCommand, + ValidateSQLCommand, +) +from marimo._sql.engines.duckdb import INTERNAL_DUCKDB_ENGINE +from marimo._sql.parse import SqlCatalogCheckResult, SqlParseResult +from marimo._types.ids import CellId_t, RequestId +from tests.conftest import MockedKernel + +HAS_SQL = DependencyManager.duckdb.has() and DependencyManager.polars.has() + + +DUCKDB_CONN = "duckdb_conn" +SQLITE_CONN = "sqlite_conn" + + +@pytest.fixture +async def connection_requests() -> list[ExecuteCellCommand]: + return [ + ExecuteCellCommand(cell_id=CellId_t("0"), code="import duckdb"), + ExecuteCellCommand( + cell_id=CellId_t("1"), + code=f"{DUCKDB_CONN} = duckdb.connect(':memory:')", + ), + ExecuteCellCommand(cell_id=CellId_t("2"), code="import sqlite3"), + ExecuteCellCommand( + cell_id=CellId_t("3"), + code=f"{SQLITE_CONN} = sqlite3.connect(':memory:')", + ), + ] + + +# @pytest.mark.skipif(not HAS_SQL, reason="SQL deps not available") +# class TestGetSQLConnection: +# async def test_non_existent_engine( +# self, mocked_kernel: MockedKernel +# ) -> None: +# k = mocked_kernel.k + +# # Non-existent engine +# k.get_sql_connection(DUCKDB_CONN) +# assert k.get_sql_connection(DUCKDB_CONN) == (None, "Engine not found") + +# async def test_created_engine( +# self, +# mocked_kernel: MockedKernel, +# connection_requests: list[ExecuteCellCommand], +# ) -> None: +# k = mocked_kernel.k + +# # Create duckdb and sqlite connections +# await k.run(connection_requests) + +# connection, error = k.get_sql_connection("duckdb_conn") +# assert connection is not None +# assert error is None + +# # Test with SQLite engine (which is a QueryEngine, but not a EngineCatalog) +# connection, error = k.get_sql_connection(SQLITE_CONN) +# assert connection is not None +# assert error is None + + +@pytest.mark.skipif(not HAS_SQL, reason="SQL deps not available") +class TestPreviewSQLTable: + async def test_non_existent_engine( + self, mocked_kernel: MockedKernel + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + preview_sql_table_request = PreviewSQLTableCommand( + request_id=RequestId("0"), + engine=DUCKDB_CONN, + database="test", + schema="test", + table_name="t1", + ) + await k.handle_message(preview_sql_table_request) + + preview_sql_table_results = [ + op + for op in stream.operations + if isinstance(op, SQLTablePreviewNotification) + ] + assert preview_sql_table_results == [ + SQLTablePreviewNotification( + request_id=RequestId("0"), + table=None, + error="Engine not found", + metadata=SQLMetadata( + connection=DUCKDB_CONN, database="test", schema="test" + ), + ) + ] + + async def test_catalog_engine( + self, + mocked_kernel: MockedKernel, + connection_requests: list[ExecuteCellCommand], + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run(connection_requests) + + preview_sql_table_request = PreviewSQLTableCommand( + request_id=RequestId("0"), + engine=DUCKDB_CONN, + database="test", + schema="test", + table_name="t1", + ) + await k.handle_message(preview_sql_table_request) + + preview_sql_table_results = [ + op + for op in stream.operations + if isinstance(op, SQLTablePreviewNotification) + ] + assert preview_sql_table_results == [ + SQLTablePreviewNotification( + request_id=RequestId("0"), + table=None, + error=None, + metadata=SQLMetadata( + connection=DUCKDB_CONN, database="test", schema="test" + ), + ) + ] + + async def test_query_engine( + self, + mocked_kernel: MockedKernel, + connection_requests: list[ExecuteCellCommand], + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run(connection_requests) + + preview_sql_table_request = PreviewSQLTableCommand( + request_id=RequestId("0"), + engine=SQLITE_CONN, + database="test", + schema="test", + table_name="t1", + ) + await k.handle_message(preview_sql_table_request) + + preview_sql_table_results = [ + op + for op in stream.operations + if isinstance(op, SQLTablePreviewNotification) + ] + assert preview_sql_table_results == [ + SQLTablePreviewNotification( + request_id=RequestId("0"), + table=None, + error="Connection does not support catalog operations", + metadata=SQLMetadata( + connection=SQLITE_CONN, database="test", schema="test" + ), + ) + ] + + +@pytest.mark.skipif(not HAS_SQL, reason="SQL deps not available") +class TestPreviewSQLSchemaList: + async def test_non_existent_engine( + self, mocked_kernel: MockedKernel + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + preview_sql_schema_list_request = ListSQLSchemasCommand( + request_id=RequestId("0"), + engine=DUCKDB_CONN, + database="test", + ) + await k.handle_message(preview_sql_schema_list_request) + preview_sql_schema_list_results = [ + op + for op in stream.operations + if isinstance(op, SQLSchemaListPreviewNotification) + ] + assert preview_sql_schema_list_results == [ + SQLSchemaListPreviewNotification( + request_id=RequestId("0"), + schemas=[], + error="Engine not found", + metadata=SQLDatabaseMetadata( + connection=DUCKDB_CONN, database="test" + ), + ) + ] + + async def test_catalog_engine( + self, + mocked_kernel: MockedKernel, + connection_requests: list[ExecuteCellCommand], + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run(connection_requests) + + preview_sql_schema_list_request = ListSQLSchemasCommand( + request_id=RequestId("0"), + engine=DUCKDB_CONN, + database="test", + ) + await k.handle_message(preview_sql_schema_list_request) + + preview_sql_schema_list_results = [ + op + for op in stream.operations + if isinstance(op, SQLSchemaListPreviewNotification) + ] + assert preview_sql_schema_list_results == [ + SQLSchemaListPreviewNotification( + request_id=RequestId("0"), + schemas=[], + error=None, + metadata=SQLDatabaseMetadata( + connection=DUCKDB_CONN, database="test" + ), + ) + ] + + async def test_query_engine( + self, + mocked_kernel: MockedKernel, + connection_requests: list[ExecuteCellCommand], + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run(connection_requests) + + preview_sql_schema_list_request = ListSQLSchemasCommand( + request_id=RequestId("0"), + engine=SQLITE_CONN, + database="test", + ) + await k.handle_message(preview_sql_schema_list_request) + + preview_sql_schema_list_results = [ + op + for op in stream.operations + if isinstance(op, SQLSchemaListPreviewNotification) + ] + assert preview_sql_schema_list_results == [ + SQLSchemaListPreviewNotification( + request_id=RequestId("0"), + schemas=[], + error="Connection does not support catalog operations", + metadata=SQLDatabaseMetadata( + connection=SQLITE_CONN, database="test" + ), + ) + ] + + async def test_nested_schema_path_echoed( + self, + mocked_kernel: MockedKernel, + connection_requests: list[ExecuteCellCommand], + ) -> None: + """A request with a schema_path lists the child schemas at that path + and echoes the path in the response metadata. Catalog engines without + hierarchical namespaces return an empty list.""" + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run(connection_requests) + + preview_sql_schema_list_request = ListSQLSchemasCommand( + request_id=RequestId("0"), + engine=DUCKDB_CONN, + database="test", + schema_path=["sub"], + ) + await k.handle_message(preview_sql_schema_list_request) + + results = [ + op + for op in stream.operations + if isinstance(op, SQLSchemaListPreviewNotification) + ] + assert results == [ + SQLSchemaListPreviewNotification( + request_id=RequestId("0"), + schemas=[], + error=None, + metadata=SQLDatabaseMetadata( + connection=DUCKDB_CONN, + database="test", + schema_path=["sub"], + ), + ) + ] + + +@pytest.mark.skipif(not HAS_SQL, reason="SQL deps not available") +class TestPreviewSQLTableList: + async def test_non_existent_engine( + self, mocked_kernel: MockedKernel + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + preview_sql_table_list_request = ListSQLTablesCommand( + request_id=RequestId("0"), + engine=DUCKDB_CONN, + database="test", + schema="test", + ) + await k.handle_message(preview_sql_table_list_request) + preview_sql_table_list_results = [ + op + for op in stream.operations + if isinstance(op, SQLTableListPreviewNotification) + ] + assert preview_sql_table_list_results == [ + SQLTableListPreviewNotification( + request_id=RequestId("0"), + tables=[], + error="Engine not found", + metadata=SQLMetadata( + connection=DUCKDB_CONN, database="test", schema="test" + ), + ) + ] + + async def test_catalog_engine( + self, + mocked_kernel: MockedKernel, + connection_requests: list[ExecuteCellCommand], + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run(connection_requests) + + preview_sql_table_list_request = ListSQLTablesCommand( + request_id=RequestId("0"), + engine=DUCKDB_CONN, + database="test", + schema="test", + ) + await k.handle_message(preview_sql_table_list_request) + + preview_sql_table_list_results = [ + op + for op in stream.operations + if isinstance(op, SQLTableListPreviewNotification) + ] + assert preview_sql_table_list_results == [ + SQLTableListPreviewNotification( + request_id=RequestId("0"), + tables=[], + error=None, + metadata=SQLMetadata( + connection=DUCKDB_CONN, database="test", schema="test" + ), + ) + ] + + async def test_query_engine( + self, + mocked_kernel: MockedKernel, + connection_requests: list[ExecuteCellCommand], + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run(connection_requests) + + preview_sql_table_list_request = ListSQLTablesCommand( + request_id=RequestId("0"), + engine=SQLITE_CONN, + database="test", + schema="test", + ) + await k.handle_message(preview_sql_table_list_request) + + preview_sql_table_list_results = [ + op + for op in stream.operations + if isinstance(op, SQLTableListPreviewNotification) + ] + assert preview_sql_table_list_results == [ + SQLTableListPreviewNotification( + request_id=RequestId("0"), + tables=[], + error="Connection does not support catalog operations", + metadata=SQLMetadata( + connection=SQLITE_CONN, database="test", schema="test" + ), + ) + ] + + +class TestPreviewDatasourceConnection: + async def test_non_existent_engine( + self, mocked_kernel: MockedKernel + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + preview_datasource_connection_request = ( + ListDataSourceConnectionCommand(engine=DUCKDB_CONN) + ) + await k.handle_message(preview_datasource_connection_request) + preview_datasource_connection_results = [ + op + for op in stream.operations + if isinstance(op, DataSourceConnectionsNotification) + ] + assert preview_datasource_connection_results == [] + + @pytest.mark.skipif(not HAS_SQL, reason="SQL deps not available") + async def test_query_only_engine_is_broadcast( + self, + mocked_kernel: MockedKernel, + connection_requests: list[ExecuteCellCommand], + ) -> None: + """Regression: query-only engines (QueryEngine, not EngineCatalog) must broadcast.""" + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run(connection_requests) + + baseline = sum( + 1 + for op in stream.operations + if isinstance(op, DataSourceConnectionsNotification) + ) + + await k.handle_message( + ListDataSourceConnectionCommand(engine=SQLITE_CONN) + ) + + results = [ + op + for op in stream.operations + if isinstance(op, DataSourceConnectionsNotification) + ] + assert len(results) == baseline + 1 + connection = results[-1].connections[0] + assert connection.name == SQLITE_CONN + assert connection.databases == [] + + @pytest.mark.xfail( + reason="Should have only 2 connections (duckdb and sqlite)" + ) + async def test_engines( + self, + mocked_kernel: MockedKernel, + connection_requests: list[ExecuteCellCommand], + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run(connection_requests) + + preview_datasource_connection_request = ( + ListDataSourceConnectionCommand(engine=DUCKDB_CONN) + ) + await k.handle_message(preview_datasource_connection_request) + + preview_datasource_connection_results = [ + op + for op in stream.operations + if isinstance(op, DataSourceConnectionsNotification) + ] + assert len(preview_datasource_connection_results) == 2 + + +@pytest.mark.skipif(not HAS_SQL, reason="SQL deps not available") +class TestSQLValidate: + async def test_non_existent_engine( + self, mocked_kernel: MockedKernel + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + # Non-existent engine + validate_sql_request = ValidateSQLCommand( + request_id=RequestId("0"), + engine=DUCKDB_CONN, + query="SELECT * from t1", + only_parse=False, + ) + await k.handle_message(validate_sql_request) + validate_sql_results = [ + op + for op in stream.operations + if isinstance(op, ValidateSQLResultNotification) + ] + assert validate_sql_results == [ + ValidateSQLResultNotification( + request_id=RequestId("0"), + parse_result=None, + validate_result=None, + error="Failed to get engine duckdb_conn", + ) + ] + + async def test_internal_engine_and_valid_query( + self, mocked_kernel: MockedKernel + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + # Internal engine and valid query + validate_sql_request = ValidateSQLCommand( + request_id=RequestId("1"), + engine=INTERNAL_DUCKDB_ENGINE, + query="SELECT 1, 2", + only_parse=False, + ) + await k.handle_message(validate_sql_request) + validate_sql_results = [ + op + for op in stream.operations + if isinstance(op, ValidateSQLResultNotification) + ] + assert validate_sql_results[-1] == ValidateSQLResultNotification( + request_id=RequestId("1"), + parse_result=SqlParseResult(success=True, errors=[]), + validate_result=SqlCatalogCheckResult( + success=True, error_message=None + ), + error=None, + ) + + async def test_internal_engine_and_invalid_query( + self, mocked_kernel: MockedKernel + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + # Internal engine and invalid query + validate_sql_request = ValidateSQLCommand( + request_id=RequestId("2"), + engine=INTERNAL_DUCKDB_ENGINE, + query="SELECT * FROM t1", + only_parse=False, + ) + await k.handle_message(validate_sql_request) + validate_sql_results = [ + op + for op in stream.operations + if isinstance(op, ValidateSQLResultNotification) + ] + latest_validate_sql_result = validate_sql_results[-1] + assert latest_validate_sql_result.request_id == RequestId("2") + + assert latest_validate_sql_result.parse_result is not None + # query is syntactically valid + assert latest_validate_sql_result.parse_result.success is True + assert len(latest_validate_sql_result.parse_result.errors) == 0 + + assert latest_validate_sql_result.validate_result is not None + assert latest_validate_sql_result.validate_result.success is False + assert ( + latest_validate_sql_result.validate_result.error_message + is not None + ) + assert latest_validate_sql_result.error is None + + stream.operations.clear() + + async def test_other_engine_and_valid_query( + self, + mocked_kernel: MockedKernel, + connection_requests: list[ExecuteCellCommand], + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + # Handle other engines + await k.run(connection_requests) + validate_sql_request = ValidateSQLCommand( + request_id=RequestId("3"), + engine=SQLITE_CONN, + query="SELECT 1, 2", + only_parse=False, + ) + await k.handle_message(validate_sql_request) + validate_sql_results = [ + op + for op in stream.operations + if isinstance(op, ValidateSQLResultNotification) + ] + assert ( + validate_sql_results[-1] + == ValidateSQLResultNotification( + request_id=RequestId("3"), + parse_result=None, # Currently does not support parse errors for non-duckdb engines + validate_result=SqlCatalogCheckResult( + success=True, error_message=None + ), + error=None, + ) + ) + + async def test_only_parse_with_no_dialect( + self, + mocked_kernel: MockedKernel, + connection_requests: list[ExecuteCellCommand], + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run(connection_requests) + + validate_sql_request = ValidateSQLCommand( + request_id=RequestId("4"), + engine=SQLITE_CONN, + query="SELECT 1, 2", + only_parse=True, + ) + await k.handle_message(validate_sql_request) + + validate_sql_results = [ + op + for op in stream.operations + if isinstance(op, ValidateSQLResultNotification) + ] + assert validate_sql_results[-1] == ValidateSQLResultNotification( + request_id=RequestId("4"), + parse_result=None, + validate_result=None, + error="Dialect is required when only parsing", + ) + + async def test_only_parse_unsupported_dialect( + self, + mocked_kernel: MockedKernel, + connection_requests: list[ExecuteCellCommand], + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run(connection_requests) + + validate_sql_request = ValidateSQLCommand( + request_id=RequestId("5"), + dialect="sqlite", + query="SELECT 1, 2", + only_parse=True, + ) + await k.handle_message(validate_sql_request) + + validate_sql_results = [ + op + for op in stream.operations + if isinstance(op, ValidateSQLResultNotification) + ] + assert validate_sql_results[-1] == ValidateSQLResultNotification( + request_id=RequestId("5"), + parse_result=None, + validate_result=None, + error="Unsupported dialect: sqlite", + ) + + async def test_only_parse_duckdb( + self, + mocked_kernel: MockedKernel, + connection_requests: list[ExecuteCellCommand], + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run(connection_requests) + + validate_sql_request = ValidateSQLCommand( + request_id=RequestId("6"), + dialect="duckdb", + query="SELECT 1, 2", + only_parse=True, + ) + await k.handle_message(validate_sql_request) + + validate_sql_results = [ + op + for op in stream.operations + if isinstance(op, ValidateSQLResultNotification) + ] + assert validate_sql_results[-1] == ValidateSQLResultNotification( + request_id=RequestId("6"), + parse_result=SqlParseResult(success=True, errors=[]), + validate_result=None, + error=None, + ) + + async def test_validate_but_no_engine( + self, + mocked_kernel: MockedKernel, + connection_requests: list[ExecuteCellCommand], + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run(connection_requests) + + validate_sql_request = ValidateSQLCommand( + request_id=RequestId("7"), + query="SELECT 1, 2", + only_parse=False, + ) + await k.handle_message(validate_sql_request) + + validate_sql_results = [ + op + for op in stream.operations + if isinstance(op, ValidateSQLResultNotification) + ] + assert validate_sql_results[-1] == ValidateSQLResultNotification( + request_id=RequestId("7"), + parse_result=None, + validate_result=None, + error="Engine is required for validating catalog", + ) diff --git a/tests/_runtime/test_runtime_external_storage.py b/tests/_runtime/test_runtime_external_storage.py new file mode 100644 index 00000000000..a5e89ef951e --- /dev/null +++ b/tests/_runtime/test_runtime_external_storage.py @@ -0,0 +1,955 @@ +# Copyright 2026 Marimo. All rights reserved. + +from __future__ import annotations + +import threading +from unittest.mock import AsyncMock, MagicMock + +import pytest +from dirty_equals import IsPositiveFloat, IsStr + +from marimo._data._external_storage.models import ( + StorageEntry, + StorageListResult, +) +from marimo._data._external_storage.storage import Obstore +from marimo._dependencies.dependencies import DependencyManager +from marimo._messaging.notification import ( + StorageDownloadReadyNotification, + StorageEntriesNotification, +) +from marimo._runtime.commands import ( + ExecuteCellCommand, + StorageDownloadCommand, + StorageListEntriesCommand, +) +from marimo._types.ids import CellId_t, RequestId, VariableName +from tests.conftest import MockedKernel + +HAS_OBSTORE = DependencyManager.obstore.has() + +STORAGE_VAR = "my_store" + + +class TestExternalStorageErrors: + """Error-handling tests that don't require a real storage backend.""" + + async def test_list_entries_variable_not_found( + self, mocked_kernel: MockedKernel + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + request = StorageListEntriesCommand( + request_id=RequestId("req-1"), + namespace="nonexistent_var", + limit=100, + prefix=None, + ) + await k.handle_message(request) + + results = [ + op + for op in stream.operations + if isinstance(op, StorageEntriesNotification) + ] + assert results == [ + StorageEntriesNotification( + request_id=RequestId("req-1"), + entries=[], + namespace="nonexistent_var", + prefix=None, + error="Variable 'nonexistent_var' not found", + ) + ] + + async def test_list_entries_incompatible_backend( + self, mocked_kernel: MockedKernel + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run( + [ + ExecuteCellCommand( + cell_id=CellId_t("0"), + code='not_storage = "just a string"', + ), + ] + ) + + request = StorageListEntriesCommand( + request_id=RequestId("req-2"), + namespace="not_storage", + limit=100, + prefix=None, + ) + await k.handle_message(request) + + results = [ + op + for op in stream.operations + if isinstance(op, StorageEntriesNotification) + ] + assert results == [ + StorageEntriesNotification( + request_id=RequestId("req-2"), + entries=[], + namespace="not_storage", + prefix=None, + error=( + "Variable 'not_storage' is not a compatible " + "storage backend (expected obstore, huggingface_hub.HfApi, or fsspec)" + ), + ) + ] + + async def test_download_variable_not_found( + self, mocked_kernel: MockedKernel + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + request = StorageDownloadCommand( + request_id=RequestId("req-3"), + namespace="nonexistent_var", + path="data/file.csv", + ) + await k.handle_message(request) + + results = [ + op + for op in stream.operations + if isinstance(op, StorageDownloadReadyNotification) + ] + assert results == [ + StorageDownloadReadyNotification( + request_id=RequestId("req-3"), + url=None, + filename=None, + error="Variable 'nonexistent_var' not found", + ) + ] + + async def test_download_incompatible_backend( + self, mocked_kernel: MockedKernel + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run( + [ + ExecuteCellCommand( + cell_id=CellId_t("0"), + code="not_storage = 42", + ), + ] + ) + + request = StorageDownloadCommand( + request_id=RequestId("req-4"), + namespace="not_storage", + path="data/file.csv", + ) + await k.handle_message(request) + + results = [ + op + for op in stream.operations + if isinstance(op, StorageDownloadReadyNotification) + ] + assert results == [ + StorageDownloadReadyNotification( + request_id=RequestId("req-4"), + url=None, + filename=None, + error=( + "Variable 'not_storage' is not a compatible " + "storage backend (expected obstore, huggingface_hub.HfApi, or fsspec)" + ), + ) + ] + + +@pytest.mark.skipif(not HAS_OBSTORE, reason="obstore not installed") +class TestExternalStorageCallbacks: + """Integration tests using a real obstore MemoryStore.""" + + async def test_list_entries(self, mocked_kernel: MockedKernel) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run( + [ + ExecuteCellCommand( + cell_id=CellId_t("0"), + code=( + "from obstore.store import MemoryStore\n" + f"{STORAGE_VAR} = MemoryStore()" + ), + ), + ] + ) + + store = k.globals[VariableName(STORAGE_VAR)] + await store.put_async("data/file1.csv", b"a,b,c") + await store.put_async("data/file2.txt", b"hello") + + request = StorageListEntriesCommand( + request_id=RequestId("req-10"), + namespace=STORAGE_VAR, + limit=100, + prefix="data/", + ) + await k.handle_message(request) + + results = [ + op + for op in stream.operations + if isinstance(op, StorageEntriesNotification) + ] + assert results == [ + StorageEntriesNotification( + request_id=RequestId("req-10"), + entries=[ + StorageEntry( + path="data/file1.csv", + kind="object", + size=5, + last_modified=IsPositiveFloat(), # pyright: ignore[reportArgumentType] + metadata={"e_tag": IsStr()}, + mime_type="text/csv", + ), + StorageEntry( + path="data/file2.txt", + kind="object", + size=5, + last_modified=IsPositiveFloat(), # pyright: ignore[reportArgumentType] + metadata={"e_tag": IsStr()}, + mime_type="text/plain", + ), + ], + namespace=STORAGE_VAR, + prefix="data/", + ) + ] + + async def test_list_entries_with_limit( + self, mocked_kernel: MockedKernel + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run( + [ + ExecuteCellCommand( + cell_id=CellId_t("0"), + code=( + "from obstore.store import MemoryStore\n" + f"{STORAGE_VAR} = MemoryStore()" + ), + ), + ] + ) + + store = k.globals[VariableName(STORAGE_VAR)] + await store.put_async("a.txt", b"1") + await store.put_async("b.txt", b"2") + await store.put_async("c.txt", b"3") + + request = StorageListEntriesCommand( + request_id=RequestId("req-11"), + namespace=STORAGE_VAR, + limit=2, + prefix=None, + ) + await k.handle_message(request) + + results = [ + op + for op in stream.operations + if isinstance(op, StorageEntriesNotification) + ] + assert results == [ + StorageEntriesNotification( + request_id=RequestId("req-11"), + entries=[ + StorageEntry( + path="a.txt", + kind="object", + size=1, + last_modified=IsPositiveFloat(), # pyright: ignore[reportArgumentType] + metadata={"e_tag": IsStr()}, + mime_type="text/plain", + ), + StorageEntry( + path="b.txt", + kind="object", + size=1, + last_modified=IsPositiveFloat(), # pyright: ignore[reportArgumentType] + metadata={"e_tag": IsStr()}, + mime_type="text/plain", + ), + ], + namespace=STORAGE_VAR, + prefix=None, + next_page_token="2", + ) + ] + + request = StorageListEntriesCommand( + request_id=RequestId("req-11-page-2"), + namespace=STORAGE_VAR, + limit=2, + prefix=None, + page_token="2", + ) + await k.handle_message(request) + + results = [ + op + for op in stream.operations + if isinstance(op, StorageEntriesNotification) + and op.request_id == RequestId("req-11-page-2") + ] + assert results == [ + StorageEntriesNotification( + request_id=RequestId("req-11-page-2"), + entries=[ + StorageEntry( + path="c.txt", + kind="object", + size=1, + last_modified=IsPositiveFloat(), # pyright: ignore[reportArgumentType] + metadata={"e_tag": IsStr()}, + mime_type="text/plain", + ), + ], + namespace=STORAGE_VAR, + prefix=None, + ) + ] + + async def test_list_entries_empty( + self, mocked_kernel: MockedKernel + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run( + [ + ExecuteCellCommand( + cell_id=CellId_t("0"), + code=( + "from obstore.store import MemoryStore\n" + f"{STORAGE_VAR} = MemoryStore()" + ), + ), + ] + ) + + request = StorageListEntriesCommand( + request_id=RequestId("req-12"), + namespace=STORAGE_VAR, + limit=100, + prefix=None, + ) + await k.handle_message(request) + + results = [ + op + for op in stream.operations + if isinstance(op, StorageEntriesNotification) + ] + assert results == [ + StorageEntriesNotification( + request_id=RequestId("req-12"), + entries=[], + namespace=STORAGE_VAR, + prefix=None, + ) + ] + + async def test_list_entries_backend_exception( + self, mocked_kernel: MockedKernel + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run( + [ + ExecuteCellCommand( + cell_id=CellId_t("0"), + code=( + "from obstore.store import MemoryStore\n" + f"{STORAGE_VAR} = MemoryStore()" + ), + ), + ] + ) + + # Use a real Obstore backend with a broken list_entries + store = k.globals[VariableName(STORAGE_VAR)] + broken_backend = Obstore(store, VariableName(STORAGE_VAR)) + broken_backend.list_entries = MagicMock( # type: ignore[method-assign] + side_effect=RuntimeError("connection timeout"), + ) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + k.external_storage_callbacks, + "_get_storage_backend", + lambda _: (broken_backend, None), + ) + request = StorageListEntriesCommand( + request_id=RequestId("req-13"), + namespace=STORAGE_VAR, + limit=100, + prefix=None, + ) + await k.handle_message(request) + + results = [ + op + for op in stream.operations + if isinstance(op, StorageEntriesNotification) + ] + assert results == [ + StorageEntriesNotification( + request_id=RequestId("req-13"), + entries=[], + namespace=STORAGE_VAR, + prefix=None, + error="Failed to list entries: connection timeout", + ) + ] + + async def test_list_entries_runs_in_background_thread( + self, mocked_kernel: MockedKernel + ) -> None: + """list_entries dispatches to a thread via asyncio.to_thread.""" + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run( + [ + ExecuteCellCommand( + cell_id=CellId_t("0"), + code=( + "from obstore.store import MemoryStore\n" + f"{STORAGE_VAR} = MemoryStore()" + ), + ), + ] + ) + + store = k.globals[VariableName(STORAGE_VAR)] + real_backend = Obstore(store, VariableName(STORAGE_VAR)) + + call_thread_name: str | None = None + original_list = real_backend.list_entries + + def spy_list_entries( + prefix: str | None, + *, + limit: int = 100, + page_token: str | None = None, + ) -> StorageListResult: + nonlocal call_thread_name + call_thread_name = threading.current_thread().name + return original_list(prefix, limit=limit, page_token=page_token) + + real_backend.list_entries = spy_list_entries # type: ignore[method-assign] + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + k.external_storage_callbacks, + "_get_storage_backend", + lambda _: (real_backend, None), + ) + request = StorageListEntriesCommand( + request_id=RequestId("req-14"), + namespace=STORAGE_VAR, + limit=100, + prefix=None, + ) + await k.handle_message(request) + + # asyncio.to_thread runs the callable in a worker thread, + # not the main thread + assert call_thread_name is not None + assert call_thread_name != threading.main_thread().name + + results = [ + op + for op in stream.operations + if isinstance(op, StorageEntriesNotification) + ] + assert results == [ + StorageEntriesNotification( + request_id=RequestId("req-14"), + entries=[], + namespace=STORAGE_VAR, + prefix=None, + ) + ] + + async def test_download(self, mocked_kernel: MockedKernel) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run( + [ + ExecuteCellCommand( + cell_id=CellId_t("0"), + code=( + "from obstore.store import MemoryStore\n" + f"{STORAGE_VAR} = MemoryStore()" + ), + ), + ] + ) + + store = k.globals[VariableName(STORAGE_VAR)] + await store.put_async("reports/data.csv", b"a,b,c\n1,2,3") + + request = StorageDownloadCommand( + request_id=RequestId("req-20"), + namespace=STORAGE_VAR, + path="reports/data.csv", + ) + await k.handle_message(request) + + results = [ + op + for op in stream.operations + if isinstance(op, StorageDownloadReadyNotification) + ] + assert results == [ + StorageDownloadReadyNotification( + request_id=RequestId("req-20"), + url=IsStr(), # pyright: ignore[reportArgumentType] + filename="data.csv", + ) + ] + + async def test_download_uses_signed_url_when_available( + self, mocked_kernel: MockedKernel + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run( + [ + ExecuteCellCommand( + cell_id=CellId_t("0"), + code=( + "from obstore.store import MemoryStore\n" + f"{STORAGE_VAR} = MemoryStore()" + ), + ), + ] + ) + + store = k.globals[VariableName(STORAGE_VAR)] + backend = Obstore(store, VariableName(STORAGE_VAR)) + backend.sign_download_url = AsyncMock( # type: ignore[method-assign] + return_value="https://signed.example.com/data.csv?token=abc", + ) + # download_file should NOT be called when signing succeeds + backend.download_file = AsyncMock( # type: ignore[method-assign] + side_effect=AssertionError("should not be called"), + ) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + k.external_storage_callbacks, + "_get_storage_backend", + lambda _: (backend, None), + ) + request = StorageDownloadCommand( + request_id=RequestId("req-22"), + namespace=STORAGE_VAR, + path="reports/data.csv", + ) + await k.handle_message(request) + + results = [ + op + for op in stream.operations + if isinstance(op, StorageDownloadReadyNotification) + ] + assert results == [ + StorageDownloadReadyNotification( + request_id=RequestId("req-22"), + url="https://signed.example.com/data.csv?token=abc", + filename="data.csv", + ) + ] + + async def test_download_falls_back_when_signing_returns_none( + self, mocked_kernel: MockedKernel + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run( + [ + ExecuteCellCommand( + cell_id=CellId_t("0"), + code=( + "from obstore.store import MemoryStore\n" + f"{STORAGE_VAR} = MemoryStore()" + ), + ), + ] + ) + + store = k.globals[VariableName(STORAGE_VAR)] + await store.put_async("local/readme.txt", b"hello world") + + # MemoryStore can't sign, so sign_download_url returns None + # and the handler falls back to virtual file + request = StorageDownloadCommand( + request_id=RequestId("req-23"), + namespace=STORAGE_VAR, + path="local/readme.txt", + ) + await k.handle_message(request) + + results = [ + op + for op in stream.operations + if isinstance(op, StorageDownloadReadyNotification) + ] + assert results == [ + StorageDownloadReadyNotification( + request_id=RequestId("req-23"), + url=IsStr(), # pyright: ignore[reportArgumentType] + filename="readme.txt", + ) + ] + + async def test_download_fallback_schedules_cleanup( + self, mocked_kernel: MockedKernel + ) -> None: + import asyncio + + k = mocked_kernel.k + + await k.run( + [ + ExecuteCellCommand( + cell_id=CellId_t("0"), + code=( + "from obstore.store import MemoryStore\n" + f"{STORAGE_VAR} = MemoryStore()" + ), + ), + ] + ) + + store = k.globals[VariableName(STORAGE_VAR)] + await store.put_async("tmp/file.bin", b"data") + + request = StorageDownloadCommand( + request_id=RequestId("req-24"), + namespace=STORAGE_VAR, + path="tmp/file.bin", + ) + + loop = asyncio.get_running_loop() + scheduled: list[tuple[float, object]] = [] + original_call_later = loop.call_later + + def spy_call_later( + delay: float, callback: object, *args: object + ) -> asyncio.TimerHandle: + scheduled.append((delay, callback)) + return original_call_later(delay, callback, *args) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(loop, "call_later", spy_call_later) + await k.handle_message(request) + + assert len(scheduled) == 1 + delay, _callback = scheduled[0] + assert delay == 60 + + async def test_download_backend_exception( + self, mocked_kernel: MockedKernel + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run( + [ + ExecuteCellCommand( + cell_id=CellId_t("0"), + code=( + "from obstore.store import MemoryStore\n" + f"{STORAGE_VAR} = MemoryStore()" + ), + ), + ] + ) + + store = k.globals[VariableName(STORAGE_VAR)] + broken_backend = Obstore(store, VariableName(STORAGE_VAR)) + broken_backend.download_file = AsyncMock( # type: ignore[method-assign] + side_effect=PermissionError("access denied"), + ) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + k.external_storage_callbacks, + "_get_storage_backend", + lambda _: (broken_backend, None), + ) + request = StorageDownloadCommand( + request_id=RequestId("req-21"), + namespace=STORAGE_VAR, + path="secret/file.bin", + ) + await k.handle_message(request) + + results = [ + op + for op in stream.operations + if isinstance(op, StorageDownloadReadyNotification) + ] + assert results == [ + StorageDownloadReadyNotification( + request_id=RequestId("req-21"), + url=None, + filename=None, + error="Failed to download: access denied", + ) + ] + + async def test_download_preview(self, mocked_kernel: MockedKernel) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run( + [ + ExecuteCellCommand( + cell_id=CellId_t("0"), + code=( + "from obstore.store import MemoryStore\n" + f"{STORAGE_VAR} = MemoryStore()" + ), + ), + ] + ) + + store = k.globals[VariableName(STORAGE_VAR)] + await store.put_async("docs/readme.txt", b"hello world") + + request = StorageDownloadCommand( + request_id=RequestId("req-30"), + namespace=STORAGE_VAR, + path="docs/readme.txt", + preview=True, + ) + await k.handle_message(request) + + results = [ + op + for op in stream.operations + if isinstance(op, StorageDownloadReadyNotification) + ] + assert results == [ + StorageDownloadReadyNotification( + request_id=RequestId("req-30"), + url=IsStr(), # pyright: ignore[reportArgumentType] + filename="readme.txt", + error=None, + ) + ] + notif = results[0] + assert notif.url is not None + # Preview returns a virtual file URL, not a signed URL + assert "@file/" in notif.url + + async def test_download_preview_skips_signed_url( + self, mocked_kernel: MockedKernel + ) -> None: + """Preview mode should use read_range, never sign_download_url.""" + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run( + [ + ExecuteCellCommand( + cell_id=CellId_t("0"), + code=( + "from obstore.store import MemoryStore\n" + f"{STORAGE_VAR} = MemoryStore()" + ), + ), + ] + ) + + store = k.globals[VariableName(STORAGE_VAR)] + await store.put_async("file.py", b"print('hello')") + + backend = Obstore(store, VariableName(STORAGE_VAR)) + backend.sign_download_url = AsyncMock( # type: ignore[method-assign] + side_effect=AssertionError("should not be called"), + ) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + k.external_storage_callbacks, + "_get_storage_backend", + lambda _: (backend, None), + ) + request = StorageDownloadCommand( + request_id=RequestId("req-31"), + namespace=STORAGE_VAR, + path="file.py", + preview=True, + ) + await k.handle_message(request) + + results = [ + op + for op in stream.operations + if isinstance(op, StorageDownloadReadyNotification) + ] + assert results == [ + StorageDownloadReadyNotification( + request_id=RequestId("req-31"), + url=IsStr(), # pyright: ignore[reportArgumentType] + filename="file.py", + error=None, + ) + ] + + async def test_download_preview_schedules_cleanup( + self, mocked_kernel: MockedKernel + ) -> None: + import asyncio + + k = mocked_kernel.k + + await k.run( + [ + ExecuteCellCommand( + cell_id=CellId_t("0"), + code=( + "from obstore.store import MemoryStore\n" + f"{STORAGE_VAR} = MemoryStore()" + ), + ), + ] + ) + + store = k.globals[VariableName(STORAGE_VAR)] + await store.put_async("tmp/preview.txt", b"data") + + request = StorageDownloadCommand( + request_id=RequestId("req-32"), + namespace=STORAGE_VAR, + path="tmp/preview.txt", + preview=True, + ) + + loop = asyncio.get_running_loop() + scheduled: list[tuple[float, object]] = [] + original_call_later = loop.call_later + + def spy_call_later( + delay: float, callback: object, *args: object + ) -> asyncio.TimerHandle: + scheduled.append((delay, callback)) + return original_call_later(delay, callback, *args) # pyright: ignore[reportArgumentType] + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(loop, "call_later", spy_call_later) + await k.handle_message(request) + + assert len(scheduled) == 1 + delay, _callback = scheduled[0] + assert delay == 60 + + async def test_download_preview_backend_exception( + self, mocked_kernel: MockedKernel + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + await k.run( + [ + ExecuteCellCommand( + cell_id=CellId_t("0"), + code=( + "from obstore.store import MemoryStore\n" + f"{STORAGE_VAR} = MemoryStore()" + ), + ), + ] + ) + + store = k.globals[VariableName(STORAGE_VAR)] + broken_backend = Obstore(store, VariableName(STORAGE_VAR)) + broken_backend.read_range = AsyncMock( # type: ignore[method-assign] + side_effect=OSError("read failed"), + ) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + k.external_storage_callbacks, + "_get_storage_backend", + lambda _: (broken_backend, None), + ) + request = StorageDownloadCommand( + request_id=RequestId("req-33"), + namespace=STORAGE_VAR, + path="broken/file.txt", + preview=True, + ) + await k.handle_message(request) + + results = [ + op + for op in stream.operations + if isinstance(op, StorageDownloadReadyNotification) + ] + assert results == [ + StorageDownloadReadyNotification( + request_id=RequestId("req-33"), + url=None, + filename=None, + error="Failed to download: read failed", + ) + ] + + async def test_download_preview_variable_not_found( + self, mocked_kernel: MockedKernel + ) -> None: + k = mocked_kernel.k + stream = mocked_kernel.stream + + request = StorageDownloadCommand( + request_id=RequestId("req-34"), + namespace="nonexistent_var", + path="file.txt", + preview=True, + ) + await k.handle_message(request) + + results = [ + op + for op in stream.operations + if isinstance(op, StorageDownloadReadyNotification) + ] + assert results == [ + StorageDownloadReadyNotification( + request_id=RequestId("req-34"), + url=None, + filename=None, + error="Variable 'nonexistent_var' not found", + ) + ] diff --git a/tests/_runtime/test_runtime_secrets.py b/tests/_runtime/test_runtime_secrets.py new file mode 100644 index 00000000000..f86a79be5c7 --- /dev/null +++ b/tests/_runtime/test_runtime_secrets.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import os +from unittest.mock import MagicMock + +import pytest + +from marimo._messaging.notification import SecretKeysResultNotification +from marimo._runtime.callbacks import SecretsCallbacks +from marimo._runtime.commands import ( + ListSecretKeysCommand, + RefreshSecretsCommand, +) +from marimo._types.ids import RequestId +from tests._messaging.mocks import MockStream +from tests.conftest import MockedKernel + + +@pytest.fixture +def secrets_callbacks(mocked_kernel: MockedKernel): + return SecretsCallbacks(mocked_kernel.k) + + +async def test_list_secrets_with_values( + secrets_callbacks: SecretsCallbacks, mocked_kernel: MockedKernel +): + # Set some test secrets + test_secrets = ["DUMMY_SECRET"] + os.environ["DUMMY_SECRET"] = "dummy-value" + secrets_callbacks._original_environ = os.environ.copy() + + await secrets_callbacks.list_secrets( + ListSecretKeysCommand(request_id=RequestId("test")) + ) + + # Check that the broadcast message was sent with the correct secrets + stream = MockStream(mocked_kernel.stream) + secret_messages = [ + msg + for msg in stream.parsed_operations + if isinstance(msg, SecretKeysResultNotification) + ] + assert len(secret_messages) == 1 + first_secret_message = secret_messages[0] + assert first_secret_message.secrets[0].provider == "env" + assert all( + secret in first_secret_message.secrets[0].keys + for secret in test_secrets + ) + + # Clean up + for key in test_secrets: + del os.environ[key] + + +async def test_refresh_secrets( + secrets_callbacks: SecretsCallbacks, mocked_kernel: MockedKernel +): + # Set some test secrets + os.environ["DUMMY_SECRET"] = "dummy-value" + secrets_callbacks._original_environ = os.environ.copy() + + # Spy on load_dotenv method + original_load_dotenv = mocked_kernel.k.load_dotenv + mocked_kernel.k.load_dotenv = MagicMock(wraps=original_load_dotenv) + + await secrets_callbacks.refresh_secrets(RefreshSecretsCommand()) + + # Check that load_dotenv was called + assert mocked_kernel.k.load_dotenv.call_count == 1 diff --git a/tests/_runtime/test_scheduler.py b/tests/_runtime/test_scheduler.py new file mode 100644 index 00000000000..136ff77131a --- /dev/null +++ b/tests/_runtime/test_scheduler.py @@ -0,0 +1,156 @@ +# Copyright 2026 Marimo. All rights reserved. +"""Queue + cancellation invariants for SequentialScheduler.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +from marimo._runtime.runner.scheduler import SequentialScheduler +from marimo._types.ids import CellId_t + +if TYPE_CHECKING: + import pytest + + +def _empty_graph() -> MagicMock: + """A graph whose transitive_closure returns just the input cell.""" + g = MagicMock() + g.cells = {} + return g + + +def test_pending_and_pop_cell_fifo() -> None: + cells = [CellId_t("a"), CellId_t("b"), CellId_t("c")] + sched = SequentialScheduler(cells, graph=_empty_graph()) + + assert sched.pending() is True + assert sched.pop_cell() == "a" + assert sched.pop_cell() == "b" + assert sched.pop_cell() == "c" + assert sched.pending() is False + + +def test_interrupted_blocks_pending() -> None: + sched = SequentialScheduler([CellId_t("a")], graph=_empty_graph()) + + assert sched.pending() is True + sched.interrupted = True + assert sched.pending() is False + + +def test_cancel_marks_cancelled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Mock graph: transitive_closure returns just the cell itself, no + # descendants. Cell registered in graph.cells so set_run_result_status + # has a target. + g = MagicMock() + cid = CellId_t("a") + cell_mock = MagicMock() + g.cells = {cid: cell_mock} + + def fake_closure(graph: object, roots: set[CellId_t]) -> set[CellId_t]: + del graph + return set(roots) + + monkeypatch.setattr( + "marimo._runtime.dataflow.transitive_closure", fake_closure + ) + sched = SequentialScheduler([cid], graph=g) + assert sched.cancelled(cid) is False + sched.cancel(cid) + assert sched.cancelled(cid) is True + cell_mock.set_run_result_status.assert_called_with("cancelled") + + +def test_requeue_for_rerun_moves_producer_ahead_of_queued_consumer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A producer already queued *behind* the consumer is moved to the head + so it runs before the consumer's retry — not left stranded behind it + (which would re-trip on the stale value forever).""" + p, c, y = CellId_t("P"), CellId_t("C"), CellId_t("Y") + + def fake_topo(graph: object, cells: set[CellId_t]) -> list[CellId_t]: + del graph + return [cid for cid in (p, c) if cid in cells] # producer first + + monkeypatch.setattr("marimo._runtime.dataflow.topological_sort", fake_topo) + # Consumer C is the current (popped) cell, not in the queue; producer P + # is already queued, behind Y. + sched = SequentialScheduler([y, p], graph=_empty_graph()) + sched.requeue_for_rerun({p, c}) + assert list(sched.cells_to_run) == [p, c, y] + + +def test_requeue_for_rerun_no_duplicate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A requeued cell already in the queue is moved, not duplicated.""" + p, c = CellId_t("P"), CellId_t("C") + + def fake_topo(graph: object, cells: set[CellId_t]) -> list[CellId_t]: + del graph + return [cid for cid in (p, c) if cid in cells] + + monkeypatch.setattr("marimo._runtime.dataflow.topological_sort", fake_topo) + sched = SequentialScheduler([c], graph=_empty_graph()) + sched.requeue_for_rerun({p, c}) + queued = list(sched.cells_to_run) + assert queued == [p, c] + assert queued.count(c) == 1 + + +def test_requeue_for_rerun_uncancels_stranded_descendants( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Requeuing a raiser (without its descendants in the rerun set, as + `cells_to_rerun = self | producers` does) must not leave the descendants + it previously cancelled stuck in the cancelled set.""" + r, d = CellId_t("R"), CellId_t("D") + g = MagicMock() + g.cells = {r: MagicMock(), d: MagicMock()} + + def fake_closure(graph: object, roots: set[CellId_t]) -> set[CellId_t]: + del graph, roots + return {r, d} # cancelling R also cancels descendant D + + def fake_topo(graph: object, cells: set[CellId_t]) -> list[CellId_t]: + del graph + return [cid for cid in (r, d) if cid in cells] + + monkeypatch.setattr( + "marimo._runtime.dataflow.transitive_closure", fake_closure + ) + monkeypatch.setattr("marimo._runtime.dataflow.topological_sort", fake_topo) + + sched = SequentialScheduler([r, d], graph=g) + sched.cancel(r) + assert sched.cancelled(r) is True + assert sched.cancelled(d) is True + + # Rerun set contains only the raiser, not descendant D. + sched.requeue_for_rerun({r}) + assert sched.cancelled(r) is False + assert sched.cancelled(d) is False + + +def test_batch_yields_singletons() -> None: + sched = SequentialScheduler([], graph=_empty_graph()) + cells = [CellId_t("a"), CellId_t("b"), CellId_t("c")] + # batch() yields iterables, not indexable lists — callers iterate with + # ``for cell_id in batch:`` rather than ``batch[0]``. + batches = [list(b) for b in sched.batch(cells)] + assert batches == [["a"], ["b"], ["c"]] + + +def test_batch_respects_interrupt() -> None: + sched = SequentialScheduler([], graph=_empty_graph()) + cells = [CellId_t("a"), CellId_t("b"), CellId_t("c")] + iterator = sched.batch(cells) + assert list(next(iterator)) == ["a"] + sched.interrupted = True + # Generator stops once interrupted is set. + remaining = list(iterator) + assert remaining == [] diff --git a/tests/_runtime/test_single_execution.py b/tests/_runtime/test_single_execution.py new file mode 100644 index 00000000000..6e978f0b8b9 --- /dev/null +++ b/tests/_runtime/test_single_execution.py @@ -0,0 +1,78 @@ +# Copyright 2026 Marimo. All rights reserved. +import os +import subprocess +import sys + +from marimo._runtime.capture import capture_stdout + +# 4 methods of execution +# - script +# - as a module +# - In test +# In kernel runtime + +# In kernel, setup should just act like another cell, so we focus on the +# behavior here that interacts with the notebook as a script. + + +def test_acts_like_script() -> None: + p = subprocess.run( + [ + sys.executable, + "tests/_runtime/script_data/script_global_setup_difference.py", + ], + capture_output=True, + ) + assert p.returncode == 0 + result = p.stdout.decode() + assert result.replace("\r", "") == "*\n1\n" + + +def test_acts_like_module() -> None: + # Turn off for recursion guard + previous = os.environ.get("PYTEST_CURRENT_TEST", "") + os.environ["PYTEST_CURRENT_TEST"] = "" + del os.environ["PYTEST_CURRENT_TEST"] + + with capture_stdout() as stdout: + from tests._runtime.script_data.script_global_setup_difference import ( + injected, + test_single_run, + ) + + # No 1 since doesn't run app. + assert stdout.getvalue() == "*\n" + assert injected == 1 + + with capture_stdout() as stdout: + test_single_run() + # No rerun on call + assert stdout.getvalue() == "1\n" + + os.environ["PYTEST_CURRENT_TEST"] = previous + + +def test_acts_as_test() -> None: + # Turn off for recursion guard + previous = os.environ.get("PYTEST_CURRENT_TEST", "") + os.environ["PYTEST_CURRENT_TEST"] = "" + del os.environ["PYTEST_CURRENT_TEST"] + + p = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "-rP", + "-qq", + "--disable-warnings", + "--", + "tests/_runtime/script_data/script_global_setup_difference.py", + ], + capture_output=True, + ) + result = p.stdout.decode() + assert p.returncode == 0, result + assert "-\n*\n1\n" in result.replace("\r", "") + + os.environ["PYTEST_CURRENT_TEST"] = previous diff --git a/tests/_runtime/test_state.py b/tests/_runtime/test_state.py index 6c92c8fd4e7..f8661761ab8 100644 --- a/tests/_runtime/test_state.py +++ b/tests/_runtime/test_state.py @@ -1,4 +1,4 @@ -# Copyright 2024 Marimo. All rights reserved. +# Copyright 2026 Marimo. All rights reserved. from marimo._runtime.runtime import Kernel from tests.conftest import ExecReqProvider @@ -218,3 +218,100 @@ async def test_set_state_not_strict_copied( assert id(k.globals["a"]) == id(k.globals["state"]) assert id(k.globals["b"]) == id(k.globals["set_state"]) + + +async def test_external_state_update( + execution_kernel: Kernel, exec_req: ExecReqProvider +) -> None: + """State setter called outside cell execution (no execution context). + + Simulates the pattern where a widget callback or async task calls + set_state. The __external__ sentinel should skip self-loop prevention + and downstream cells should re-run. + """ + k = execution_kernel + await k.run( + [ + exec_req.get("import marimo as mo"), + exec_req.get("state, set_state = mo.state(0)"), + exec_req.get("x = state()"), + ] + ) + assert k.globals["x"] == 0 + + # Call set_state outside any cell execution context, + # like a widget callback or async task would. + k.globals["set_state"](42) + + # _find_cells_for_state with __external__ should find the + # cell that reads state but not loop back to any "setter" cell. + from marimo._runtime.state import State + from marimo._types.ids import CellId_t + + state_obj = k.globals["state"] + assert isinstance(state_obj, State) + + affected = k._find_cells_for_state(state_obj, CellId_t("__external__")) + # Should include the cell that reads `state` (x = state()) + assert len(affected) > 0 + + # The cell that defines state should NOT be in affected + # (it defines but doesn't read it as a ref) + define_cell_id = list(k.graph.cells.keys())[1] # "state, set_state = ..." + assert define_cell_id not in affected + + +async def test_external_set_state_reruns_dependent_cell( + execution_kernel: Kernel, exec_req: ExecReqProvider +) -> None: + """Calling set_state from outside a cell (async task / widget callback) + should re-run downstream cells that read the state.""" + k = execution_kernel + + # Wire enqueue so run_stale_cells can actually flush the update + k.enqueue_control_request = lambda _: None # type: ignore + + await k.run( + [ + exec_req.get("import marimo as mo"), + exec_req.get("state, set_state = mo.state(0)"), + exec_req.get("result = state()"), + ] + ) + assert k.globals["result"] == 0 + + # Simulate a widget callback / async task calling set_state + k.globals["set_state"](42) + + # Flush — in production the event loop processes this automatically + await k.run_stale_cells() + + assert k.globals["result"] == 42 + + +async def test_find_cells_with_multiple_refs_to_same_state( + execution_kernel: Kernel, exec_req: ExecReqProvider +) -> None: + """Cell that references the same state via multiple names is found once.""" + k = execution_kernel + await k.run( + [ + exec_req.get("import marimo as mo"), + exec_req.get("state, set_state = mo.state(0)"), + # alias creates a second ref to the same state object + exec_req.get("alias = state; x = state() + alias()"), + ] + ) + assert k.globals["x"] == 0 + + from marimo._runtime.state import State + from marimo._types.ids import CellId_t + + state_obj = k.globals["state"] + assert isinstance(state_obj, State) + + affected = k._find_cells_for_state(state_obj, CellId_t("__external__")) + reader_cell_id = list(k.graph.cells.keys())[2] + # Cell should appear exactly once despite two refs to the same state + assert reader_cell_id in affected + assert len([c for c in affected if c == reader_cell_id]) == 1 diff --git a/tests/_runtime/test_storage.py b/tests/_runtime/test_storage.py new file mode 100644 index 00000000000..82c97506fa2 --- /dev/null +++ b/tests/_runtime/test_storage.py @@ -0,0 +1,374 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import pytest + +from marimo._runtime.virtual_file.storage import ( + InMemoryStorage, + SharedMemoryStorage, + VirtualFileStorageManager, +) + + +class TestInMemoryStorageReadChunked: + def test_read_chunked_basic(self) -> None: + storage = InMemoryStorage() + storage.store("test_key", b"hello world") + chunks = list(storage.read_chunked("test_key", 11)) + assert b"".join(chunks) == b"hello world" + + def test_read_chunked_with_byte_length(self) -> None: + storage = InMemoryStorage() + storage.store("test_key", b"hello world") + chunks = list(storage.read_chunked("test_key", 5)) + assert b"".join(chunks) == b"hello" + + def test_read_chunked_multiple_chunks(self) -> None: + storage = InMemoryStorage() + data = b"a" * 100 + storage.store("test_key", data) + # Use a small chunk size to force multiple chunks + chunks = list(storage.read_chunked("test_key", 100, chunk_size=30)) + assert b"".join(chunks) == data + assert len(chunks) == 4 # 30 + 30 + 30 + 10 + + def test_read_chunked_nonexistent_raises_keyerror(self) -> None: + storage = InMemoryStorage() + with pytest.raises(KeyError, match="Virtual file not found"): + list(storage.read_chunked("nonexistent", 10)) + + def test_read_chunked_chunk_sizes(self) -> None: + """Verify each chunk is at most chunk_size bytes.""" + storage = InMemoryStorage() + data = b"x" * 250 + storage.store("test_key", data) + chunk_size = 64 + chunks = list( + storage.read_chunked("test_key", 250, chunk_size=chunk_size) + ) + for chunk in chunks[:-1]: + assert len(chunk) == chunk_size + # Last chunk may be smaller + assert len(chunks[-1]) <= chunk_size + assert b"".join(chunks) == data + + def test_read_chunked_with_start_offset(self) -> None: + storage = InMemoryStorage() + storage.store("test_key", b"hello world") + chunks = list(storage.read_chunked("test_key", 5, start=6)) + assert b"".join(chunks) == b"world" + + +class TestInMemoryStorage: + def test_store_and_read(self) -> None: + storage = InMemoryStorage() + storage.store("test_key", b"hello world") + result = storage.read("test_key", 11) + assert result == b"hello world" + + def test_read_with_byte_length(self) -> None: + storage = InMemoryStorage() + storage.store("test_key", b"hello world") + result = storage.read("test_key", 5) + assert result == b"hello" + + def test_read_nonexistent_raises_keyerror(self) -> None: + storage = InMemoryStorage() + with pytest.raises(KeyError, match="Virtual file not found"): + storage.read("nonexistent", 10) + + def test_store_overwrites(self) -> None: + storage = InMemoryStorage() + storage.store("test_key", b"original") + storage.store("test_key", b"updated") + result = storage.read("test_key", 7) + assert result == b"updated" + + def test_remove(self) -> None: + storage = InMemoryStorage() + storage.store("test_key", b"hello") + assert storage.has("test_key") + storage.remove("test_key") + assert not storage.has("test_key") + + def test_remove_nonexistent_no_error(self) -> None: + storage = InMemoryStorage() + storage.remove("nonexistent") # Should not raise + + def test_has(self) -> None: + storage = InMemoryStorage() + assert not storage.has("test_key") + storage.store("test_key", b"hello") + assert storage.has("test_key") + + def test_shutdown_clears_storage(self) -> None: + storage = InMemoryStorage() + storage.store("key1", b"data1") + storage.store("key2", b"data2") + assert storage.has("key1") + assert storage.has("key2") + storage.shutdown() + assert not storage.has("key1") + assert not storage.has("key2") + + +class TestSharedMemoryStorage: + def test_store_and_read(self) -> None: + storage = SharedMemoryStorage() + try: + storage.store("marimo_test_1", b"hello world") + result = storage.read("marimo_test_1", 11) + assert result == b"hello world" + finally: + storage.shutdown() + + def test_read_with_byte_length(self) -> None: + storage = SharedMemoryStorage() + try: + storage.store("marimo_test_2", b"hello world") + result = storage.read("marimo_test_2", 5) + assert result == b"hello" + finally: + storage.shutdown() + + def test_read_nonexistent_raises_keyerror(self) -> None: + storage = SharedMemoryStorage() + try: + with pytest.raises(KeyError, match="Virtual file not found"): + storage.read("nonexistent_key_xyz", 10) + finally: + storage.shutdown() + + def test_store_duplicate_skipped(self) -> None: + storage = SharedMemoryStorage() + try: + storage.store("marimo_test_3", b"original") + # Second store should be a no-op (not overwrite) + storage.store("marimo_test_3", b"updated_longer") + result = storage.read("marimo_test_3", 8) + assert result == b"original" + finally: + storage.shutdown() + + def test_remove(self) -> None: + storage = SharedMemoryStorage() + try: + storage.store("marimo_test_4", b"hello") + assert storage.has("marimo_test_4") + storage.remove("marimo_test_4") + assert not storage.has("marimo_test_4") + finally: + storage.shutdown() + + def test_remove_nonexistent_no_error(self) -> None: + storage = SharedMemoryStorage() + try: + storage.remove("nonexistent") # Should not raise + finally: + storage.shutdown() + + def test_has(self) -> None: + storage = SharedMemoryStorage() + try: + assert not storage.has("marimo_test_5") + storage.store("marimo_test_5", b"hello") + assert storage.has("marimo_test_5") + finally: + storage.shutdown() + + def test_shutdown_clears_storage(self) -> None: + storage = SharedMemoryStorage() + storage.store("marimo_test_6", b"data1") + storage.store("marimo_test_7", b"data2") + assert storage.has("marimo_test_6") + assert storage.has("marimo_test_7") + storage.shutdown() + assert not storage.has("marimo_test_6") + assert not storage.has("marimo_test_7") + + def test_shutdown_with_keys_only_removes_requested_entries(self) -> None: + storage = SharedMemoryStorage() + try: + storage.store("marimo_test_subset_1", b"data1") + storage.store("marimo_test_subset_2", b"data2") + + storage.shutdown(keys=["marimo_test_subset_1"]) + + assert not storage.has("marimo_test_subset_1") + assert storage.has("marimo_test_subset_2") + assert storage.read("marimo_test_subset_2", 5) == b"data2" + finally: + storage.shutdown() + + def test_cross_process_read(self) -> None: + """Test that shared memory can be read by name from a fresh instance.""" + storage1 = SharedMemoryStorage() + try: + storage1.store("marimo_test_cross", b"cross process data") + # Create a new instance and read by name + storage2 = SharedMemoryStorage() + result = storage2.read("marimo_test_cross", 18) + assert result == b"cross process data" + finally: + storage1.shutdown() + + def test_shutdown_is_reentrant(self) -> None: + """Test that shutdown can be called multiple times safely.""" + storage = SharedMemoryStorage() + storage.store("marimo_test_8", b"data") + storage.shutdown() + storage.shutdown() # Should not raise + + def test_read_chunked_basic(self) -> None: + storage = SharedMemoryStorage() + try: + storage.store("marimo_chunk_1", b"hello world") + chunks = list(storage.read_chunked("marimo_chunk_1", 11)) + assert b"".join(chunks) == b"hello world" + finally: + storage.shutdown() + + def test_read_chunked_with_byte_length(self) -> None: + storage = SharedMemoryStorage() + try: + storage.store("marimo_chunk_2", b"hello world") + chunks = list(storage.read_chunked("marimo_chunk_2", 5)) + assert b"".join(chunks) == b"hello" + finally: + storage.shutdown() + + def test_read_chunked_multiple_chunks(self) -> None: + storage = SharedMemoryStorage() + try: + data = b"a" * 100 + storage.store("marimo_chunk_3", data) + chunks = list( + storage.read_chunked("marimo_chunk_3", 100, chunk_size=30) + ) + assert b"".join(chunks) == data + assert len(chunks) == 4 # 30 + 30 + 30 + 10 + finally: + storage.shutdown() + + def test_read_chunked_nonexistent_raises_keyerror(self) -> None: + storage = SharedMemoryStorage() + try: + with pytest.raises(KeyError, match="Virtual file not found"): + list(storage.read_chunked("nonexistent_chunk", 10)) + finally: + storage.shutdown() + + def test_read_chunked_cross_process(self) -> None: + """Test chunked read works across fresh instances.""" + storage1 = SharedMemoryStorage() + try: + data = b"cross process chunked" + storage1.store("marimo_chunk_cross", data) + storage2 = SharedMemoryStorage() + chunks = list( + storage2.read_chunked( + "marimo_chunk_cross", len(data), chunk_size=5 + ) + ) + assert b"".join(chunks) == data + finally: + storage1.shutdown() + + def test_read_chunked_with_start_offset(self) -> None: + storage = SharedMemoryStorage() + try: + storage.store("marimo_chunk_offset", b"hello world") + chunks = list( + storage.read_chunked("marimo_chunk_offset", 5, start=6) + ) + assert b"".join(chunks) == b"world" + finally: + storage.shutdown() + + def test_read_chunked_data_integrity(self) -> None: + """Test that chunked read produces identical data to regular read.""" + storage = SharedMemoryStorage() + try: + data = bytes(range(256)) * 4 # 1024 bytes of varied data + storage.store("marimo_chunk_integ", data) + regular = storage.read("marimo_chunk_integ", len(data)) + chunked = b"".join( + storage.read_chunked( + "marimo_chunk_integ", len(data), chunk_size=100 + ) + ) + assert regular == chunked == data + finally: + storage.shutdown() + + +class TestVirtualFileStorageManager: + def test_singleton(self) -> None: + manager1 = VirtualFileStorageManager() + manager2 = VirtualFileStorageManager() + assert manager1 is manager2 + + def test_storage_property(self) -> None: + manager = VirtualFileStorageManager() + original_storage = manager.storage + try: + storage = InMemoryStorage() + manager.storage = storage + assert manager.storage is storage + finally: + manager.storage = original_storage + + def test_read_with_storage(self) -> None: + manager = VirtualFileStorageManager() + original_storage = manager.storage + try: + storage = InMemoryStorage() + storage.store("test_file", b"test data") + manager.storage = storage + result = manager.read("test_file", 9) + assert result == b"test data" + finally: + manager.storage = original_storage + + def test_read_without_storage_falls_back_to_shared_memory(self) -> None: + manager = VirtualFileStorageManager() + original_storage = manager.storage + # Store data in shared memory directly + shm_storage = SharedMemoryStorage() + try: + shm_storage.store("marimo_fallback_test", b"fallback data") + # Set manager storage to None to trigger fallback + manager.storage = None + result = manager.read("marimo_fallback_test", 13) + assert result == b"fallback data" + finally: + manager.storage = original_storage + shm_storage.shutdown() + + def test_read_chunked_with_storage(self) -> None: + manager = VirtualFileStorageManager() + original_storage = manager.storage + try: + storage = InMemoryStorage() + storage.store("test_file", b"test data chunked") + manager.storage = storage + chunks = list(manager.read_chunked("test_file", 17)) + assert b"".join(chunks) == b"test data chunked" + finally: + manager.storage = original_storage + + def test_read_chunked_falls_back_to_shared_memory(self) -> None: + manager = VirtualFileStorageManager() + original_storage = manager.storage + shm_storage = SharedMemoryStorage() + try: + shm_storage.store("marimo_fb_chunk", b"fallback chunked") + manager.storage = None + chunks = list( + manager.read_chunked("marimo_fb_chunk", 16, chunk_size=4) + ) + assert b"".join(chunks) == b"fallback chunked" + assert len(chunks) == 4 # 16 / 4 = 4 chunks + finally: + manager.storage = original_storage + shm_storage.shutdown() diff --git a/tests/_runtime/test_threads.py b/tests/_runtime/test_threads.py index b3cd4f3c949..fd28310bb12 100644 --- a/tests/_runtime/test_threads.py +++ b/tests/_runtime/test_threads.py @@ -1,18 +1,54 @@ +from __future__ import annotations + +import asyncio import time +from typing import cast +from marimo._ast.app import App, InternalApp +from marimo._pyodide.streams import PyodideStream +from marimo._runtime.commands import DeleteCellCommand +from marimo._runtime.context import get_context +from marimo._runtime.context.script_context import ( + ScriptRuntimeContext, + initialize_script_context, +) +from marimo._runtime.context.types import teardown_context from marimo._runtime.runtime import Kernel +from marimo._runtime.threads import Thread +from marimo._types.ids import CellId_t +from tests._messaging.mocks import MockStream from tests.conftest import ExecReqProvider -async def test_set_ui_element_value_lensed( - any_kernel: Kernel, exec_req: ExecReqProvider -) -> None: - """Test setting the value of a lensed element. +# Doesn't work with strict kernel ... +async def test_thread_set_global(k: Kernel, exec_req: ExecReqProvider) -> None: + """Test that a thread starts and runs.""" + + await k.run( + [ + exec_req.get("import marimo as mo"), + exec_req.get( + """ + value = 0 + def target(): + global value + value = 1 + """ + ), + exec_req.get("t = mo.Thread(target=target); t.start(); t.join()"), + ] + ) + + # thread run should be basically instantaneous, but sleep just in case ... + assert not k.errors + time.sleep(0.01) # noqa: ASYNC251 + assert k.globals["value"] == 1 - Make sure reactivity flows through its parent, and that its on_change - handler is called exactly once. - """ - k = any_kernel + +async def test_thread_has_own_stream( + k: Kernel, exec_req: ExecReqProvider +) -> None: + """Test that a thread starts, runs, has its own stream.""" await k.run( [ @@ -20,16 +56,324 @@ async def test_set_ui_element_value_lensed( exec_req.get("ctx_main = mo._runtime.context.get_context()"), exec_req.get( """ - ctx_thread = None + cell_id = ctx_main.stream.cell_id + thread_ctx = None + def target(): + global thread_ctx + thread_ctx = mo._runtime.context.get_context() + t = mo.Thread(target=target); t.start(); t.join() + """ + ), + ] + ) + + # thread run should be basically instantaneous, but sleep just in case ... + assert not k.errors + time.sleep(0.01) # noqa: ASYNC251 + # thread gets its own context + assert k.globals["thread_ctx"] != k.globals["ctx_main"] + stream = k.globals["thread_ctx"].stream + assert stream.cell_id == k.globals["cell_id"] + + +def test_thread_copies_pyodide_stream_in_script_context() -> None: + stream = PyodideStream( + pipe=lambda _message: None, + input_queue=asyncio.Queue(), + cell_id=CellId_t("script-cell"), + ) + observed_contexts: list[ScriptRuntimeContext] = [] + + teardown_context() + initialize_script_context(InternalApp(App()), stream, filename=None) + ctx = cast(ScriptRuntimeContext, get_context()) + ctx.query_params["source"] = "parent" + try: + + def target() -> None: + observed_contexts.append(cast(ScriptRuntimeContext, get_context())) + + thread = Thread(target=target) + thread.start() + thread.join(timeout=1) + + assert not thread.is_alive() + assert len(observed_contexts) == 1 + copied_ctx = observed_contexts[0] + copied = cast(PyodideStream, copied_ctx.stream) + assert copied is not stream + assert copied.pipe is stream.pipe + assert copied.input_queue is stream.input_queue + assert copied.cell_id == stream.cell_id + assert copied_ctx.query_params is ctx.query_params + assert copied_ctx.query_params.get("source") == "parent" + finally: + teardown_context() + + +async def test_thread_output_append( + k: Kernel, exec_req: ExecReqProvider +) -> None: + """Test that a thread starts, runs, and appends to output.""" + + await k.run( + [ + exec_req.get("import marimo as mo"), + exec_req.get( + """ + stream = mo._runtime.context.get_context().stream + thread_stream = None def target(): - global ctx_thread - ctx_thread = mo._runtime.context.get_context() - mo.Thread(target=target).start().join() + global thread_stream + import marimo as mo + mo.output.append("hello") + mo.output.append("world") + thread_stream = mo._runtime.context.get_context().stream + t = mo.Thread(target=target); t.start(); t.join() """ ), ] ) # thread run should be basically instantaneous, but sleep just in case ... + assert not k.errors time.sleep(0.01) # noqa: ASYNC251 - assert k.globals["ctx_main"] == k.globals["ctx_thread"] + # The main thread should not have any output, but the new thread should + cell_notifications = MockStream(k.globals["stream"]).cell_notifications + for m in cell_notifications: + if m.output is not None: + assert "hello" not in m.output.data + assert "world" not in m.output.data + thread_stream_cell_notifications = MockStream( + k.globals["thread_stream"] + ).cell_notifications + assert "hello" in thread_stream_cell_notifications[0].output.data + assert "world" in thread_stream_cell_notifications[1].output.data + + +async def test_print_is_builtin_without_threads( + k: Kernel, exec_req: ExecReqProvider +) -> None: + """Thread-free notebooks keep the real builtin `print` that numba and + similar libraries require (#9765).""" + await k.run( + [ + exec_req.get( + """ + import builtins + is_builtin = print is builtins.print + print_in_globals = "print" in globals() + """ + ), + ] + ) + + assert not k.errors + assert k.globals["is_builtin"] is True + # Not shadowed in cell globals; resolves to the real builtin via __builtins__. + assert k.globals["print_in_globals"] is False + + +async def test_print_overridden_after_thread( + k: Kernel, exec_req: ExecReqProvider +) -> None: + """Creating a mo.Thread lazily patches `print` so thread output is routed.""" + await k.run( + [ + exec_req.get("import builtins; import marimo as mo"), + exec_req.get("before = print is builtins.print"), + exec_req.get( + """ + t = mo.Thread(target=lambda: None) + after = print is builtins.print + """ + ), + ] + ) + + assert not k.errors + assert k.globals["before"] is True + assert k.globals["after"] is False + + +async def test_thread_print(k: Kernel, exec_req: ExecReqProvider) -> None: + """Test that a thread starts, runs, and prints.""" + + await k.run( + [ + exec_req.get("import marimo as mo"), + exec_req.get( + """ + stream = mo._runtime.context.get_context().stream + thread_stream = None + def target(): + global thread_stream + import marimo as mo + print("hello") + print("world") + thread_stream = mo._runtime.context.get_context().stream + t = mo.Thread(target=target); t.start(); t.join() + """ + ), + ] + ) + + # thread run should be basically instantaneous, but sleep just in case ... + assert not k.errors + time.sleep(0.01) # noqa: ASYNC251 + # The main thread should not have any output, but the new thread should + stream = MockStream(k.globals["stream"]) + for m in stream.operations: + assert ("console" not in m) or not m["console"] + + thread_stream = MockStream(k.globals["thread_stream"]) + assert len(thread_stream.operations) == 2 + assert "hello" in thread_stream.operations[0]["console"]["data"] + assert thread_stream.operations[0]["console"]["channel"] == "stdout" + assert "world" in thread_stream.operations[1]["console"]["data"] + assert thread_stream.operations[1]["console"]["channel"] == "stdout" + + +async def test_thread_should_exit_on_rerun( + k: Kernel, exec_req: ExecReqProvider +) -> None: + """Test that a thread's exit event is set when cell lifecycle is disposed.""" + + await k.run( + [ + exec_req.get("import marimo as mo"), + exec_req.get( + """ + def target(): + ... + """ + ), + er := exec_req.get( + """ + thread = mo.Thread(target=target) + thread.start() + """ + ), + ] + ) + + thread = k.globals["thread"] + assert not thread.should_exit + + # rerunning the cell should trigger the cell lifecycle disposal and set its exit event + await k.run([er]) + assert thread.should_exit + + +async def test_thread_should_not_exit_on_other_cell_run( + k: Kernel, exec_req: ExecReqProvider +) -> None: + """Test that a thread's exit event is set when cell lifecycle is disposed.""" + + await k.run( + [ + exec_req.get("import marimo as mo"), + exec_req.get( + """ + def target(): + ... + """ + ), + exec_req.get( + """ + thread = mo.Thread(target=target) + thread.start() + """ + ), + er := exec_req.get( + """ + ... + """ + ), + ] + ) + + thread = k.globals["thread"] + assert not thread.should_exit + + # rerunning a cell that is not related to the spawning cell should _not_ + # signal the thread to exit + await k.run([er]) + assert not thread.should_exit + + +async def test_thread_should_exit_on_deletion( + k: Kernel, exec_req: ExecReqProvider +) -> None: + """Test that a thread's exit event is set when cell lifecycle is disposed.""" + + await k.run( + [ + exec_req.get("import marimo as mo"), + exec_req.get( + """ + def target(): + ... + """ + ), + er := exec_req.get( + """ + thread = mo.Thread(target=target) + thread.start() + """ + ), + ] + ) + + thread = k.globals["thread"] + assert not thread.should_exit + + await k.delete_cell(DeleteCellCommand(er.cell_id)) + assert thread.should_exit + + +async def test_threads_share_output_list_with_parent( + k: Kernel, exec_req: ExecReqProvider +) -> None: + """Test that mo.Threads share the parent's output list. + + Sharing ensures that accumulated_output is non-None after cell + execution, so the post-execution hook doesn't wipe thread output. + """ + + await k.run( + [ + exec_req.get("import marimo as mo"), + exec_req.get( + """ + output_ids = [] + def target(): + import marimo as mo + ctx = mo._runtime.context.get_context() + output_ids.append(id(ctx.execution_context.output)) + mo.output.append("from thread") + """ + ), + exec_req.get( + """ + mo.output.append("some output") + threads = [mo.Thread(target=target) for _ in range(3)] + for t in threads: + t.start() + for t in threads: + t.join() + """ + ), + ] + ) + + assert not k.errors + time.sleep(0.01) # noqa: ASYNC251 + + output_ids = k.globals["output_ids"] + # All threads should share the same output list as the parent + assert len(output_ids) == 3 + assert len(set(output_ids)) == 1, ( + "All threads should share the same output list, " + f"but got ids: {output_ids}" + ) diff --git a/tests/_runtime/test_topology.py b/tests/_runtime/test_topology.py new file mode 100644 index 00000000000..9db82660c41 --- /dev/null +++ b/tests/_runtime/test_topology.py @@ -0,0 +1,462 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from functools import partial + +import pytest + +from marimo._ast import compiler +from marimo._runtime.dataflow.topology import MutableGraphTopology +from marimo._types.ids import CellId_t + +parse_cell = partial(compiler.compile_cell, cell_id=CellId_t("0")) + + +class TestMutableGraphTopology: + """Tests for MutableGraphTopology class.""" + + def test_empty_graph(self) -> None: + """Test that an empty graph has no cells, parents, or children.""" + graph = MutableGraphTopology() + assert len(graph.cells) == 0 + assert len(graph.children) == 0 + assert len(graph.parents) == 0 + + def test_add_single_node(self) -> None: + """Test adding a single node to the graph.""" + graph = MutableGraphTopology() + cell = parse_cell("x = 1") + + graph.add_node("cell_1", cell) + + assert "cell_1" in graph.cells + assert graph.cells["cell_1"] == cell + assert graph.children["cell_1"] == set() + assert graph.parents["cell_1"] == set() + + def test_add_duplicate_node_raises_error(self) -> None: + """Test that adding a duplicate node raises an assertion error.""" + graph = MutableGraphTopology() + cell = parse_cell("x = 1") + + graph.add_node("cell_1", cell) + + with pytest.raises( + AssertionError, match="Cell cell_1 already in graph" + ): + graph.add_node("cell_1", cell) + + def test_add_multiple_nodes(self) -> None: + """Test adding multiple nodes to the graph.""" + graph = MutableGraphTopology() + cell1 = parse_cell("x = 1") + cell2 = parse_cell("y = 2") + cell3 = parse_cell("z = 3") + + graph.add_node("cell_1", cell1) + graph.add_node("cell_2", cell2) + graph.add_node("cell_3", cell3) + + assert len(graph.cells) == 3 + assert "cell_1" in graph.cells + assert "cell_2" in graph.cells + assert "cell_3" in graph.cells + + def test_remove_node(self) -> None: + """Test removing a node from the graph.""" + graph = MutableGraphTopology() + cell = parse_cell("x = 1") + + graph.add_node("cell_1", cell) + graph.remove_node("cell_1") + + assert "cell_1" not in graph.cells + assert "cell_1" not in graph.children + assert "cell_1" not in graph.parents + + def test_remove_nonexistent_node_raises_error(self) -> None: + """Test that removing a non-existent node raises ValueError.""" + graph = MutableGraphTopology() + + with pytest.raises(ValueError, match="Cell cell_1 not found"): + graph.remove_node("cell_1") + + def test_remove_node_with_edges(self) -> None: + """Test that removing a node also removes its edges.""" + graph = MutableGraphTopology() + cell1 = parse_cell("x = 1") + cell2 = parse_cell("y = x") + cell3 = parse_cell("z = y") + + graph.add_node("cell_1", cell1) + graph.add_node("cell_2", cell2) + graph.add_node("cell_3", cell3) + graph.add_edge("cell_1", "cell_2") + graph.add_edge("cell_2", "cell_3") + + # Remove middle node + graph.remove_node("cell_2") + + # Check that cell_2 is removed from children and parents + assert "cell_2" not in graph.cells + assert "cell_2" not in graph.children["cell_1"] + assert "cell_2" not in graph.parents["cell_3"] + + def test_remove_hub_node_cleans_all_neighbors(self) -> None: + """Removing a hub node updates all its parents and children.""" + graph = MutableGraphTopology() + cells = {f"cell_{i}": parse_cell(f"x{i} = {i}") for i in range(1, 6)} + for cid, cell in cells.items(): + graph.add_node(cid, cell) + + # cell_3 is a hub: parents=[cell_1, cell_2], children=[cell_4, cell_5] + graph.add_edge("cell_1", "cell_3") + graph.add_edge("cell_2", "cell_3") + graph.add_edge("cell_3", "cell_4") + graph.add_edge("cell_3", "cell_5") + # unrelated edge + graph.add_edge("cell_1", "cell_2") + + graph.remove_node("cell_3") + + # cell_3 is fully removed + assert "cell_3" not in graph.cells + assert "cell_3" not in graph.children + assert "cell_3" not in graph.parents + + # parents no longer list cell_3 as a child + assert "cell_3" not in graph.children["cell_1"] + assert "cell_3" not in graph.children["cell_2"] + # children no longer list cell_3 as a parent + assert "cell_3" not in graph.parents["cell_4"] + assert "cell_3" not in graph.parents["cell_5"] + + # unrelated edge is untouched + assert "cell_2" in graph.children["cell_1"] + assert "cell_1" in graph.parents["cell_2"] + + def test_add_edge(self) -> None: + """Test adding an edge between two nodes.""" + graph = MutableGraphTopology() + cell1 = parse_cell("x = 1") + cell2 = parse_cell("y = x") + + graph.add_node("cell_1", cell1) + graph.add_node("cell_2", cell2) + graph.add_edge("cell_1", "cell_2") + + assert "cell_2" in graph.children["cell_1"] + assert "cell_1" in graph.parents["cell_2"] + + def test_add_multiple_edges(self) -> None: + """Test adding multiple edges in the graph.""" + graph = MutableGraphTopology() + cell1 = parse_cell("x = 1") + cell2 = parse_cell("y = x") + cell3 = parse_cell("z = x + y") + + graph.add_node("cell_1", cell1) + graph.add_node("cell_2", cell2) + graph.add_node("cell_3", cell3) + graph.add_edge("cell_1", "cell_2") + graph.add_edge("cell_1", "cell_3") + graph.add_edge("cell_2", "cell_3") + + # cell_1 has two children + assert graph.children["cell_1"] == {"cell_2", "cell_3"} + # cell_2 has one child + assert graph.children["cell_2"] == {"cell_3"} + # cell_3 has two parents + assert graph.parents["cell_3"] == {"cell_1", "cell_2"} + + def test_remove_edge(self) -> None: + """Test removing an edge between two nodes.""" + graph = MutableGraphTopology() + cell1 = parse_cell("x = 1") + cell2 = parse_cell("y = x") + + graph.add_node("cell_1", cell1) + graph.add_node("cell_2", cell2) + graph.add_edge("cell_1", "cell_2") + graph.remove_edge("cell_1", "cell_2") + + assert "cell_2" not in graph.children["cell_1"] + assert "cell_1" not in graph.parents["cell_2"] + + def test_remove_nonexistent_edge(self) -> None: + """Test that removing a non-existent edge doesn't raise error.""" + graph = MutableGraphTopology() + cell1 = parse_cell("x = 1") + cell2 = parse_cell("y = 2") + + graph.add_node("cell_1", cell1) + graph.add_node("cell_2", cell2) + + # Should not raise an error (using discard) + graph.remove_edge("cell_1", "cell_2") + + assert "cell_2" not in graph.children["cell_1"] + assert "cell_1" not in graph.parents["cell_2"] + + def test_get_path_direct_edge(self) -> None: + """Test getting a path between two directly connected nodes.""" + graph = MutableGraphTopology() + cell1 = parse_cell("x = 1") + cell2 = parse_cell("y = x") + + graph.add_node("cell_1", cell1) + graph.add_node("cell_2", cell2) + graph.add_edge("cell_1", "cell_2") + + path = graph.get_path("cell_1", "cell_2") + + assert path == [("cell_1", "cell_2")] + + def test_get_path_indirect(self) -> None: + """Test getting a path through intermediate nodes.""" + graph = MutableGraphTopology() + cell1 = parse_cell("x = 1") + cell2 = parse_cell("y = x") + cell3 = parse_cell("z = y") + + graph.add_node("cell_1", cell1) + graph.add_node("cell_2", cell2) + graph.add_node("cell_3", cell3) + graph.add_edge("cell_1", "cell_2") + graph.add_edge("cell_2", "cell_3") + + path = graph.get_path("cell_1", "cell_3") + + assert path == [("cell_1", "cell_2"), ("cell_2", "cell_3")] + + def test_get_path_no_path(self) -> None: + """Test getting a path when no path exists.""" + graph = MutableGraphTopology() + cell1 = parse_cell("x = 1") + cell2 = parse_cell("y = 2") + + graph.add_node("cell_1", cell1) + graph.add_node("cell_2", cell2) + + path = graph.get_path("cell_1", "cell_2") + + assert path == [] + + def test_get_path_same_node(self) -> None: + """Test getting a path from a node to itself.""" + graph = MutableGraphTopology() + cell1 = parse_cell("x = 1") + + graph.add_node("cell_1", cell1) + + path = graph.get_path("cell_1", "cell_1") + + assert path == [] + + def test_get_path_complex_graph(self) -> None: + """Test getting a path in a more complex graph with multiple paths.""" + graph = MutableGraphTopology() + cell1 = parse_cell("a = 1") + cell2 = parse_cell("b = a") + cell3 = parse_cell("c = a") + cell4 = parse_cell("d = b + c") + + graph.add_node("cell_1", cell1) + graph.add_node("cell_2", cell2) + graph.add_node("cell_3", cell3) + graph.add_node("cell_4", cell4) + graph.add_edge("cell_1", "cell_2") + graph.add_edge("cell_1", "cell_3") + graph.add_edge("cell_2", "cell_4") + graph.add_edge("cell_3", "cell_4") + + # BFS should find one of the shortest paths + path = graph.get_path("cell_1", "cell_4") + + # Should be length 2 (one of: cell_1->cell_2->cell_4 or cell_1->cell_3->cell_4) + assert len(path) == 2 + assert path[0][0] == "cell_1" + assert path[1][1] == "cell_4" + + def test_ancestors_empty(self) -> None: + """Test ancestors of a node with no parents.""" + graph = MutableGraphTopology() + cell1 = parse_cell("x = 1") + + graph.add_node("cell_1", cell1) + + ancestors = graph.ancestors("cell_1") + + assert ancestors == set() + + def test_ancestors_single_parent(self) -> None: + """Test ancestors with a single parent.""" + graph = MutableGraphTopology() + cell1 = parse_cell("x = 1") + cell2 = parse_cell("y = x") + + graph.add_node("cell_1", cell1) + graph.add_node("cell_2", cell2) + graph.add_edge("cell_1", "cell_2") + + ancestors = graph.ancestors("cell_2") + + assert ancestors == {"cell_1"} + + def test_ancestors_multiple_generations(self) -> None: + """Test ancestors across multiple generations.""" + graph = MutableGraphTopology() + cell1 = parse_cell("x = 1") + cell2 = parse_cell("y = x") + cell3 = parse_cell("z = y") + + graph.add_node("cell_1", cell1) + graph.add_node("cell_2", cell2) + graph.add_node("cell_3", cell3) + graph.add_edge("cell_1", "cell_2") + graph.add_edge("cell_2", "cell_3") + + ancestors = graph.ancestors("cell_3") + + assert ancestors == {"cell_1", "cell_2"} + + def test_ancestors_diamond_shape(self) -> None: + """Test ancestors in a diamond-shaped graph.""" + graph = MutableGraphTopology() + cell1 = parse_cell("a = 1") + cell2 = parse_cell("b = a") + cell3 = parse_cell("c = a") + cell4 = parse_cell("d = b + c") + + graph.add_node("cell_1", cell1) + graph.add_node("cell_2", cell2) + graph.add_node("cell_3", cell3) + graph.add_node("cell_4", cell4) + graph.add_edge("cell_1", "cell_2") + graph.add_edge("cell_1", "cell_3") + graph.add_edge("cell_2", "cell_4") + graph.add_edge("cell_3", "cell_4") + + ancestors = graph.ancestors("cell_4") + + assert ancestors == {"cell_1", "cell_2", "cell_3"} + + def test_descendants_empty(self) -> None: + """Test descendants of a node with no children.""" + graph = MutableGraphTopology() + cell1 = parse_cell("x = 1") + + graph.add_node("cell_1", cell1) + + descendants = graph.descendants("cell_1") + + assert descendants == set() + + def test_descendants_single_child(self) -> None: + """Test descendants with a single child.""" + graph = MutableGraphTopology() + cell1 = parse_cell("x = 1") + cell2 = parse_cell("y = x") + + graph.add_node("cell_1", cell1) + graph.add_node("cell_2", cell2) + graph.add_edge("cell_1", "cell_2") + + descendants = graph.descendants("cell_1") + + assert descendants == {"cell_2"} + + def test_descendants_multiple_generations(self) -> None: + """Test descendants across multiple generations.""" + graph = MutableGraphTopology() + cell1 = parse_cell("x = 1") + cell2 = parse_cell("y = x") + cell3 = parse_cell("z = y") + + graph.add_node("cell_1", cell1) + graph.add_node("cell_2", cell2) + graph.add_node("cell_3", cell3) + graph.add_edge("cell_1", "cell_2") + graph.add_edge("cell_2", "cell_3") + + descendants = graph.descendants("cell_1") + + assert descendants == {"cell_2", "cell_3"} + + def test_descendants_diamond_shape(self) -> None: + """Test descendants in a diamond-shaped graph.""" + graph = MutableGraphTopology() + cell1 = parse_cell("a = 1") + cell2 = parse_cell("b = a") + cell3 = parse_cell("c = a") + cell4 = parse_cell("d = b + c") + + graph.add_node("cell_1", cell1) + graph.add_node("cell_2", cell2) + graph.add_node("cell_3", cell3) + graph.add_node("cell_4", cell4) + graph.add_edge("cell_1", "cell_2") + graph.add_edge("cell_1", "cell_3") + graph.add_edge("cell_2", "cell_4") + graph.add_edge("cell_3", "cell_4") + + descendants = graph.descendants("cell_1") + + assert descendants == {"cell_2", "cell_3", "cell_4"} + + def test_properties_are_readonly(self) -> None: + """Test that the properties return Mapping (read-only) types.""" + graph = MutableGraphTopology() + cell1 = parse_cell("x = 1") + + graph.add_node("cell_1", cell1) + + # The properties should return Mapping types + from collections.abc import Mapping + + assert isinstance(graph.cells, Mapping) + assert isinstance(graph.children, Mapping) + assert isinstance(graph.parents, Mapping) + + def test_complex_graph_operations(self) -> None: + """Test a complex sequence of operations on the graph.""" + graph = MutableGraphTopology() + + # Build a graph: 1 -> 2 -> 3 -> 4 + # \-> 5 -> 6 + cells = {f"cell_{i}": parse_cell(f"x{i} = {i}") for i in range(1, 7)} + + for cell_id, cell in cells.items(): + graph.add_node(cell_id, cell) + + graph.add_edge("cell_1", "cell_2") + graph.add_edge("cell_2", "cell_3") + graph.add_edge("cell_3", "cell_4") + graph.add_edge("cell_2", "cell_5") + graph.add_edge("cell_5", "cell_6") + + # Test various operations + assert graph.descendants("cell_1") == { + "cell_2", + "cell_3", + "cell_4", + "cell_5", + "cell_6", + } + assert graph.descendants("cell_2") == { + "cell_3", + "cell_4", + "cell_5", + "cell_6", + } + assert graph.ancestors("cell_4") == {"cell_1", "cell_2", "cell_3"} + assert graph.ancestors("cell_6") == {"cell_1", "cell_2", "cell_5"} + + # Remove an edge and test again + graph.remove_edge("cell_2", "cell_5") + assert graph.descendants("cell_2") == {"cell_3", "cell_4"} + + # Remove a node and test + graph.remove_node("cell_3") + assert graph.descendants("cell_1") == {"cell_2"} + assert "cell_3" not in graph.cells diff --git a/tests/_runtime/test_trace.py b/tests/_runtime/test_trace.py index f8e8d11657e..b6eb8145e96 100644 --- a/tests/_runtime/test_trace.py +++ b/tests/_runtime/test_trace.py @@ -1,17 +1,57 @@ -# Copyright 2024 Marimo. All rights reserved. +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import copy import os import re import subprocess import sys import textwrap +from typing import TYPE_CHECKING +from unittest.mock import patch -from marimo._runtime.requests import ( - ExecutionRequest, +from marimo._runtime.commands import ( + ExecuteCellCommand, + ExecuteCellsCommand, + HTTPRequest, ) from marimo._runtime.runtime import Kernel +from marimo._utils import async_path +from tests._messaging.mocks import MockStderr, MockStream + +if TYPE_CHECKING: + from pathlib import Path + + from tests.conftest import MockedKernel class TestScriptTrace: + @staticmethod + def test_function_script_trace() -> None: + p = subprocess.run( + [sys.executable, "tests/_runtime/script_data/fn_exception.py"], + capture_output=True, + ) + assert p.returncode == 1 + + result = p.stderr.decode() + assert "ZeroDivisionError: division by zero" in result + assert ('fn_exception.py", line 14') in result + assert ('fn_exception.py", line 25') in result + assert "bad_divide(0, x)" in result + assert "y / x" in result + # Test col_offset + # Expected output: + # y = y / x + # ~~^~~ + # exact line numbers differ by python version + if sys.version_info >= (3, 11): + assert ( + result.split("y / x")[1] + .split("\n")[1] + .startswith(" ~~^~~") + ) + @staticmethod def test_script_trace() -> None: p = subprocess.run( @@ -29,7 +69,7 @@ def test_script_trace() -> None: # y = y / x # ^ # exact line numbers differ by python version - if sys.version_info >= (3, 11): + if sys.version_info == (3, 11): assert ( result.split("y / x")[1].split("\n")[1].startswith(" ^") ) @@ -47,7 +87,7 @@ def test_script_trace_with_output() -> None: result = p.stderr.decode() assert "ZeroDivisionError: division by zero" in result - assert ('script_exception_with_output.py"' ", line 11") in result + assert ('script_exception_with_output.py", line 17') in result assert "y / x" in result @staticmethod @@ -70,13 +110,45 @@ def test_script_trace_with_imported_file() -> None: assert ( os.path.normpath( - "tests/_runtime/script_data/script_exception_with_imported_function.py" # noqa: E501 + "tests/_runtime/script_data/script_exception_with_imported_function.py" ) + '", line 11' in result ) assert "y = y / x" in result + @staticmethod + def test_script_trace_function() -> None: + p = subprocess.run( + [ + sys.executable, + "tests/_runtime/script_data/script_exception_function.py", + ], + capture_output=True, + ) + assert p.returncode == 1 + + result = p.stderr.decode() + assert "ZeroDivisionError: division by zero" in result + assert ('script_exception_function.py", line 9') in result + assert "y / 0" in result + + @staticmethod + def test_script_trace_setup_cell() -> None: + p = subprocess.run( + [ + sys.executable, + "tests/_runtime/script_data/script_exception_setup_cell.py", + ], + capture_output=True, + ) + assert p.returncode == 1 + + result = p.stderr.decode() + assert "ZeroDivisionError" in result + assert ('script_exception_setup_cell.py", line 10') in result + assert "y / x" in result + class TestAppTrace: @staticmethod @@ -86,7 +158,7 @@ async def test_app_trace_body_line_number( k = execution_kernel await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="0", code=textwrap.dedent( """ @@ -103,8 +175,9 @@ async def test_app_trace_body_line_number( ) # Naively strip tags to check trace + stderr_messages = MockStderr(k.stderr) tag_re = re.compile(r"(|<[^>]*>)") - result = k.stderr.messages[-1] + result = stderr_messages.messages[-1] result = tag_re.sub("", result) assert "ZeroDivisionError: division by zero" in result @@ -129,7 +202,7 @@ async def test_app_trace_output_line_number( k = execution_kernel await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="0", code=textwrap.dedent( """ @@ -143,7 +216,7 @@ def foo(): #L2 """ ), ), - ExecutionRequest( + ExecuteCellCommand( cell_id="1", code="foo()", ), @@ -178,19 +251,19 @@ async def test_app_trace_name_error_reference_caught( k = execution_kernel await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="0", code=textwrap.dedent( """ try: R = R # Causes error since no def - C = 0 # Unaccessible + C = 0 # Inaccessible except: pass """ ), ), - ExecutionRequest( + ExecuteCellCommand( cell_id="1", code=textwrap.dedent( """ @@ -200,24 +273,175 @@ async def test_app_trace_name_error_reference_caught( ), ] ) + # Runtime error expected- since not a kernel error check stderr + + stream_messages = MockStream(k.stream) + stderr_messages = MockStderr(k.stderr) + assert "C" not in k.globals if k.execution_type == "strict": assert ( "name `R` is referenced before definition." - in k.stream.messages[-4][1]["output"]["data"][0]["msg"] + in stream_messages.operations[-4]["output"]["data"][0]["msg"] ) assert ( "This cell wasn't run" - in k.stream.messages[-1][1]["output"]["data"][0]["msg"] + in stream_messages.operations[-1]["output"]["data"][0]["msg"] ) else: assert ( - "marimo came across the undefined variable `C` during runtime." - in k.stream.messages[-2][1]["output"]["data"][0]["msg"] + "Name `C` is not defined." + in stream_messages.operations[-2]["output"]["data"][0]["msg"] ) - assert "NameError" in k.stderr.messages[0] - assert "NameError" in k.stderr.messages[-1] + assert "NameError" in stderr_messages.messages[0] + assert "NameError" in stderr_messages.messages[-1] + + +class TestRunModeTrace: + @staticmethod + async def test_run_mode_trace_references_real_file( + run_mode_kernel: MockedKernel, + tmp_path: Path, + ) -> None: + """In run mode, tracebacks should reference the real notebook file.""" + # Create a real notebook file so solve_source_position can match + notebook_code = textwrap.dedent("""\ + import marimo + + __generated_with = "0.0.0" + app = marimo.App() + + + @app.cell + def _(): + x = 1 + return (x,) + + + @app.cell + def _(x): + y = x + 1 + raise ValueError("boom") + return (y,) + + + if __name__ == "__main__": + app.run() + """) + notebook_file = tmp_path / "test_notebook.py" + notebook_file.write_text(notebook_code) + + k = run_mode_kernel.k + k.app_metadata.filename = str(notebook_file) + + k.user_config = copy.deepcopy(k.user_config) + k.user_config["runtime"]["show_tracebacks"] = True + + cell_code = textwrap.dedent( + """ + x = 1 + """ + ) + await k.run( + [ + ExecuteCellCommand(cell_id="0", code=cell_code), + ExecuteCellCommand( + cell_id="1", + code=textwrap.dedent( + """ + y = x + 1 + raise ValueError("boom") + """ + ), + ), + ] + ) + + tag_re = re.compile(r"(|<[^>]*>)") + + # Collect all output: stderr messages and stream operations + all_output = "\n".join(k.stderr.messages) + stream_messages = MockStream(k.stream) + for op in stream_messages.operations: + all_output += "\n" + str(op) + + result = tag_re.sub("", all_output) + + assert "ValueError" in result or "boom" in result + # Should reference the real notebook file, NOT __marimo__cell_ + assert "__marimo__cell_" not in result + assert "test_notebook.py" in result + + @staticmethod + async def test_run_mode_watch_invalidates_cache( + run_mode_kernel: MockedKernel, + tmp_path: Path, + ) -> None: + """Source position cache is invalidated on recompilation (--watch).""" + from marimo._ast.compiler import _build_source_position_map + + notebook_v1 = textwrap.dedent("""\ + import marimo + + __generated_with = "0.0.0" + app = marimo.App() + + + @app.cell + def _(): + x = 1 + return (x,) + + + if __name__ == "__main__": + app.run() + """) + notebook_file = tmp_path / "watch_notebook.py" + notebook_file.write_text(notebook_v1) + + k = run_mode_kernel.k + k.app_metadata.filename = str(notebook_file) + + # First compile — populates cache + await k.run([ExecuteCellCommand(cell_id="0", code="x = 1")]) + cached = _build_source_position_map(str(notebook_file)) + assert len(cached) == 1 + + # Simulate file change (--watch): add a second cell + notebook_v2 = textwrap.dedent("""\ + import marimo + + __generated_with = "0.0.0" + app = marimo.App() + + + @app.cell + def _(): + x = 1 + return (x,) + + + @app.cell + def _(x): + y = x + 2 + return (y,) + + + if __name__ == "__main__": + app.run() + """) + notebook_file.write_text(notebook_v2) + + # Second compile — mutate_graph should clear cache + await k.run( + [ + ExecuteCellCommand(cell_id="0", code="x = 1"), + ExecuteCellCommand(cell_id="1", code="y = x + 2"), + ] + ) + refreshed = _build_source_position_map(str(notebook_file)) + assert len(refreshed) == 2 class TestEmbedTrace: @@ -227,17 +451,24 @@ async def test_embed_trace( ) -> None: await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="0", code=textwrap.dedent( """ from tests._runtime.script_data import ( script_exception_with_output ) + """ + ), + ), + ExecuteCellCommand( + cell_id="1", + code=textwrap.dedent( + """ await script_exception_with_output.app.embed() """ ), - ) + ), ] ) @@ -247,10 +478,60 @@ async def test_embed_trace( result = tag_re.sub("", result) # windows support - file_path = os.path.normpath( + file_path = await async_path.normpath( "tests/_runtime/script_data/script_exception_with_output.py" ) assert "ZeroDivisionError: division by zero" in result - assert (file_path + "", line 11") in result + assert (file_path + "", line 17") in result assert "y / x" in result + + +def _http_request_with_headers(headers: dict[str, str]) -> HTTPRequest: + return HTTPRequest( + url={"path": "/", "port": None, "scheme": "http"}, + base_url={"path": "/", "port": None, "scheme": "http"}, + headers=headers, + query_params={}, + path_params={}, + cookies={}, + meta={}, + user={}, + ) + + +class TestTraceContextPropagation: + @staticmethod + async def test_handle_message_attaches_request_trace_context( + k: Kernel, + ) -> None: + """The kernel links spans to the trace carried by the request.""" + headers = { + "traceparent": ( + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" + ) + } + command = ExecuteCellsCommand( + cell_ids=["0"], + codes=["x = 1"], + request=_http_request_with_headers(headers), + ) + + with patch( + "marimo._runtime.runtime.attach_trace_context" + ) as mock_attach: + await k.handle_message(command) + + mock_attach.assert_called_once_with(headers) + + @staticmethod + async def test_handle_message_without_request(k: Kernel) -> None: + """Commands without a request pass no headers (no-op propagation).""" + command = ExecuteCellsCommand(cell_ids=["0"], codes=["x = 1"]) + + with patch( + "marimo._runtime.runtime.attach_trace_context" + ) as mock_attach: + await k.handle_message(command) + + mock_attach.assert_called_once_with(None) diff --git a/tests/_runtime/test_unwrap_user_exception.py b/tests/_runtime/test_unwrap_user_exception.py new file mode 100644 index 00000000000..ad68a2fc76b --- /dev/null +++ b/tests/_runtime/test_unwrap_user_exception.py @@ -0,0 +1,85 @@ +# Copyright 2026 Marimo. All rights reserved. +"""Unit tests for `marimo._runtime.exceptions.unwrap_user_exception`.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +from marimo._runtime.exceptions import ( + MarimoMissingRefError, + MarimoRuntimeException, + unwrap_user_exception, +) + + +def _wrap(cause: BaseException) -> MarimoRuntimeException: + """Build a `MarimoRuntimeException` with `__cause__` set.""" + try: + raise MarimoRuntimeException from cause + except MarimoRuntimeException as exc: + return exc + + +def _graph(definitions: set[str]) -> Any: + """Minimal graph stub with only the attribute `unwrap` reads.""" + return SimpleNamespace(definitions=definitions) + + +def test_unwrap_no_graph_returns_raw_cause() -> None: + cause = ValueError("boom") + wrapped = _wrap(cause) + + assert unwrap_user_exception(wrapped) is cause + + +def test_unwrap_nameerror_without_graph_unchanged() -> None: + """No graph → upgrade never fires, even for NameError.""" + cause = NameError("name 'x' is not defined") + cause.name = "x" # set explicitly; constructor doesn't. + wrapped = _wrap(cause) + + assert unwrap_user_exception(wrapped) is cause + + +def test_unwrap_nameerror_with_graph_upgrades_when_in_definitions() -> None: + cause = NameError("name 'x' is not defined") + cause.name = "x" + wrapped = _wrap(cause) + + unwrapped = unwrap_user_exception(wrapped, graph=_graph({"x"})) + + assert isinstance(unwrapped, MarimoMissingRefError) + assert unwrapped.ref == "x" + assert unwrapped.name_error is cause + + +def test_unwrap_nameerror_with_graph_passthrough_when_not_in_definitions() -> ( + None +): + """`.name` is set but the graph doesn't define it — no upgrade.""" + cause = NameError("name 'x' is not defined") + cause.name = "x" + wrapped = _wrap(cause) + + assert unwrap_user_exception(wrapped, graph=_graph(set())) is cause + + +def test_unwrap_nameerror_with_none_name_returns_raw() -> None: + """`NameError.name is None` (the constructor default) → upgrade + short-circuits via the `if name and …` guard.""" + cause = NameError("name 'x' is not defined") + # Don't set `.name` — leave it as the constructor's default + # (None on most CPython versions). The guard must not upgrade. + assert getattr(cause, "name", None) is None + wrapped = _wrap(cause) + + # Even with `x` in graph.definitions, the guard prevents upgrade. + assert unwrap_user_exception(wrapped, graph=_graph({"x"})) is cause + + +def test_unwrap_no_cause_returns_none() -> None: + """`MarimoRuntimeException` raised without `from …` has no cause.""" + wrapped = MarimoRuntimeException() + + assert unwrap_user_exception(wrapped) is None diff --git a/tests/_runtime/test_validate_graph.py b/tests/_runtime/test_validate_graph.py deleted file mode 100644 index 264578d39b0..00000000000 --- a/tests/_runtime/test_validate_graph.py +++ /dev/null @@ -1,136 +0,0 @@ -# Copyright 2024 Marimo. All rights reserved. -from __future__ import annotations - -from functools import partial -from typing import cast - -from marimo._ast import compiler -from marimo._messaging.errors import ( - CycleError, - DeleteNonlocalError, - MultipleDefinitionError, -) -from marimo._runtime import dataflow -from marimo._runtime.validate_graph import check_for_errors - -parse_cell = partial(compiler.compile_cell, cell_id="0") - - -def test_multiple_definition_error() -> None: - graph = dataflow.DirectedGraph() - graph.register_cell("0", parse_cell("x = 0")) - graph.register_cell("1", parse_cell("x = 1")) - errors = check_for_errors(graph) - assert set(errors.keys()) == set(["0", "1"]) - assert errors["0"] == (MultipleDefinitionError(name="x", cells=("1",)),) - assert errors["1"] == (MultipleDefinitionError(name="x", cells=("0",)),) - - graph.register_cell("2", parse_cell("x = 1")) - errors = check_for_errors(graph) - assert set(errors.keys()) == set(["0", "1", "2"]) - assert errors["0"] == ( - MultipleDefinitionError(name="x", cells=("1", "2")), - ) - assert errors["1"] == ( - MultipleDefinitionError(name="x", cells=("0", "2")), - ) - assert errors["2"] == ( - MultipleDefinitionError(name="x", cells=("0", "1")), - ) - - -def test_overlapping_multiple_definition_errors() -> None: - graph = dataflow.DirectedGraph() - graph.register_cell("0", parse_cell("x = 0")) - graph.register_cell("1", parse_cell("x, y = 1, 2")) - graph.register_cell("2", parse_cell("y, z = 3, 4")) - errors = check_for_errors(graph) - assert set(errors.keys()) == set(["0", "1", "2"]) - assert errors["0"] == (MultipleDefinitionError(name="x", cells=("1",)),) - assert errors["1"] == ( - MultipleDefinitionError(name="x", cells=("0",)), - MultipleDefinitionError(name="y", cells=("2",)), - ) - assert errors["2"] == (MultipleDefinitionError(name="y", cells=("1",)),) - - -def test_underscore_variables_are_private() -> None: - graph = dataflow.DirectedGraph() - graph.register_cell("0", parse_cell("_x = 0")) - graph.register_cell("1", parse_cell("_x = 1")) - errors = check_for_errors(graph) - assert not errors - - graph = dataflow.DirectedGraph() - graph.register_cell("0", parse_cell("_x = 0")) - graph.register_cell("1", parse_cell("del _x")) - errors = check_for_errors(graph) - assert not errors - - -def test_delete_nonlocal_error() -> None: - graph = dataflow.DirectedGraph() - graph.register_cell("0", parse_cell("x = 0")) - graph.register_cell("1", parse_cell("del x")) - errors = check_for_errors(graph) - assert set(errors.keys()) == set(["1"]) - assert errors["1"] == (DeleteNonlocalError(name="x", cells=("0",)),) - - -def test_two_node_cycle() -> None: - graph = dataflow.DirectedGraph() - graph.register_cell("0", parse_cell("x = y")) - graph.register_cell("1", parse_cell("y = x")) - errors = check_for_errors(graph) - assert set(errors.keys()) == set(["0", "1"]) - assert errors["0"] == ( - CycleError(edges_with_vars=(("0", ["x"], "1"), ("1", ["y"], "0"))), - ) - assert errors["1"] == ( - CycleError(edges_with_vars=(("0", ["x"], "1"), ("1", ["y"], "0"))), - ) - - -def test_three_node_cycle() -> None: - graph = dataflow.DirectedGraph() - graph.register_cell("0", parse_cell("x = y")) - graph.register_cell("1", parse_cell("y = z")) - graph.register_cell("2", parse_cell("z = x")) - errors = check_for_errors(graph) - assert set(errors.keys()) == set(["0", "1", "2"]) - for _, t in errors.items(): - assert len(t) == 1 - assert isinstance(t[0], CycleError) - edges_with_vars = t[0].edges_with_vars - assert len(edges_with_vars) == 3 - assert ("0", ["x"], "2") in edges_with_vars - assert ("1", ["y"], "0") in edges_with_vars - assert ("2", ["z"], "1") in edges_with_vars - - -def test_cycle_and_multiple_def() -> None: - graph = dataflow.DirectedGraph() - graph.register_cell("0", parse_cell("x, z = y, 0")) - graph.register_cell("1", parse_cell("y, z = x, 0")) - errors = check_for_errors(graph) - assert set(errors.keys()) == set(["0", "1"]) - for cell, t in errors.items(): - assert len(t) == 2 - assert isinstance(t[0], CycleError) or isinstance(t[1], CycleError) - assert isinstance(t[0], MultipleDefinitionError) or isinstance( - t[1], MultipleDefinitionError - ) - cycle_error = cast( - CycleError, t[0] if isinstance(t[0], CycleError) else t[1] - ) - edges_with_vars = cycle_error.edges_with_vars - assert len(edges_with_vars) == 2 - assert ("0", ["x"], "1") in edges_with_vars - assert ("1", ["y"], "0") in edges_with_vars - - multiple_definition_error = cast( - MultipleDefinitionError, - t[0] if isinstance(t[0], MultipleDefinitionError) else t[1], - ) - assert multiple_definition_error.name == "z" - assert multiple_definition_error.cells == (str((int(cell) + 1) % 2),) diff --git a/tests/_runtime/test_virtual_file.py b/tests/_runtime/test_virtual_file.py index b240a5fc1c2..f46a44613af 100644 --- a/tests/_runtime/test_virtual_file.py +++ b/tests/_runtime/test_virtual_file.py @@ -1,10 +1,23 @@ -# Copyright 2024 Marimo. All rights reserved. +# Copyright 2026 Marimo. All rights reserved. from __future__ import annotations +import uuid + +from marimo._runtime.commands import DeleteCellCommand from marimo._runtime.context import get_context -from marimo._runtime.requests import DeleteCellRequest from marimo._runtime.runtime import Kernel -from tests.conftest import ExecReqProvider +from marimo._runtime.virtual_file.storage import ( + InMemoryStorage, + SharedMemoryStorage, + VirtualFileStorageManager, +) +from marimo._runtime.virtual_file.virtual_file import ( + VirtualFile, + VirtualFileLifecycleItem, + VirtualFileRegistry, + read_virtual_file, +) +from tests.conftest import ExecReqProvider, MockedKernel async def test_virtual_file_creation( @@ -24,7 +37,7 @@ async def test_virtual_file_creation( ] ) assert len(get_context().virtual_file_registry.registry) == 1 - for fname, _ in get_context().virtual_file_registry.registry.items(): + for fname in get_context().virtual_file_registry.registry: assert fname.endswith(".pdf") @@ -45,10 +58,10 @@ async def test_virtual_file_deletion( ] ) assert len(get_context().virtual_file_registry.registry) == 1 - for fname, _ in get_context().virtual_file_registry.registry.items(): + for fname in get_context().virtual_file_registry.registry: assert fname.endswith(".pdf") - await k.delete_cell(DeleteCellRequest(cell_id=er.cell_id)) + await k.delete_cell(DeleteCellCommand(cell_id=er.cell_id)) assert not get_context().virtual_file_registry.registry @@ -89,8 +102,8 @@ def create_vfile(arg): assert len(get_context().virtual_file_registry.registry) == 2 # Remove the cells that create the vfiles - await k.delete_cell(DeleteCellRequest(cell_id=create_vfile_1.cell_id)) - await k.delete_cell(DeleteCellRequest(cell_id=create_vfile_2.cell_id)) + await k.delete_cell(DeleteCellCommand(cell_id=create_vfile_1.cell_id)) + await k.delete_cell(DeleteCellCommand(cell_id=create_vfile_2.cell_id)) # Reset the vfile cache await k.run([vfile_cache]) @@ -124,7 +137,7 @@ def create_vfile(arg): assert len(get_context().virtual_file_registry.registry) == 1 # Delete the vfile cache: virtual file registry should be empty - await k.delete_cell(DeleteCellRequest(cell_id=vfile_cache.cell_id)) + await k.delete_cell(DeleteCellCommand(cell_id=vfile_cache.cell_id)) assert len(get_context().virtual_file_registry.registry) == 0 @@ -154,7 +167,7 @@ def create_vfile(arg): ] ) assert len(get_context().virtual_file_registry.registry) == 1 - vfile = list(get_context().virtual_file_registry.filenames())[0] + vfile = next(iter(get_context().virtual_file_registry.filenames())) # 1 reference for the cached `mo.pdf` # + 1 reference for the markdown @@ -190,7 +203,7 @@ def create_vfile(arg): ) ctx = get_context() assert len(ctx.virtual_file_registry.registry) == 1 - vfile = list(ctx.virtual_file_registry.filenames())[0] + vfile = next(iter(ctx.virtual_file_registry.filenames())) # 0 references because HTML not bound to a variable # NB: this test may be flaky! refcount decremented when `__del__` is called @@ -241,7 +254,7 @@ def create_vfile(arg): ) ctx = get_context() assert len(ctx.virtual_file_registry.registry) == 1 - vfile = list(ctx.virtual_file_registry.filenames())[0] + vfile = next(iter(ctx.virtual_file_registry.filenames())) # 1 reference, in the list assert ctx.virtual_file_registry.refcount(vfile) == 1 @@ -281,3 +294,156 @@ async def test_virtual_files_not_supported( ctx = get_context() assert len(ctx.virtual_file_registry.registry) == 0 ctx.virtual_files_supported = True + + +async def test_virtual_file_empty_buffer( + execution_kernel: Kernel, exec_req: ExecReqProvider +) -> None: + k = execution_kernel + await k.run( + [ + exec_req.get( + """ + import io + import marimo as mo + bytestream = io.BytesIO(b"") + pdf_plugin = mo.pdf(bytestream) + """ + ), + ] + ) + # Empty buffers should not be added to the registry + assert len(get_context().virtual_file_registry.registry) == 0 + + +def test_virtual_file_registry_shared_inmemory_storage( + run_mode_kernel: MockedKernel, # noqa: ARG001 +) -> None: + ctx = get_context() + + # Create vfile in existing vfile registry + item = VirtualFileLifecycleItem(ext="pdf", buffer=b"abc") + item.create(context=ctx) + vf = item.virtual_file + + assert read_virtual_file(vf.filename, 3) == b"abc" + + # Simulate a second session initializing its own vfile registry + VirtualFileRegistry(storage=InMemoryStorage()) + + # Ensure old file should still readable + assert read_virtual_file(vf.filename, 3) == b"abc" + + +def test_virtual_file_registry_shared_shared_memory_storage() -> None: + """A shared-memory registry teardown must not clobber another live + registry in the same process. + + AppHost-backed run sessions for one notebook share a process but each + session gets its own runtime context + VirtualFileRegistry. That is the + same multi-registry shape the in-memory regression test above covers; + here we assert the shared-memory backend honors the same contract. + """ + manager = VirtualFileStorageManager() + original_storage = manager.storage + key1 = f"{uuid.uuid4().hex[:8]}.txt" + key2 = f"{uuid.uuid4().hex[:8]}.txt" + context = type("Context", (), {"virtual_files_supported": True})() + + registry1 = None + registry2 = None + try: + manager.storage = None + registry1 = VirtualFileRegistry(storage=SharedMemoryStorage()) + registry1.add(VirtualFile(filename=key1, buffer=b"one"), context) + assert read_virtual_file(key1, 3) == b"one" + + registry2 = VirtualFileRegistry(storage=SharedMemoryStorage()) + registry2.add(VirtualFile(filename=key2, buffer=b"two"), context) + assert read_virtual_file(key2, 3) == b"two" + + registry1.shutdown() + + # A still-live registry must keep serving its files after another + # session tears down. + assert read_virtual_file(key2, 3) == b"two" + finally: + manager.storage = original_storage + if registry1 is not None: + registry1.shutdown() + if registry2 is not None: + registry2.shutdown() + + +def test_create_and_register_with_context( + run_mode_kernel: MockedKernel, # noqa: ARG001 +) -> None: + ctx = get_context() + assert len(ctx.virtual_file_registry.registry) == 0 + + vfile = VirtualFile.create_and_register(b"hello world", "txt") + + assert vfile.filename.endswith(".txt") + # Should be a relative file URL, not a data URL + assert vfile.url.startswith("./@file/") + assert "11-" in vfile.url # 11 bytes = len(b"hello world") + # Should be registered + assert len(ctx.virtual_file_registry.registry) == 1 + assert read_virtual_file(vfile.filename, 11) == b"hello world" + + +def test_create_and_register_without_context() -> None: + # No kernel context initialized — should fall back to data URL + vfile = VirtualFile.create_and_register(b"test data", "bin") + + assert vfile.filename.endswith(".bin") + assert vfile.url.startswith("data:") + + +def test_create_and_register_empty_buffer_uses_data_url( + run_mode_kernel: MockedKernel, # noqa: ARG001 +) -> None: + ctx = get_context() + registry_size_before = len(ctx.virtual_file_registry.registry) + + vfile = VirtualFile.create_and_register(b"", "csv") + + assert vfile.filename.endswith(".csv") + # Empty buffer should use a data URL, not a ./@file/ URL, + # because empty buffers can't be served via the file registry. + assert vfile.url.startswith("data:") + assert not vfile.url.startswith("./@file/") + # Registry should not have grown + assert len(ctx.virtual_file_registry.registry) == registry_size_before + + +def test_create_and_register_empty_buffer_without_context() -> None: + # No kernel context — empty buffer should still produce a data URL + vfile = VirtualFile.create_and_register(b"", "bin") + + assert vfile.filename.endswith(".bin") + assert vfile.url.startswith("data:") + + +def test_create_and_register_virtual_files_not_supported( + run_mode_kernel: MockedKernel, # noqa: ARG001 +) -> None: + ctx = get_context() + ctx.virtual_files_supported = False + try: + vfile = VirtualFile.create_and_register(b"some data", "txt") + + assert vfile.filename.endswith(".txt") + # Should fall back to data URL when virtual files aren't supported + assert vfile.url.startswith("data:") + assert len(ctx.virtual_file_registry.registry) == 0 + finally: + ctx.virtual_files_supported = True + + +def test_create_and_register_preserves_extension( + run_mode_kernel: MockedKernel, # noqa: ARG001 +) -> None: + for ext in ("pdf", "png", "csv"): + vfile = VirtualFile.create_and_register(b"content", ext) + assert vfile.filename.endswith(f".{ext}") diff --git a/tests/_runtime/test_wasm_futures.py b/tests/_runtime/test_wasm_futures.py new file mode 100644 index 00000000000..fbcc351f72e --- /dev/null +++ b/tests/_runtime/test_wasm_futures.py @@ -0,0 +1,892 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import asyncio +import concurrent.futures +import contextvars +import functools +import threading +from typing import Any, cast + +import pytest + +from marimo._runtime._wasm._concurrency import _futures as wasm_futures +from marimo._runtime._wasm._concurrency._futures import AsyncioFuture +from marimo._runtime._wasm._concurrency._install import ( + install_wasm_concurrency_shims, +) +from marimo._runtime._wasm._concurrency._wait import ( + UnsupportedWasmConcurrencyError, +) +from tests._runtime._helpers.wasm import install_run_sync, wait_until +from tests.conftest import mock_pyodide + + +def _identity(value: int) -> int: + return value + + +class _OrderedAsyncioFuture(AsyncioFuture): + def __init__(self, order: int) -> None: + super().__init__() + self._order = order + + def __hash__(self) -> int: + return self._order + + +class _OrderedFuture(concurrent.futures.Future[str]): + def __init__(self, order: int) -> None: + super().__init__() + self._order = order + + def __hash__(self) -> int: + return self._order + + +def _mixed_futures_with_done_shim_before_foreign() -> tuple[ + _OrderedAsyncioFuture, _OrderedFuture, _OrderedAsyncioFuture +]: + for done_order in range(20): + for foreign_order in range(20): + for late_order in range(20): + if len({done_order, foreign_order, late_order}) < 3: + continue + shim_done = _OrderedAsyncioFuture(done_order) + foreign = _OrderedFuture(foreign_order) + shim_late = _OrderedAsyncioFuture(late_order) + ordered = list({shim_done, foreign, shim_late}) + if ordered.index(shim_done) < ordered.index(foreign): + return shim_done, foreign, shim_late + raise AssertionError("could not build ordered futures") + + +def _mixed_futures_with_late_shim_before_done_shim() -> tuple[ + _OrderedAsyncioFuture, _OrderedFuture, _OrderedAsyncioFuture +]: + # Control set iteration so the generator has already skipped the late + # shim before the caller completes it. + for done_order in range(20): + for foreign_order in range(20): + for late_order in range(20): + if len({done_order, foreign_order, late_order}) < 3: + continue + shim_done = _OrderedAsyncioFuture(done_order) + foreign = _OrderedFuture(foreign_order) + shim_late = _OrderedAsyncioFuture(late_order) + ordered = list({shim_done, foreign, shim_late}) + if ordered.index(shim_late) < ordered.index(shim_done): + return shim_done, foreign, shim_late + raise AssertionError("could not build ordered futures") + + +def _wasm_futures_with_reversed_hash_order() -> tuple[ + _OrderedAsyncioFuture, _OrderedAsyncioFuture +]: + for first_order in range(20): + for second_order in range(20): + if first_order == second_order: + continue + first = _OrderedAsyncioFuture(first_order) + second = _OrderedAsyncioFuture(second_order) + if list({first, second}) == [second, first]: + return first, second + raise AssertionError("could not build ordered futures") + + +def test_wasm_thread_pool_map_returns_ordered_results() -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + try: + with concurrent.futures.ThreadPoolExecutor( + max_workers=2 + ) as executor: + assert list( + executor.map(lambda value: value + 1, [1, 2, 3]) + ) == [2, 3, 4] + assert list( + executor.map( + lambda value: value + 1, + [1, 2, 3], + chunksize=0, + ) + ) == [2, 3, 4] + + consumed: list[int] = [] + + def values() -> object: + consumed.append(1) + yield 1 + consumed.append(2) + yield 2 + + iterator = executor.map( + lambda value: value, + values(), + buffersize=1, + ) + assert consumed == [1] + assert next(iterator) == 1 + assert consumed == [1, 2] + assert list(iterator) == [2] + finally: + unpatch() + + +def test_wasm_thread_pool_initializer_state_persists_across_tasks() -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + try: + local = threading.local() + + def initialize() -> None: + local.ready = True + local.count = 0 + + def work() -> tuple[str, bool, int]: + local.count += 1 + return ( + threading.current_thread().name, + local.ready, + local.count, + ) + + with concurrent.futures.ThreadPoolExecutor( + max_workers=2, + thread_name_prefix="lane", + initializer=initialize, + ) as executor: + first = executor.submit(work).result() + second = executor.submit(work).result() + + assert first[0] == second[0] + assert first[1:] == (True, 1) + assert second[1:] == (True, 2) + finally: + unpatch() + + +def test_wasm_thread_pool_initializer_failure_breaks_executor() -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + try: + + def initialize() -> None: + raise RuntimeError("initializer failed") + + with concurrent.futures.ThreadPoolExecutor( + initializer=initialize, + ) as executor: + future = executor.submit(lambda: "unreachable") + with pytest.raises(RuntimeError, match="initializer failed"): + future.result() + with pytest.raises(RuntimeError, match="initializer failed"): + executor.submit(lambda: "later") + finally: + unpatch() + + +def test_wasm_thread_pool_rejects_noncallable_initializer() -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + try: + with pytest.raises(TypeError, match="initializer"): + concurrent.futures.ThreadPoolExecutor(initializer=object()) + finally: + unpatch() + + +def test_wasm_thread_pool_map_validates_buffersize() -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + try: + with concurrent.futures.ThreadPoolExecutor() as executor: + for buffersize in (0, -1): + with pytest.raises(ValueError, match="buffersize"): + executor.map( + lambda value: value, + [1], + buffersize=buffersize, + ) + with pytest.raises(TypeError, match="buffersize"): + executor.map( + lambda value: value, + [1], + buffersize=object(), # type: ignore[arg-type] + ) + finally: + unpatch() + + +def test_wasm_thread_pool_preserves_awaitable_return_values() -> None: + async def returned() -> str: + return "awaited elsewhere" + + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + try: + with concurrent.futures.ThreadPoolExecutor() as executor: + result = executor.submit(lambda: returned()).result() + + assert asyncio.iscoroutine(result) + result.close() + finally: + unpatch() + + +def test_wasm_executor_current_thread_has_thread_surface() -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + try: + + def worker() -> tuple[bool, bool, bool]: + current = threading.current_thread() + return ( + isinstance(current, threading.Thread), + current.ident is not None, + current.is_alive(), + ) + + with concurrent.futures.ThreadPoolExecutor() as executor: + assert executor.submit(worker).result() == ( + True, + True, + True, + ) + finally: + unpatch() + + +def test_wasm_thread_pool_result_exception_and_callbacks() -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + try: + callback_results: list[tuple[str, int | str]] = [] + with concurrent.futures.ThreadPoolExecutor( + max_workers=2 + ) as executor: + success = executor.submit(lambda: 7) + success.add_done_callback( + lambda future: callback_results.append( + ("success", future.result()) + ) + ) + assert success.result() == 7 + + failure = executor.submit( + lambda: (_ for _ in ()).throw(ValueError("boom")) + ) + failure.add_done_callback( + lambda future: callback_results.append( + ("error", type(future.exception()).__name__) + ) + ) + with pytest.raises(ValueError, match="boom"): + failure.result() + + assert callback_results == [ + ("success", 7), + ("error", "ValueError"), + ] + finally: + unpatch() + + +@pytest.mark.asyncio +async def test_wasm_thread_pool_callbacks_run_in_worker_identity() -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + callback_threads: list[tuple[str, str]] = [] + + def worker() -> str: + return threading.current_thread().name + + try: + with concurrent.futures.ThreadPoolExecutor( + thread_name_prefix="CallbackWorker" + ) as executor: + future = executor.submit(worker) + future.add_done_callback( + lambda done: callback_threads.append( + (done.result(), threading.current_thread().name) + ) + ) + await wait_until(lambda: bool(callback_threads)) + + worker_thread, callback_thread = callback_threads[0] + assert worker_thread.startswith("CallbackWorker_") + assert callback_thread == worker_thread + finally: + unpatch() + + +def test_wasm_thread_pool_wait_returns_done_futures() -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + try: + with concurrent.futures.ThreadPoolExecutor( + max_workers=2 + ) as executor: + futures = [ + executor.submit(_identity, value) for value in range(3) + ] + done, not_done = concurrent.futures.wait(futures) + + assert done == set(futures) + assert not not_done + finally: + unpatch() + + +def test_wasm_wait_accepts_stdlib_fs_keyword() -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + future: concurrent.futures.Future[str] = AsyncioFuture() + try: + future.set_result("done") + + done, not_done = concurrent.futures.wait(fs=[future]) + + assert done == {future} + assert not not_done + finally: + unpatch() + + +def test_wasm_wait_first_exception_returns_all_done_without_exception() -> ( + None +): + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + try: + with concurrent.futures.ThreadPoolExecutor() as executor: + futures = [ + executor.submit(_identity, value) for value in range(3) + ] + done, not_done = concurrent.futures.wait( + futures, + return_when=concurrent.futures.FIRST_EXCEPTION, + ) + + assert done == set(futures) + assert not not_done + finally: + unpatch() + + +def test_wasm_wait_rejects_invalid_return_when() -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + try: + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(lambda: "done") + + with pytest.raises( + ValueError, match="Invalid return condition" + ): + concurrent.futures.wait( + [future], + return_when="not-a-return-condition", + ) + finally: + unpatch() + + +def test_wasm_future_timed_waits_honor_short_timeouts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + install_run_sync(monkeypatch) + future: concurrent.futures.Future[str] = AsyncioFuture() + try: + with pytest.raises(concurrent.futures.TimeoutError): + future.result(timeout=0.001) + + done, not_done = concurrent.futures.wait([future], timeout=0.001) + + assert done == set() + assert not_done == {future} + finally: + future.cancel() + unpatch() + + +def test_wasm_future_timed_waits_can_reuse_pending_future( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + install_run_sync(monkeypatch) + future: concurrent.futures.Future[str] = AsyncioFuture() + try: + done, not_done = concurrent.futures.wait([future], timeout=0.001) + assert done == set() + assert not_done == {future} + + with pytest.raises(concurrent.futures.TimeoutError): + future.result(timeout=0.001) + finally: + future.cancel() + unpatch() + + +def test_wasm_thread_pool_as_completed_yields_finished_futures() -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + try: + with concurrent.futures.ThreadPoolExecutor( + max_workers=2 + ) as executor: + futures = [ + executor.submit(_identity, value) for value in range(3) + ] + assert sorted( + future.result() + for future in concurrent.futures.as_completed(futures) + ) == [0, 1, 2] + finally: + unpatch() + + +def test_wasm_as_completed_accepts_stdlib_fs_keyword() -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + future: concurrent.futures.Future[str] = AsyncioFuture() + try: + future.set_result("done") + + assert list(concurrent.futures.as_completed(fs=[future])) == [ + future + ] + finally: + unpatch() + + +def test_wasm_as_completed_preserves_completion_order_after_waiter_starts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + install_run_sync(monkeypatch) + from pyodide import ffi + + first, second = _wasm_futures_with_reversed_hash_order() + + def run_sync(awaitable: object) -> object: + async def run() -> object: + loop = asyncio.get_running_loop() + loop.call_soon(first.set_result, "first") + loop.call_soon(second.set_result, "second") + return await cast(Any, awaitable) + + return asyncio.run(run()) + + monkeypatch.setattr(ffi, "run_sync", run_sync) + try: + iterator = concurrent.futures.as_completed( + [first, second], timeout=1 + ) + + assert next(iterator) is first + assert next(iterator) is second + finally: + unpatch() + + +def test_wasm_as_completed_orders_callback_completion_after_trigger() -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + first: concurrent.futures.Future[str] = AsyncioFuture() + second: concurrent.futures.Future[str] = AsyncioFuture() + try: + first.add_done_callback( + lambda _future: second.set_result("second") + ) + + first.set_result("first") + + assert list(concurrent.futures.as_completed([first, second])) == [ + first, + second, + ] + finally: + unpatch() + + +def test_wasm_as_completed_deduplicates_input_futures() -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + try: + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(lambda: "done") + + assert list( + concurrent.futures.as_completed( + [future, future], timeout=1 + ) + ) == [future] + finally: + unpatch() + + +def test_wasm_as_completed_timeout_zero_yields_done_futures() -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + done_future: concurrent.futures.Future[str] = AsyncioFuture() + pending_future: concurrent.futures.Future[str] = AsyncioFuture() + try: + done_future.set_result("done") + iterator = concurrent.futures.as_completed( + [done_future, pending_future], timeout=0 + ) + + assert next(iterator) is done_future + with pytest.raises(concurrent.futures.TimeoutError): + next(iterator) + finally: + pending_future.cancel() + unpatch() + + +def test_wasm_as_completed_deadline_excludes_late_completions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + first: concurrent.futures.Future[str] = AsyncioFuture() + second: concurrent.futures.Future[str] = AsyncioFuture() + timeout = 0.001 + now = 0.0 + + class _FakeTime: + def monotonic(self) -> float: + return now + + monkeypatch.setattr(wasm_futures, "time", _FakeTime()) + try: + first.set_result("first") + iterator = concurrent.futures.as_completed( + [first, second], timeout=timeout + ) + + assert next(iterator) is first + now = timeout * 2 + second.set_result("late") + + with pytest.raises(concurrent.futures.TimeoutError): + next(iterator) + finally: + second.cancel() + unpatch() + + +def test_wasm_mixed_pending_wait_raises_clear_error() -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + shim_future: concurrent.futures.Future[str] = AsyncioFuture() + foreign_future: concurrent.futures.Future[str] = ( + concurrent.futures.Future() + ) + try: + with pytest.raises( + UnsupportedWasmConcurrencyError, match="mixed pending" + ): + concurrent.futures.wait([shim_future, foreign_future]) + finally: + shim_future.cancel() + foreign_future.cancel() + unpatch() + + +def test_wasm_mixed_as_completed_yields_done_before_clear_error() -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + shim_done: concurrent.futures.Future[str] = AsyncioFuture() + foreign_pending: concurrent.futures.Future[str] = ( + concurrent.futures.Future() + ) + try: + shim_done.set_result("shim") + iterator = concurrent.futures.as_completed( + [shim_done, foreign_pending] + ) + + assert next(iterator) is shim_done + with pytest.raises( + UnsupportedWasmConcurrencyError, match="mixed pending" + ): + next(iterator) + finally: + foreign_pending.cancel() + unpatch() + + +def test_wasm_mixed_as_completed_accepts_foreign_completion_between_yields() -> ( + None +): + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + shim_done: concurrent.futures.Future[str] = AsyncioFuture() + foreign_future: concurrent.futures.Future[str] = ( + concurrent.futures.Future() + ) + try: + shim_done.set_result("shim") + iterator = concurrent.futures.as_completed( + [shim_done, foreign_future] + ) + + assert next(iterator) is shim_done + foreign_future.set_result("foreign") + assert next(iterator) is foreign_future + with pytest.raises(StopIteration): + next(iterator) + finally: + unpatch() + + +def test_wasm_mixed_as_completed_does_not_repeat_foreign_future() -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + shim_done, foreign_future, shim_late = ( + _mixed_futures_with_done_shim_before_foreign() + ) + try: + shim_done.set_result("shim") + iterator = concurrent.futures.as_completed( + [shim_done, foreign_future, shim_late] + ) + + assert next(iterator) is shim_done + foreign_future.set_result("foreign") + assert next(iterator) is foreign_future + shim_late.set_result("late") + assert next(iterator) is shim_late + with pytest.raises(StopIteration): + next(iterator) + finally: + unpatch() + + +def test_wasm_mixed_as_completed_yields_late_wasm_before_foreign_error() -> ( + None +): + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + shim_done, foreign_pending, shim_late = ( + _mixed_futures_with_late_shim_before_done_shim() + ) + try: + shim_done.set_result("shim") + iterator = concurrent.futures.as_completed( + [shim_done, foreign_pending, shim_late] + ) + + assert next(iterator) is shim_done + shim_late.set_result("late") + assert next(iterator) is shim_late + with pytest.raises( + UnsupportedWasmConcurrencyError, match="mixed pending" + ): + next(iterator) + finally: + foreign_pending.cancel() + unpatch() + + +@pytest.mark.asyncio +async def test_wasm_thread_pool_cancelled_queue_allows_immediate_unpatch() -> ( + None +): + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + executor = concurrent.futures.ThreadPoolExecutor() + try: + future = executor.submit(lambda: "unreachable") + + executor.shutdown(wait=True, cancel_futures=True) + + assert future.cancelled() + unpatch() + finally: + executor.shutdown(wait=False, cancel_futures=True) + unpatch() + await asyncio.sleep(0) + + +@pytest.mark.asyncio +async def test_wasm_thread_pool_cancelled_future_allows_immediate_unpatch() -> ( + None +): + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + executor = concurrent.futures.ThreadPoolExecutor() + try: + future = executor.submit(lambda: "unreachable") + + assert future.cancel() + executor.shutdown(wait=True) + + unpatch() + finally: + executor.shutdown(wait=False, cancel_futures=True) + unpatch() + await asyncio.sleep(0) + + +def test_wasm_thread_pool_reuses_worker_local_state_until_shutdown() -> None: + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + try: + local = threading.local() + + def write_value() -> str: + local.value = "worker" + return local.value + + def read_value() -> bool: + return hasattr(local, "value") + + with concurrent.futures.ThreadPoolExecutor() as executor: + assert executor.submit(write_value).result() == "worker" + assert executor.submit(read_value).result() is True + assert not hasattr(local, "value") + finally: + unpatch() + + +@pytest.mark.asyncio +async def test_wasm_thread_pool_does_not_inherit_ambient_contextvars() -> None: + ambient = contextvars.ContextVar("ambient", default="unset") + ambient.set("parent") + + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + try: + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(lambda: ambient.get()) + await wait_until(future.done) + + assert future.result(timeout=0) == "unset" + assert ambient.get() == "parent" + finally: + unpatch() + + +@pytest.mark.asyncio +async def test_asyncio_to_thread_keeps_its_contextvars_contract() -> None: + ambient = contextvars.ContextVar("ambient", default="unset") + ambient.set("parent") + + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + try: + assert await asyncio.to_thread(lambda: ambient.get()) == "parent" + assert ambient.get() == "parent" + finally: + await asyncio.get_running_loop().shutdown_default_executor() + unpatch() + + +@pytest.mark.asyncio +async def test_asyncio_to_thread_uses_worker_thread_local_identity() -> None: + ambient = contextvars.ContextVar("ambient", default="unset") + ambient.set("parent") + + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + try: + local = threading.local() + local.value = "parent" + + def worker() -> tuple[str, bool, str]: + return ( + ambient.get(), + hasattr(local, "value"), + threading.current_thread().name, + ) + + ( + ambient_value, + saw_parent_local, + thread_name, + ) = await asyncio.to_thread(worker) + + assert ambient_value == "parent" + assert saw_parent_local is False + assert thread_name != "MainThread" + assert local.value == "parent" + finally: + await asyncio.get_running_loop().shutdown_default_executor() + unpatch() + + +def test_context_run_executor_keeps_worker_identity_temporary() -> None: + ambient = contextvars.ContextVar("ambient", default="unset") + ambient.set("parent") + context = contextvars.copy_context() + + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + try: + local = threading.local() + local.value = "parent" + + def worker() -> tuple[str, bool, str]: + return ( + ambient.get(), + hasattr(local, "value"), + threading.current_thread().name, + ) + + with concurrent.futures.ThreadPoolExecutor() as executor: + ambient_value, saw_parent_local, thread_name = executor.submit( + context.run, worker + ).result() + + assert ambient_value == "parent" + assert saw_parent_local is False + assert thread_name != "MainThread" + assert context.run(lambda: threading.current_thread().name) == ( + "MainThread" + ) + assert local.value == "parent" + finally: + unpatch() + + +def test_partial_context_run_executor_keeps_worker_identity_temporary() -> ( + None +): + ambient = contextvars.ContextVar("ambient", default="unset") + ambient.set("parent") + context = contextvars.copy_context() + + with mock_pyodide(): + unpatch = install_wasm_concurrency_shims() + try: + local = threading.local() + local.value = "parent" + + def worker() -> tuple[str, bool, str]: + return ( + ambient.get(), + hasattr(local, "value"), + threading.current_thread().name, + ) + + with concurrent.futures.ThreadPoolExecutor() as executor: + ambient_value, saw_parent_local, thread_name = executor.submit( + functools.partial(context.run, worker) + ).result() + + assert ambient_value == "parent" + assert saw_parent_local is False + assert thread_name != "MainThread" + assert context.run(lambda: threading.current_thread().name) == ( + "MainThread" + ) + assert local.value == "parent" + finally: + unpatch() diff --git a/tests/_runtime/test_wasm_multiprocessing.py b/tests/_runtime/test_wasm_multiprocessing.py new file mode 100644 index 00000000000..622ddbf40f3 --- /dev/null +++ b/tests/_runtime/test_wasm_multiprocessing.py @@ -0,0 +1,741 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import asyncio +import concurrent.futures +import multiprocessing +import multiprocessing.context +import threading +from typing import Any + +import pytest + +from marimo._runtime._wasm._concurrency import _state +from marimo._runtime._wasm._concurrency._install import ( + install_wasm_concurrency_shims, + install_wasm_process_shims, + replace_loop_create_task, +) +from marimo._runtime._wasm._concurrency._wait import ( + UnsupportedWasmConcurrencyError, +) +from marimo._runtime._wasm._patches import WasmPatchSet +from tests._runtime._helpers.wasm import ( + install_wasm_process_test_shims, + wait_until, +) +from tests.conftest import mock_pyodide + + +def test_wasm_process_runs_same_interpreter_target() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + values: list[tuple[str, str | None, int | None]] = [] + + def worker(output: list[tuple[str, str | None, int | None]]) -> None: + current = multiprocessing.current_process() + parent = multiprocessing.parent_process() + output.append( + ( + current.name, + None if parent is None else parent.name, + current.pid, + ) + ) + + def read_sentinel(process: Any) -> int: + return process.sentinel + + try: + process = multiprocessing.Process( + target=worker, args=(values,), name="child" + ) + assert process.pid is None + assert process.exitcode is None + assert not process.is_alive() + + process.start() + process.join(timeout=1) + + assert process.pid is not None + assert process.ident == process.pid + assert not process.is_alive() + assert process.exitcode == 0 + assert values == [("child", "MainProcess", process.pid)] + assert multiprocessing.active_children() == [] + with pytest.raises(UnsupportedWasmConcurrencyError): + read_sentinel(process) + finally: + unpatch() + + +def test_wasm_process_context_uses_spawn_process_factory() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + values: list[str] = [] + try: + assert multiprocessing.cpu_count() == 1 + assert multiprocessing.get_all_start_methods() == ["spawn"] + assert multiprocessing.get_start_method() == "spawn" + multiprocessing.set_start_method("spawn") + with pytest.raises(ValueError, match="only supports 'spawn'"): + multiprocessing.set_start_method("fork") + + ctx = multiprocessing.get_context("spawn") + assert ctx.cpu_count() == 1 + assert ctx.current_process().name == "MainProcess" + assert ctx.current_process().ident == ctx.current_process().pid + assert ctx.parent_process() is None + assert isinstance(ctx.Process, type) + assert ctx.Process is multiprocessing.context.SpawnProcess + with pytest.raises(ValueError, match="only supports 'spawn'"): + multiprocessing.get_context("fork") + + class CustomProcess(ctx.Process): + pass + + process = CustomProcess(target=values.append, args=("ctx",)) + process.start() + process.join(timeout=1) + + assert process.exitcode == 0 + assert values == ["ctx"] + finally: + unpatch() + + +def test_wasm_process_blocks_unsupported_factories() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + ctx = multiprocessing.get_context("spawn") + calls = ( + ("Pipe", lambda: multiprocessing.Pipe()), + ("Manager", lambda: multiprocessing.Manager()), + ("JoinableQueue", lambda: multiprocessing.JoinableQueue()), + ("Value", lambda: multiprocessing.Value("i", 1)), + ("Array", lambda: multiprocessing.Array("i", [1])), + ("RawValue", lambda: multiprocessing.RawValue("i", 1)), + ("RawArray", lambda: multiprocessing.RawArray("i", [1])), + ("Event", lambda: multiprocessing.Event()), + ("Lock", lambda: multiprocessing.Lock()), + ("RLock", lambda: multiprocessing.RLock()), + ("Semaphore", lambda: multiprocessing.Semaphore()), + ( + "BoundedSemaphore", + lambda: multiprocessing.BoundedSemaphore(), + ), + ("Condition", lambda: multiprocessing.Condition()), + ("Barrier", lambda: multiprocessing.Barrier(2)), + ("Pipe", lambda: ctx.Pipe()), + ("Manager", lambda: ctx.Manager()), + ("JoinableQueue", lambda: ctx.JoinableQueue()), + ("Value", lambda: ctx.Value("i", 1)), + ("Array", lambda: ctx.Array("i", [1])), + ("RawValue", lambda: ctx.RawValue("i", 1)), + ("RawArray", lambda: ctx.RawArray("i", [1])), + ("Event", lambda: ctx.Event()), + ("Lock", lambda: ctx.Lock()), + ("RLock", lambda: ctx.RLock()), + ("Semaphore", lambda: ctx.Semaphore()), + ("BoundedSemaphore", lambda: ctx.BoundedSemaphore()), + ("Condition", lambda: ctx.Condition()), + ("Barrier", lambda: ctx.Barrier(2)), + ) + for attr, call in calls: + with pytest.raises( + UnsupportedWasmConcurrencyError, + match=attr, + ): + call() + for attr in ("ForkProcess", "ForkServerProcess"): + process_type = getattr(multiprocessing.context, attr, None) + if process_type is None: + continue + with pytest.raises( + UnsupportedWasmConcurrencyError, + match=attr, + ): + process_type(target=lambda: None) + for attr in ("ForkContext", "ForkServerContext"): + context_type = getattr(multiprocessing.context, attr, None) + if context_type is None: + continue + with pytest.raises( + UnsupportedWasmConcurrencyError, + match="Process", + ): + context_type().Process(target=lambda: None) + finally: + unpatch() + + +def test_wasm_process_sets_exitcode_for_target_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(threading, "excepthook", lambda _args: None) + + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + + def fail() -> None: + raise ValueError("boom") + + def exit_with_code() -> None: + raise SystemExit(3) + + try: + failed = multiprocessing.Process(target=fail) + failed.start() + failed.join(timeout=1) + + exited = multiprocessing.Process(target=exit_with_code) + exited.start() + exited.join(timeout=1) + + assert failed.exitcode == 1 + assert exited.exitcode == 3 + finally: + unpatch() + + +@pytest.mark.asyncio +async def test_wasm_active_children_is_scoped_to_current_process() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + started = asyncio.Event() + release = asyncio.Event() + child_seen_children: list[list[object]] = [] + + async def worker() -> None: + child_seen_children.append(multiprocessing.active_children()) + started.set() + await release.wait() + + try: + process = multiprocessing.Process(target=worker, name="child") + process.start() + await asyncio.wait_for(started.wait(), timeout=1) + + assert multiprocessing.active_children() == [process] + assert child_seen_children == [[]] + + release.set() + await wait_until(lambda: not process.is_alive()) + assert multiprocessing.active_children() == [] + finally: + unpatch() + + +def test_wasm_process_start_requires_creator_process() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + child = multiprocessing.Process(target=lambda: None) + errors: list[str] = [] + + async def worker() -> None: + try: + child.start() + except AssertionError as exc: + errors.append(str(exc)) + + try: + process = multiprocessing.Process(target=worker) + process.start() + process.join(timeout=1) + + assert process.exitcode == 0 + assert child.exitcode is None + assert errors == [ + "can only start a process object created by current process" + ] + finally: + unpatch() + + +def test_wasm_daemon_process_cannot_start_child_process() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + errors: list[str] = [] + child_daemon_defaults: list[bool] = [] + + async def worker() -> None: + child = multiprocessing.Process(target=lambda: None) + child_daemon_defaults.append(child.daemon) + try: + child.start() + except AssertionError as exc: + errors.append(str(exc)) + + try: + process = multiprocessing.Process(target=worker, daemon=True) + process.start() + process.join(timeout=1) + + assert process.exitcode == 0 + assert child_daemon_defaults == [True] + assert errors == [ + "daemonic processes are not allowed to have children" + ] + finally: + unpatch() + + +def test_wasm_process_identity_reaches_synthetic_thread_lanes() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + seen: list[tuple[str, str | None]] = [] + + def process_identity() -> tuple[str, str | None]: + current = multiprocessing.current_process() + parent = multiprocessing.parent_process() + return current.name, None if parent is None else parent.name + + async def worker() -> None: + def thread_target() -> None: + seen.append(process_identity()) + + thread = threading.Thread(target=thread_target) + thread.start() + await wait_until(lambda: bool(seen)) + + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(process_identity) + await wait_until(future.done) + seen.append(future.result(timeout=0)) + + try: + process = multiprocessing.Process(target=worker, name="child") + process.start() + process.join(timeout=1) + + assert process.exitcode == 0 + assert seen == [ + ("child", "MainProcess"), + ("child", "MainProcess"), + ] + finally: + unpatch() + + +def test_wasm_process_waits_for_owned_background_thread() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + events: list[str] = [] + + async def worker() -> None: + async def background() -> None: + events.append("thread-start") + await asyncio.sleep(0) + events.append("thread-done") + + thread = threading.Thread(target=background) + thread.start() + events.append("target-returned") + + try: + process = multiprocessing.Process(target=worker) + process.start() + process.join(timeout=1) + + assert process.exitcode == 0 + assert events == [ + "target-returned", + "thread-start", + "thread-done", + ] + finally: + unpatch() + + +def test_wasm_process_cancels_owned_daemon_thread_on_exit() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + events: list[str] = [] + + async def worker() -> None: + async def background() -> None: + events.append("daemon-start") + await asyncio.Event().wait() + events.append("daemon-after-exit") + + thread = threading.Thread(target=background, daemon=True) + thread.start() + await wait_until(lambda: bool(events)) + + try: + process = multiprocessing.Process(target=worker) + process.start() + process.join(timeout=1) + + assert process.exitcode == 0 + assert events == ["daemon-start"] + finally: + unpatch() + + +def test_wasm_process_exception_cleans_owned_daemon_thread( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(threading, "excepthook", lambda _args: None) + + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + events: list[str] = [] + + async def worker() -> None: + async def background() -> None: + events.append("daemon-start") + await asyncio.Event().wait() + events.append("daemon-after-exception") + + thread = threading.Thread(target=background, daemon=True) + thread.start() + await wait_until(lambda: bool(events)) + raise ValueError("boom") + + try: + process = multiprocessing.Process(target=worker) + process.start() + process.join(timeout=1) + + assert process.exitcode == 1 + assert events == ["daemon-start"] + finally: + unpatch() + + +def test_wasm_process_kills_owned_daemon_child_on_exit() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + events: list[str] = [] + children: list[multiprocessing.Process] = [] + + async def child_worker() -> None: + events.append("child-start") + await asyncio.Event().wait() + events.append("child-after-exit") + + async def parent_worker() -> None: + child = multiprocessing.Process(target=child_worker, daemon=True) + children.append(child) + child.start() + await wait_until(lambda: events == ["child-start"]) + events.append("parent-returned") + + try: + process = multiprocessing.Process(target=parent_worker) + process.start() + process.join(timeout=1) + + assert process.exitcode == 0 + assert not children[0].is_alive() + assert children[0].exitcode == -1 + assert events == ["child-start", "parent-returned"] + finally: + unpatch() + + +def test_wasm_process_cancels_owned_asyncio_task_on_exit() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + events: list[str] = [] + tasks: list[asyncio.Task[None]] = [] + + async def worker() -> None: + async def background() -> None: + events.append("task-start") + await asyncio.Event().wait() + events.append("task-after-exit") + + tasks.append(asyncio.create_task(background())) + await wait_until(lambda: events == ["task-start"]) + + try: + process = multiprocessing.Process(target=worker) + process.start() + process.join(timeout=1) + + assert process.exitcode == 0 + assert tasks[0].cancelled() + assert events == ["task-start"] + finally: + unpatch() + + +@pytest.mark.asyncio +async def test_wasm_process_does_not_poison_loop_default_executor() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + events: list[str] = [] + + async def worker() -> None: + events.append(await asyncio.to_thread(lambda: "inside-process")) + + try: + process = multiprocessing.Process(target=worker) + process.start() + await wait_until(lambda: not process.is_alive()) + + assert process.exitcode == 0 + assert events == ["inside-process"] + assert await asyncio.to_thread(lambda: "after-process") == ( + "after-process" + ) + finally: + loop = asyncio.get_running_loop() + default_executor = getattr(loop, "_default_executor", None) + if default_executor is not None: + default_executor.shutdown(wait=False, cancel_futures=True) + loop._default_executor = None + unpatch() + + +def test_wasm_process_waits_for_owned_executor_work() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + events: list[str] = [] + executors: list[concurrent.futures.Executor] = [] + + async def worker() -> None: + executor = concurrent.futures.ThreadPoolExecutor() + executors.append(executor) + executor.submit(lambda: events.append("executor-done")) + events.append("target-returned") + + try: + process = multiprocessing.Process(target=worker) + process.start() + process.join(timeout=1) + + assert process.exitcode == 0 + assert events == ["target-returned", "executor-done"] + finally: + for executor in executors: + executor.shutdown(wait=False, cancel_futures=True) + unpatch() + + +def test_wasm_process_shuts_down_owned_executor_on_exit() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + events: list[str] = [] + executors: list[concurrent.futures.Executor] = [] + + async def worker() -> None: + executor = concurrent.futures.ThreadPoolExecutor() + executors.append(executor) + executor.submit(lambda: events.append("executor-done")) + events.append("target-returned") + + try: + process = multiprocessing.Process(target=worker) + process.start() + process.join(timeout=1) + + assert process.exitcode == 0 + assert events == ["target-returned", "executor-done"] + with pytest.raises(RuntimeError, match="shutdown"): + executors[0].submit(lambda: None) + finally: + unpatch() + + +def test_wasm_process_waits_for_reused_executor_submissions() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + executor = concurrent.futures.ThreadPoolExecutor() + events: list[str] = [] + + async def worker(label: str) -> None: + executor.submit(lambda: events.append(label)) + + try: + first = multiprocessing.Process(target=worker, args=("first",)) + first.start() + first.join(timeout=1) + + second = multiprocessing.Process(target=worker, args=("second",)) + second.start() + second.join(timeout=1) + + assert first.exitcode == 0 + assert second.exitcode == 0 + assert events == ["first", "second"] + finally: + executor.shutdown(wait=False, cancel_futures=True) + unpatch() + + +@pytest.mark.asyncio +async def test_wasm_process_terminate_suppresses_requested_cancel_excepthook( + monkeypatch: pytest.MonkeyPatch, +) -> None: + excepthook_types: list[type[BaseException]] = [] + monkeypatch.setattr( + threading, + "excepthook", + lambda args: excepthook_types.append(args.exc_type), + ) + + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + started = asyncio.Event() + + async def worker() -> None: + started.set() + await asyncio.Event().wait() + + try: + process = multiprocessing.Process(target=worker) + process.start() + await asyncio.wait_for(started.wait(), timeout=1) + + process.terminate() + await wait_until(lambda: not process.is_alive()) + + assert process.exitcode == -1 + assert excepthook_types == [] + finally: + unpatch() + + +@pytest.mark.asyncio +async def test_wasm_process_terminate_cancels_owned_thread_work() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + started = asyncio.Event() + events: list[str] = [] + + async def worker() -> None: + async def background() -> None: + events.append("background-start") + started.set() + await asyncio.Event().wait() + events.append("background-after-cancel") + + thread = threading.Thread(target=background) + thread.start() + await started.wait() + await asyncio.Event().wait() + + try: + process = multiprocessing.Process(target=worker) + process.start() + await asyncio.wait_for(started.wait(), timeout=1) + + process.terminate() + await wait_until(lambda: not process.is_alive()) + await asyncio.sleep(0) + + assert process.exitcode == -1 + assert events == ["background-start"] + finally: + unpatch() + + +def test_wasm_process_task_tracking_patches_active_loop_class( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class CustomLoop: + def create_task(self, coro: Any) -> Any: + return coro + + loop = CustomLoop() + original_create_task = CustomLoop.create_task + monkeypatch.setattr(_state, "get_event_loop", lambda: loop) + + with mock_pyodide(): + patches = WasmPatchSet() + try: + replace_loop_create_task(patches) + + assert CustomLoop.create_task is not original_create_task + finally: + patches.unpatch_all()() + + assert CustomLoop.create_task is original_create_task + + +def test_wasm_process_install_requires_core_shims() -> None: + original_process = multiprocessing.Process + + with mock_pyodide(): + with pytest.raises(RuntimeError, match="must be installed"): + install_wasm_process_shims() + + assert multiprocessing.Process is original_process + + +def test_wasm_process_unpatch_restores_process_patches() -> None: + original_process = multiprocessing.Process + original_context_process = multiprocessing.context.Process + base_context_descriptors = { + name: vars(multiprocessing.context.BaseContext)[name] + for name in ( + "current_process", + "parent_process", + "active_children", + ) + } + + with mock_pyodide(): + core_unpatch = install_wasm_concurrency_shims() + process_unpatch = install_wasm_process_shims() + try: + assert multiprocessing.Process is not original_process + assert ( + multiprocessing.context.Process is not original_context_process + ) + + process_unpatch() + + assert multiprocessing.Process is original_process + assert multiprocessing.context.Process is original_context_process + assert { + name: vars(multiprocessing.context.BaseContext)[name] + for name in base_context_descriptors + } == base_context_descriptors + ctx = multiprocessing.get_context("spawn") + assert ctx.current_process().name == "MainProcess" + assert ctx.parent_process() is None + assert ctx.active_children() == [] + finally: + process_unpatch() + core_unpatch() + + +def test_wasm_core_unpatch_requires_process_unpatch_first() -> None: + with mock_pyodide(): + core_unpatch = install_wasm_concurrency_shims() + process_unpatch = install_wasm_process_shims() + try: + with pytest.raises(RuntimeError, match="process shims"): + core_unpatch() + finally: + process_unpatch() + core_unpatch() + + +def test_wasm_runtime_bootstrap_installs_process_shim() -> None: + from marimo._runtime._wasm import ensure_wasm_runtime_bootstrapped + + original_process = multiprocessing.Process + values: list[str] = [] + + with mock_pyodide(): + unpatch = ensure_wasm_runtime_bootstrapped() + try: + assert multiprocessing.Process is not original_process + process = multiprocessing.Process( + target=values.append, args=("bootstrapped",) + ) + process.start() + process.join(timeout=1) + + assert process.exitcode == 0 + assert values == ["bootstrapped"] + finally: + unpatch() + + assert multiprocessing.Process is original_process diff --git a/tests/_runtime/test_wasm_multiprocessing_pool.py b/tests/_runtime/test_wasm_multiprocessing_pool.py new file mode 100644 index 00000000000..2454537b929 --- /dev/null +++ b/tests/_runtime/test_wasm_multiprocessing_pool.py @@ -0,0 +1,646 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import asyncio +import importlib +import multiprocessing +import multiprocessing.context +import sys +from types import ModuleType +from typing import Any, cast + +import pytest + +from marimo._runtime._wasm._concurrency._install import ( + install_wasm_concurrency_shims, + install_wasm_process_shims, +) +from marimo._runtime._wasm._concurrency._wait import ( + UnsupportedWasmConcurrencyError, +) +from tests._runtime._helpers.wasm import ( + install_wasm_process_test_shims, + wait_until, +) +from tests.conftest import mock_pyodide + + +def _forbid_jspi_promising_frame(monkeypatch: pytest.MonkeyPatch) -> None: + pyodide_module = ModuleType("pyodide") + ffi_module = ModuleType("pyodide.ffi") + + def run_sync(_awaitable: object) -> object: + raise AssertionError("can_run_sync=False should avoid run_sync") + + cast(Any, ffi_module).run_sync = run_sync + cast(Any, ffi_module).can_run_sync = lambda: False + cast(Any, pyodide_module).ffi = ffi_module + monkeypatch.setitem(sys.modules, "pyodide", pyodide_module) + monkeypatch.setitem(sys.modules, "pyodide.ffi", ffi_module) + + +def _interrupt_jspi_wait(monkeypatch: pytest.MonkeyPatch) -> None: + pyodide_module = ModuleType("pyodide") + ffi_module = ModuleType("pyodide.ffi") + + def run_sync(awaitable: object) -> object: + close = getattr(awaitable, "close", None) + if callable(close): + close() + raise KeyboardInterrupt("interrupted") + + cast(Any, ffi_module).run_sync = run_sync + cast(Any, ffi_module).can_run_sync = lambda: True + cast(Any, pyodide_module).ffi = ffi_module + monkeypatch.setitem(sys.modules, "pyodide", pyodide_module) + monkeypatch.setitem(sys.modules, "pyodide.ffi", ffi_module) + + +def test_wasm_pool_serialized_methods() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + with multiprocessing.Pool(2) as pool: + assert pool.apply(lambda value: value + 1, (1,)) == 2 + assert pool.map(lambda value: value * 2, [1, 2, 3]) == [ + 2, + 4, + 6, + ] + assert pool.starmap(lambda a, b: a + b, [(1, 2), (3, 4)]) == [ + 3, + 7, + ] + assert list(pool.imap(lambda value: value + 10, [1, 2])) == [ + 11, + 12, + ] + assert sorted( + pool.imap_unordered(lambda value: value + 20, [1, 2]) + ) == [21, 22] + assert pool.apply_async(lambda: "async").get() == "async" + finally: + unpatch() + + +def test_wasm_pool_context_and_submodule_factories() -> None: + original_pool = multiprocessing.Pool + + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + pool_module = importlib.import_module("multiprocessing.pool") + assert multiprocessing.Pool is not original_pool + + with pool_module.Pool(1) as pool: + assert isinstance(pool, pool_module.Pool) + assert pool.apply(lambda value: value + 1, (1,)) == 2 + + ctx = multiprocessing.get_context("spawn") + with ctx.Pool(1) as pool: + assert pool.map(lambda value: value + 1, [1, 2]) == [2, 3] + + with pytest.raises( + UnsupportedWasmConcurrencyError, + match="multiprocessing.pool.ThreadPool", + ): + pool_module.ThreadPool(1) + finally: + unpatch() + + +def test_wasm_pool_rejects_unsupported_contexts() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + for context_name in ("ForkContext", "ForkServerContext"): + context_type = getattr( + multiprocessing.context, + context_name, + None, + ) + if context_type is None: + continue + context = context_type() + + with pytest.raises(ValueError, match="spawn"): + multiprocessing.Pool(1, context=context) + with pytest.raises( + UnsupportedWasmConcurrencyError, + match=f"{context_name}.Pool", + ): + context.Pool(1) + finally: + unpatch() + + +def test_wasm_pool_unpatch_cleans_lazily_imported_pool_module() -> None: + missing = object() + module_name = "multiprocessing.pool" + saved_module = sys.modules.get(module_name, missing) + saved_parent_attr = getattr(multiprocessing, "pool", missing) + + try: + sys.modules.pop(module_name, None) + if hasattr(multiprocessing, "pool"): + del multiprocessing.pool + + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + assert module_name in sys.modules + assert hasattr(multiprocessing, "pool") + finally: + unpatch() + + assert module_name not in sys.modules + assert not hasattr(multiprocessing, "pool") + finally: + sys.modules.pop(module_name, None) + if saved_module is not missing: + sys.modules[module_name] = saved_module # type: ignore[assignment] + if hasattr(multiprocessing, "pool"): + del multiprocessing.pool + if saved_parent_attr is not missing: + multiprocessing.pool = saved_parent_attr + + +def test_wasm_pool_validates_constructor_parameters() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + with pytest.raises(ValueError, match="number of processes"): + multiprocessing.Pool(0) + with pytest.raises(TypeError, match="initializer"): + multiprocessing.Pool(1, initializer=object()) + for maxtasksperchild in (0, -1, 1.5, "bad"): + with pytest.raises(ValueError, match="maxtasksperchild"): + multiprocessing.Pool(1, maxtasksperchild=maxtasksperchild) + with pytest.raises( + UnsupportedWasmConcurrencyError, + match="maxtasksperchild", + ): + multiprocessing.Pool(1, maxtasksperchild=1) + finally: + unpatch() + + +def test_wasm_pool_rejects_invalid_chunksize() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + with multiprocessing.Pool(1) as pool: + for chunksize in (0, 1.5, float("nan")): + with pytest.raises( + (TypeError, ValueError), match="Chunksize" + ): + pool.map(lambda value: value, [1], chunksize=chunksize) + with pytest.raises( + (TypeError, ValueError), match="Chunksize" + ): + pool.map_async( + lambda value: value, [1], chunksize=chunksize + ) + with pytest.raises( + (TypeError, ValueError), match="Chunksize" + ): + pool.starmap( + lambda value: value, [(1,)], chunksize=chunksize + ) + with pytest.raises( + (TypeError, ValueError), match="Chunksize" + ): + pool.starmap_async( + lambda value: value, [(1,)], chunksize=chunksize + ) + with pytest.raises( + (TypeError, ValueError), match="Chunksize" + ): + pool.imap( + lambda value: value, [1], chunksize=chunksize + ) + with pytest.raises( + (TypeError, ValueError), match="Chunksize" + ): + pool.imap_unordered( + lambda value: value, [1], chunksize=chunksize + ) + finally: + unpatch() + + +def test_wasm_pool_imap_is_lazy() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + consumed: list[int] = [] + + def values() -> Any: + consumed.append(1) + yield 1 + consumed.append(2) + yield 2 + + with multiprocessing.Pool(1) as pool: + results = pool.imap(lambda value: value + 1, values()) + + assert consumed == [] + assert next(results) == 2 + assert consumed == [1] + assert next(results) == 3 + assert consumed == [1, 2] + finally: + unpatch() + + +@pytest.mark.asyncio +async def test_wasm_pool_map_async_defers_input_iteration() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + consumed: list[int] = [] + + def values() -> Any: + consumed.append(1) + yield 1 + + with multiprocessing.Pool(1) as pool: + result = pool.map_async(lambda value: value + 1, values()) + + assert consumed == [] + await wait_until(result.ready) + assert result.get(timeout=0) == [2] + finally: + unpatch() + + +@pytest.mark.parametrize("method_name", ["imap", "imap_unordered"]) +@pytest.mark.asyncio +async def test_wasm_pool_imap_next_timeout_keeps_pending_item( + method_name: str, +) -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + with multiprocessing.Pool(1) as pool: + results = getattr(pool, method_name)( + lambda value: value + 1, + [1], + ) + + with pytest.raises(multiprocessing.TimeoutError): + results.next(timeout=0) + + for _ in range(10): + await asyncio.sleep(0) + try: + assert results.next(timeout=0) == 2 + break + except multiprocessing.TimeoutError: + continue + else: + raise AssertionError("pending imap result did not finish") + + with pytest.raises(StopIteration): + results.next(timeout=0) + finally: + unpatch() + + +@pytest.mark.parametrize("method_name", ["imap", "imap_unordered"]) +def test_wasm_pool_imap_drains_after_close(method_name: str) -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + pool = multiprocessing.Pool(1) + try: + results = getattr(pool, method_name)( + lambda value: value + 1, + [1, 2], + ) + + pool.close() + + assert sorted(results) == [2, 3] + finally: + pool.join() + unpatch() + + +@pytest.mark.parametrize("method_name", ["imap", "imap_unordered"]) +def test_wasm_pool_imap_results_survive_close_and_join( + method_name: str, +) -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + pool = multiprocessing.Pool(1) + try: + results = getattr(pool, method_name)( + lambda value: value + 1, + [1, 2], + ) + + pool.close() + pool.join() + + assert sorted(results) == [2, 3] + finally: + unpatch() + + +@pytest.mark.parametrize("method_name", ["imap", "imap_unordered"]) +def test_wasm_pool_imap_source_errors_survive_join( + method_name: str, +) -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + pool = multiprocessing.Pool(1) + try: + + def values() -> Any: + yield 1 + raise RuntimeError("source failed") + + results = getattr(pool, method_name)( + lambda value: value + 1, + values(), + ) + + pool.close() + pool.join() + + assert next(results) == 2 + with pytest.raises(RuntimeError, match="source failed"): + next(results) + finally: + unpatch() + + +def test_wasm_pool_rejects_closed_work_before_consuming_iterables() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + pool = multiprocessing.Pool(1) + try: + pool.close() + consumed: list[str] = [] + + def values(label: str) -> Any: + consumed.append(label) + yield 1 + + with pytest.raises(ValueError, match="Pool not running"): + pool.map_async(lambda value: value, values("map")) + with pytest.raises(ValueError, match="Pool not running"): + pool.starmap_async( + lambda value: value, + ((value,) for value in values("starmap")), + ) + with pytest.raises(ValueError, match="Pool not running"): + list(pool.imap(lambda value: value, [1], chunksize=0)) + + assert consumed == [] + finally: + pool.join() + unpatch() + + +def test_wasm_pool_callbacks() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + callbacks: list[tuple[str, int | str]] = [] + with multiprocessing.Pool(1) as pool: + result = pool.apply_async( + lambda: 10, + callback=lambda value: callbacks.append(("ok", value)), + ) + assert result.get() == 10 + assert result.ready() + assert result.successful() + + failed = pool.apply_async( + lambda: (_ for _ in ()).throw(ValueError("bad")), + error_callback=lambda exc: callbacks.append( + ("error", type(exc).__name__) + ), + ) + with pytest.raises(ValueError, match="bad"): + failed.get() + assert failed.ready() + assert not failed.successful() + + user_timeout = pool.apply_async( + lambda: (_ for _ in ()).throw(TimeoutError("user timeout")) + ) + with pytest.raises(TimeoutError, match="user timeout"): + user_timeout.get() + + assert callbacks == [("ok", 10), ("error", "ValueError")] + finally: + unpatch() + + +def test_wasm_pool_starmap_async_reports_malformed_rows() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + callbacks: list[str] = [] + with multiprocessing.Pool(1) as pool: + result = pool.starmap_async( + lambda left, right: left + right, + [1], + error_callback=lambda exc: callbacks.append( + type(exc).__name__ + ), + ) + + with pytest.raises(TypeError): + result.get() + + assert callbacks == ["TypeError"] + finally: + unpatch() + + +@pytest.mark.parametrize("method_name", ["map_async", "starmap_async"]) +def test_wasm_pool_async_map_reports_source_iteration_errors( + method_name: str, +) -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + callbacks: list[str] = [] + + def values() -> Any: + yield (1, 2) if method_name == "starmap_async" else 1 + raise RuntimeError("source failed") + + with multiprocessing.Pool(1) as pool: + result = getattr(pool, method_name)( + lambda *args: sum(args), + values(), + error_callback=lambda exc: callbacks.append( + type(exc).__name__ + ), + ) + + with pytest.raises(RuntimeError, match="source failed"): + result.get() + + assert callbacks == ["RuntimeError"] + finally: + unpatch() + + +def test_wasm_pool_wait_suppresses_worker_unsupported_error() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + with multiprocessing.Pool(1) as pool: + result = pool.apply_async( + lambda: (_ for _ in ()).throw( + UnsupportedWasmConcurrencyError("worker") + ) + ) + + result.wait() + + assert result.ready() + with pytest.raises( + UnsupportedWasmConcurrencyError, + match="worker", + ): + result.get() + finally: + unpatch() + + +def test_wasm_pool_join_suppresses_worker_unsupported_error() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + unpatched = False + pool = multiprocessing.Pool(1) + try: + result = pool.apply_async( + lambda: (_ for _ in ()).throw( + UnsupportedWasmConcurrencyError("worker") + ) + ) + + pool.close() + pool.join() + + assert result.ready() + with pytest.raises( + UnsupportedWasmConcurrencyError, + match="worker", + ): + result.get() + unpatch() + unpatched = True + finally: + if not unpatched: + try: + pool.terminate() + pool.join() + except BaseException: + pass + unpatch() + + +@pytest.mark.asyncio +async def test_wasm_pool_async_get_timeout_uses_multiprocessing_error() -> ( + None +): + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + with multiprocessing.Pool(processes=1) as pool: + result = pool.apply_async(lambda: "released") + + with pytest.raises(multiprocessing.TimeoutError): + result.get(timeout=0) + + await wait_until(result.ready) + assert result.get(timeout=0) == "released" + finally: + unpatch() + + +@pytest.mark.asyncio +async def test_wasm_pool_async_wait_reports_missing_jspi_frame( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + _forbid_jspi_promising_frame(monkeypatch) + try: + with multiprocessing.Pool(processes=1) as pool: + result = pool.apply_async(lambda: "released") + + with pytest.raises( + UnsupportedWasmConcurrencyError, + match="JSPI promising frame", + ): + result.wait(timeout=1) + + await wait_until(result.ready) + assert result.get(timeout=0) == "released" + finally: + unpatch() + + +@pytest.mark.asyncio +async def test_wasm_pool_async_wait_reraises_pending_wait_interrupt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + _interrupt_jspi_wait(monkeypatch) + try: + with multiprocessing.Pool(processes=1) as pool: + result = pool.apply_async(lambda: "released") + + with pytest.raises(KeyboardInterrupt, match="interrupted"): + result.wait(timeout=1) + + await wait_until(result.ready) + assert result.get(timeout=0) == "released" + finally: + unpatch() + + +def test_wasm_pool_close_and_join_lifecycle() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + pool = multiprocessing.Pool(1) + try: + with pytest.raises(ValueError, match="still running"): + pool.join() + + pool.close() + with pytest.raises(ValueError, match="Pool not running"): + pool.apply(lambda: None) + pool.join() + finally: + unpatch() + + +def test_wasm_process_unpatch_rejects_live_pool_executor() -> None: + with mock_pyodide(): + core_unpatch = install_wasm_concurrency_shims() + process_unpatch = install_wasm_process_shims() + pool = multiprocessing.Pool(1) + try: + with pytest.raises(RuntimeError, match="process work"): + process_unpatch() + + pool.close() + pool.join() + finally: + try: + pool.terminate() + pool.join() + except BaseException: + pass + process_unpatch() + core_unpatch() diff --git a/tests/_runtime/test_wasm_multiprocessing_queue.py b/tests/_runtime/test_wasm_multiprocessing_queue.py new file mode 100644 index 00000000000..a1b06282605 --- /dev/null +++ b/tests/_runtime/test_wasm_multiprocessing_queue.py @@ -0,0 +1,505 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import asyncio +import multiprocessing +import multiprocessing.queues +import queue +from typing import Any, cast + +import pytest + +from marimo._runtime._wasm._concurrency._wait import ( + UnsupportedWasmConcurrencyError, +) +from tests._runtime._helpers.wasm import install_wasm_process_test_shims +from tests.conftest import mock_pyodide + + +def test_wasm_queue_process_handoff_and_bounds() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + values: Any = multiprocessing.Queue() + + def worker(output: Any) -> None: + output.put("ok") + + process = multiprocessing.Process(target=worker, args=(values,)) + process.start() + process.join(timeout=1) + + assert process.exitcode == 0 + assert values.get(block=False) == "ok" + assert values.empty() + + bounded: Any = multiprocessing.Queue(maxsize=1) + bounded.put_nowait("first") + assert bounded.full() + with pytest.raises(queue.Full): + bounded.put_nowait("second") + with pytest.raises(queue.Full): + bounded.put("second", timeout=0) + with pytest.raises(queue.Full): + bounded.put("negative", timeout=-1) + assert bounded.get_nowait() == "first" + bounded.put("second") + assert bounded.get(timeout=-1) == "second" + + empty_values: Any = multiprocessing.Queue() + with pytest.raises(queue.Empty): + empty_values.get(timeout=0) + with pytest.raises(queue.Empty): + empty_values.get(timeout=-1) + + reference: list[str] = [] + values.put(reference) + assert values.get() is reference + + bounded.put("closed") + bounded.close() + with pytest.raises(ValueError, match="closed"): + bounded.put("after-close") + with pytest.raises(ValueError, match="closed"): + bounded.get() + with pytest.raises(ValueError, match="closed"): + bounded.empty() + finally: + unpatch() + + +def test_wasm_simple_queue_shape_and_close() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + with pytest.raises(TypeError): + cast(Any, multiprocessing.SimpleQueue)(1) + + values: Any = multiprocessing.SimpleQueue() + values.put("simple") + assert values.get() == "simple" + assert values.empty() + for unsupported_attr in ( + "qsize", + "full", + "put_nowait", + "get_nowait", + ): + assert not hasattr(values, unsupported_attr) + with pytest.raises(TypeError): + cast(Any, values).put("blocked", block=False) + with pytest.raises(TypeError): + cast(Any, values).get(block=False) + + values.put("closed") + values.close() + with pytest.raises(ValueError, match="closed"): + values.put("after-close") + with pytest.raises(ValueError, match="closed"): + values.get() + with pytest.raises(ValueError, match="closed"): + values.empty() + finally: + unpatch() + + +def test_wasm_queue_child_close_preserves_parent_messages() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + values: Any = multiprocessing.Queue() + simple_values: Any = multiprocessing.SimpleQueue() + + def worker(output: Any, simple_output: Any) -> None: + output.put("queue") + output.close() + output.join_thread() + simple_output.put("simple") + simple_output.close() + + with pytest.raises(ValueError, match="closed"): + output.put("after-close") + with pytest.raises(ValueError, match="closed"): + simple_output.put("after-close") + + process = multiprocessing.Process( + target=worker, args=(values, simple_values) + ) + process.start() + process.join(timeout=1) + + assert process.exitcode == 0 + assert values.get(timeout=1) == "queue" + assert simple_values.get() == "simple" + finally: + unpatch() + + +def test_wasm_queue_parent_close_preserves_child_messages() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + values: Any = multiprocessing.Queue() + simple_values: Any = multiprocessing.SimpleQueue() + observed: list[str] = [] + + values.put("queue") + simple_values.put("simple") + + def worker(input_queue: Any, simple_input: Any) -> None: + observed.append(input_queue.get(timeout=1)) + observed.append(simple_input.get()) + + async def close_parent_before_child_runs() -> None: + process = multiprocessing.Process( + target=worker, + args=(values, simple_values), + ) + process.start() + values.close() + values.join_thread() + simple_values.close() + await asyncio.sleep(0) + await asyncio.sleep(0) + assert process.exitcode == 0 + + asyncio.run(close_parent_before_child_runs()) + + assert observed == ["queue", "simple"] + with pytest.raises(ValueError, match="closed"): + values.get(block=False) + with pytest.raises(ValueError, match="closed"): + simple_values.get() + finally: + unpatch() + + +def test_wasm_queue_child_close_does_not_wake_parent_get_wait( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + values: Any = multiprocessing.Queue() + + def close_child(handle: Any) -> None: + handle.close() + + def wait_through_child_close(awaitable: object) -> bool: + async def wait_and_put() -> bool: + task = asyncio.create_task(cast(Any, awaitable)) + await asyncio.sleep(0) + process = multiprocessing.Process( + target=close_child, + args=(values,), + ) + process.start() + await asyncio.sleep(0) + assert not task.done() + values.put("after-close") + return await asyncio.wait_for(task, timeout=1) + + return asyncio.run(wait_and_put()) + + monkeypatch.setattr( + "marimo._runtime._wasm._concurrency._mp_queue." + "cooperative_wait", + wait_through_child_close, + ) + + assert values.get(timeout=1) == "after-close" + finally: + unpatch() + + +def test_wasm_queue_child_close_does_not_wake_parent_put_wait( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + values: Any = multiprocessing.Queue(maxsize=1) + values.put("first") + + def close_child(handle: Any) -> None: + handle.close() + + def wait_through_child_close(awaitable: object) -> bool: + async def wait_and_drain() -> bool: + task = asyncio.create_task(cast(Any, awaitable)) + await asyncio.sleep(0) + process = multiprocessing.Process( + target=close_child, + args=(values,), + ) + process.start() + await asyncio.sleep(0) + assert not task.done() + assert values.get(block=False) == "first" + return await asyncio.wait_for(task, timeout=1) + + return asyncio.run(wait_and_drain()) + + monkeypatch.setattr( + "marimo._runtime._wasm._concurrency._mp_queue." + "cooperative_wait", + wait_through_child_close, + ) + + values.put("after-close", timeout=1) + assert values.get(block=False) == "after-close" + finally: + unpatch() + + +def test_wasm_simple_queue_child_close_does_not_wake_parent_get_wait( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + values: Any = multiprocessing.SimpleQueue() + + def close_child(handle: Any) -> None: + handle.close() + + def wait_through_child_close(awaitable: object) -> bool: + async def wait_and_put() -> bool: + task = asyncio.create_task(cast(Any, awaitable)) + await asyncio.sleep(0) + process = multiprocessing.Process( + target=close_child, + args=(values,), + ) + process.start() + await asyncio.sleep(0) + assert not task.done() + values.put("after-close") + return await asyncio.wait_for(task, timeout=1) + + return asyncio.run(wait_and_put()) + + monkeypatch.setattr( + "marimo._runtime._wasm._concurrency._mp_queue." + "cooperative_wait", + wait_through_child_close, + ) + + assert values.get() == "after-close" + finally: + unpatch() + + +def test_wasm_queue_context_and_submodule_factories() -> None: + original_queue = multiprocessing.Queue + original_submodule_queue = multiprocessing.queues.Queue + + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + assert multiprocessing.Queue is not original_queue + assert multiprocessing.queues.Queue is not original_submodule_queue + assert multiprocessing.queues.Empty is queue.Empty + assert multiprocessing.queues.Full is queue.Full + + ctx = multiprocessing.get_context("spawn") + values: Any = ctx.Queue() + values.put("context") + assert values.get(block=False) == "context" + + simple_values: Any = ctx.SimpleQueue() + simple_values.put("context-simple") + assert simple_values.get() == "context-simple" + + submodule_values: Any = multiprocessing.queues.Queue(1, ctx=ctx) + assert isinstance(submodule_values, multiprocessing.queues.Queue) + submodule_values.put("submodule") + assert submodule_values.get(block=False) == "submodule" + + submodule_simple: Any = multiprocessing.queues.SimpleQueue(ctx=ctx) + assert isinstance( + submodule_simple, multiprocessing.queues.SimpleQueue + ) + submodule_simple.put("submodule-simple") + assert submodule_simple.get() == "submodule-simple" + + for call in ( + lambda: multiprocessing.JoinableQueue(), + lambda: ctx.JoinableQueue(), + lambda: multiprocessing.queues.JoinableQueue(), + ): + with pytest.raises( + UnsupportedWasmConcurrencyError, + match="JoinableQueue", + ): + call() + finally: + unpatch() + + assert multiprocessing.Queue is original_queue + assert multiprocessing.queues.Queue is original_submodule_queue + + +def test_wasm_queue_close_rejects_blocked_put_after_wait( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + values: Any = multiprocessing.Queue(maxsize=1) + values.put("first") + + def close_during_wait(awaitable: object) -> bool: + async def wait_and_close() -> bool: + task = asyncio.create_task(cast(Any, awaitable)) + await asyncio.sleep(0) + values.close() + return await asyncio.wait_for(task, timeout=1) + + return asyncio.run(wait_and_close()) + + monkeypatch.setattr( + "marimo._runtime._wasm._concurrency._mp_queue." + "cooperative_wait", + close_during_wait, + ) + + with pytest.raises(ValueError, match="closed"): + values.put("after-close", timeout=1) + with pytest.raises(ValueError, match="closed"): + values.empty() + finally: + unpatch() + + +def test_wasm_queue_close_rejects_blocked_get_after_wait( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + values: Any = multiprocessing.Queue() + + def close_during_wait(awaitable: object) -> bool: + async def wait_and_close() -> bool: + task = asyncio.create_task(cast(Any, awaitable)) + await asyncio.sleep(0) + values.close() + return await asyncio.wait_for(task, timeout=1) + + return asyncio.run(wait_and_close()) + + monkeypatch.setattr( + "marimo._runtime._wasm._concurrency._mp_queue." + "cooperative_wait", + close_during_wait, + ) + + with pytest.raises(ValueError, match="closed"): + values.get() + with pytest.raises(ValueError, match="closed"): + values.empty() + finally: + unpatch() + + +def test_wasm_queue_closed_waiter_does_not_consume_later_message( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + values: Any = multiprocessing.Queue() + observed: list[str] = [] + + def put_child(handle: Any) -> None: + handle.put("child") + + def read_child(handle: Any) -> None: + observed.append(handle.get(timeout=1)) + + def close_and_put_during_wait(awaitable: object) -> bool: + async def wait_and_put() -> bool: + task = asyncio.create_task(cast(Any, awaitable)) + await asyncio.sleep(0) + values.close() + process = multiprocessing.Process( + target=put_child, + args=(values,), + ) + process.start() + await asyncio.sleep(0) + await asyncio.sleep(0) + assert process.exitcode == 0 + return await asyncio.wait_for(task, timeout=1) + + return asyncio.run(wait_and_put()) + + monkeypatch.setattr( + "marimo._runtime._wasm._concurrency._mp_queue." + "cooperative_wait", + close_and_put_during_wait, + ) + + with pytest.raises(ValueError, match="closed"): + values.get(timeout=1) + + reader = multiprocessing.Process(target=read_child, args=(values,)) + reader.start() + reader.join(timeout=1) + + assert reader.exitcode == 0 + assert observed == ["child"] + finally: + unpatch() + + +def test_wasm_simple_queue_closed_waiter_does_not_consume_later_message( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + values: Any = multiprocessing.SimpleQueue() + observed: list[str] = [] + + def put_child(handle: Any) -> None: + handle.put("child") + + def read_child(handle: Any) -> None: + observed.append(handle.get()) + + def close_and_put_during_wait(awaitable: object) -> bool: + async def wait_and_put() -> bool: + task = asyncio.create_task(cast(Any, awaitable)) + await asyncio.sleep(0) + values.close() + process = multiprocessing.Process( + target=put_child, + args=(values,), + ) + process.start() + await asyncio.sleep(0) + await asyncio.sleep(0) + assert process.exitcode == 0 + return await asyncio.wait_for(task, timeout=1) + + return asyncio.run(wait_and_put()) + + monkeypatch.setattr( + "marimo._runtime._wasm._concurrency._mp_queue." + "cooperative_wait", + close_and_put_during_wait, + ) + + with pytest.raises(ValueError, match="closed"): + values.get() + + reader = multiprocessing.Process(target=read_child, args=(values,)) + reader.start() + reader.join(timeout=1) + + assert reader.exitcode == 0 + assert observed == ["child"] + finally: + unpatch() diff --git a/tests/_runtime/test_wasm_process_pool_executor.py b/tests/_runtime/test_wasm_process_pool_executor.py new file mode 100644 index 00000000000..18ac0ce37d4 --- /dev/null +++ b/tests/_runtime/test_wasm_process_pool_executor.py @@ -0,0 +1,301 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import concurrent.futures +import contextvars +import importlib +import multiprocessing.context +import sys +import threading + +import pytest + +from marimo._runtime._wasm._concurrency import _process_install +from marimo._runtime._wasm._concurrency._install import ( + install_wasm_concurrency_shims, + install_wasm_process_shims, +) +from marimo._runtime._wasm._concurrency._wait import ( + UnsupportedWasmConcurrencyError, +) +from tests._runtime._helpers.wasm import ( + install_wasm_process_test_shims, + wait_until, +) +from tests.conftest import mock_pyodide + + +def test_wasm_process_pool_executor_cleans_lazily_imported_submodule() -> None: + missing = object() + module_name = "concurrent.futures.process" + saved_module = sys.modules.get(module_name, missing) + saved_parent_attr = getattr(concurrent.futures, "process", missing) + + try: + sys.modules.pop(module_name, None) + if hasattr(concurrent.futures, "process"): + del concurrent.futures.process + + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + assert module_name in sys.modules + assert hasattr(concurrent.futures, "process") + finally: + unpatch() + + assert module_name not in sys.modules + assert not hasattr(concurrent.futures, "process") + finally: + sys.modules.pop(module_name, None) + if saved_module is not missing: + sys.modules[module_name] = saved_module # type: ignore[assignment] + if hasattr(concurrent.futures, "process"): + del concurrent.futures.process + if saved_parent_attr is not missing: + concurrent.futures.process = saved_parent_attr + + +def test_wasm_process_pool_executor_installs_with_process_stub( + monkeypatch: pytest.MonkeyPatch, +) -> None: + missing = object() + module_name = "concurrent.futures.process" + saved_module = sys.modules.get(module_name, missing) + saved_parent_attr = getattr(concurrent.futures, "process", missing) + saved_executor_attr = vars(concurrent.futures).get( + "ProcessPoolExecutor", + missing, + ) + original_import_module = _process_install.import_module + + def import_or_block(name: str) -> object: + if name == module_name: + raise ImportError("blocked process module") + return original_import_module(name) + + try: + sys.modules.pop(module_name, None) + if hasattr(concurrent.futures, "process"): + del concurrent.futures.process + if "ProcessPoolExecutor" in vars(concurrent.futures): + del concurrent.futures.ProcessPoolExecutor + monkeypatch.setattr( + _process_install, + "import_module", + import_or_block, + ) + + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + assert module_name in sys.modules + assert ( + concurrent.futures.ProcessPoolExecutor.__name__ + == "AsyncioProcessPoolExecutor" + ) + finally: + unpatch() + + assert module_name not in sys.modules + assert "ProcessPoolExecutor" not in vars(concurrent.futures) + finally: + sys.modules.pop(module_name, None) + if saved_module is not missing: + sys.modules[module_name] = saved_module # type: ignore[assignment] + if hasattr(concurrent.futures, "process"): + del concurrent.futures.process + if saved_parent_attr is not missing: + concurrent.futures.process = saved_parent_attr + if "ProcessPoolExecutor" in vars(concurrent.futures): + del concurrent.futures.ProcessPoolExecutor + if saved_executor_attr is not missing: + concurrent.futures.ProcessPoolExecutor = saved_executor_attr + + +def test_wasm_process_pool_executor_factories_and_methods() -> None: + process_module = importlib.import_module("concurrent.futures.process") + original_executor = concurrent.futures.ProcessPoolExecutor + original_submodule_executor = process_module.ProcessPoolExecutor + + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + assert ( + concurrent.futures.ProcessPoolExecutor is not original_executor + ) + assert ( + process_module.ProcessPoolExecutor + is concurrent.futures.ProcessPoolExecutor + ) + + with concurrent.futures.ProcessPoolExecutor( + max_workers=2 + ) as executor: + assert executor.submit(lambda: 42).result() == 42 + assert list( + executor.map(lambda value: value * 2, [1, 2, 3]) + ) == [2, 4, 6] + with pytest.raises(RuntimeError, match="process boom"): + executor.submit( + lambda: (_ for _ in ()).throw( + RuntimeError("process boom") + ) + ).result() + finally: + unpatch() + + assert concurrent.futures.ProcessPoolExecutor is original_executor + assert process_module.ProcessPoolExecutor is original_submodule_executor + + +def test_wasm_process_pool_executor_validates_parameters() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + with pytest.raises(ValueError, match="max_workers"): + concurrent.futures.ProcessPoolExecutor(max_workers=0) + with pytest.raises(TypeError, match="initializer"): + concurrent.futures.ProcessPoolExecutor(initializer=object()) + with pytest.raises(TypeError, match="max_tasks_per_child"): + concurrent.futures.ProcessPoolExecutor( + max_tasks_per_child="bad", # type: ignore[arg-type] + ) + with pytest.raises(ValueError, match="max_tasks_per_child"): + concurrent.futures.ProcessPoolExecutor(max_tasks_per_child=0) + with pytest.raises( + UnsupportedWasmConcurrencyError, + match="max_tasks_per_child", + ): + concurrent.futures.ProcessPoolExecutor(max_tasks_per_child=1) + + fork_context_type = getattr( + multiprocessing.context, + "ForkContext", + None, + ) + if fork_context_type is not None: + with pytest.raises(ValueError, match="spawn"): + concurrent.futures.ProcessPoolExecutor( + mp_context=fork_context_type(), + ) + + with concurrent.futures.ProcessPoolExecutor() as executor: + assert list( + executor.map( + lambda value: value, + [1, 2], + buffersize=1, + ) + ) == [1, 2] + for chunksize in (0, 1.5, float("nan")): + with pytest.raises( + (TypeError, ValueError), + match="chunksize", + ): + list( + executor.map( + lambda value: value, + [1], + chunksize=chunksize, + ) + ) + with pytest.raises(ValueError, match="buffersize"): + list( + executor.map( + lambda value: value, + [1], + buffersize=0, + ) + ) + finally: + unpatch() + + +def test_wasm_process_pool_initializer_state_persists_across_tasks() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + local = threading.local() + + def initialize() -> None: + local.ready = True + local.count = 0 + + def work() -> tuple[str, bool, int]: + local.count += 1 + return ( + threading.current_thread().name, + local.ready, + local.count, + ) + + with concurrent.futures.ProcessPoolExecutor( + max_workers=2, + initializer=initialize, + ) as executor: + first = executor.submit(work).result() + second = executor.submit(work).result() + + assert first[0] == second[0] + assert first[1:] == (True, 1) + assert second[1:] == (True, 2) + finally: + unpatch() + + +def test_wasm_process_pool_initializer_failure_breaks_executor() -> None: + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + + def initialize() -> None: + raise RuntimeError("process initializer failed") + + with concurrent.futures.ProcessPoolExecutor( + initializer=initialize, + ) as executor: + future = executor.submit(lambda: "unreachable") + with pytest.raises( + RuntimeError, + match="process initializer failed", + ): + future.result() + with pytest.raises(RuntimeError, match="initializer failed"): + executor.submit(lambda: "later") + finally: + unpatch() + + +@pytest.mark.asyncio +async def test_wasm_process_pool_does_not_inherit_ambient_contextvars() -> ( + None +): + ambient = contextvars.ContextVar("ambient", default="unset") + ambient.set("parent") + + with mock_pyodide(): + unpatch = install_wasm_process_test_shims() + try: + with concurrent.futures.ProcessPoolExecutor() as executor: + future = executor.submit(lambda: ambient.get()) + await wait_until(future.done) + + assert future.result(timeout=0) == "unset" + assert ambient.get() == "parent" + finally: + unpatch() + + +def test_wasm_process_unpatch_rejects_live_process_pool_executor() -> None: + with mock_pyodide(): + core_unpatch = install_wasm_concurrency_shims() + process_unpatch = install_wasm_process_shims() + executor = concurrent.futures.ProcessPoolExecutor() + try: + with pytest.raises(RuntimeError, match="process work"): + process_unpatch() + finally: + executor.shutdown(cancel_futures=True) + process_unpatch() + core_unpatch() diff --git a/tests/_runtime/test_wasm_threading.py b/tests/_runtime/test_wasm_threading.py new file mode 100644 index 00000000000..3411b5ad853 --- /dev/null +++ b/tests/_runtime/test_wasm_threading.py @@ -0,0 +1,380 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import asyncio +import io +import json +import os +import subprocess +import sys +import threading +from contextlib import contextmanager +from pathlib import Path +from types import ModuleType, SimpleNamespace +from typing import TYPE_CHECKING, Any, cast +from unittest.mock import patch + +import pytest + +from marimo._runtime._wasm._concurrency._install import ( + install_wasm_concurrency_shims, +) + +if TYPE_CHECKING: + from collections.abc import Generator + + +@contextmanager +def _mock_pyodide_with_run_sync() -> Generator[None, None, None]: + pyodide_module = ModuleType("pyodide") + ffi_module = ModuleType("pyodide.ffi") + + def run_sync(awaitable: object) -> object: + return asyncio.run(cast(Any, awaitable)) + + cast(Any, ffi_module).run_sync = run_sync + cast(Any, pyodide_module).ffi = ffi_module + with ( + patch.object(sys, "platform", "emscripten"), + patch.dict( + sys.modules, + { + "pyodide": pyodide_module, + "pyodide.ffi": ffi_module, + }, + ), + ): + yield + + +def test_wasm_threading_patch_is_inert_outside_pyodide() -> None: + original_thread = threading.Thread + original_local = threading.local + original_event = threading.Event + + unpatch = install_wasm_concurrency_shims() + unpatch() + + assert threading.Thread is original_thread + assert threading.local is original_local + assert threading.Event is original_event + + +def test_wasm_threading_redundant_handle_does_not_unpatch_owner() -> None: + original_thread = threading.Thread + original_local = threading.local + + with _mock_pyodide_with_run_sync(): + owner_unpatch = install_wasm_concurrency_shims() + redundant_unpatch = install_wasm_concurrency_shims() + try: + assert threading.Thread is not original_thread + assert threading.local is not original_local + + redundant_unpatch() + + assert threading.Thread is not original_thread + assert threading.local is not original_local + finally: + owner_unpatch() + + assert threading.Thread is original_thread + assert threading.local is original_local + + +def _stdlib_thread_ids(patch_state: Any) -> set[int]: + ids = { + patch_state.original_get_ident(), + patch_state.original_get_native_id(), + } + for thread in patch_state.original_enumerate(): + for attr in ("ident", "native_id"): + ident = getattr(thread, attr, None) + if ident is not None: + ids.add(ident) + return ids + + +def _mock_stdlib_thread_ids( + monkeypatch: pytest.MonkeyPatch, +) -> set[int]: + current_thread = threading.current_thread() + thread_ids = {101, 202, 303, 404} + monkeypatch.setattr(threading, "get_ident", lambda: 101) + monkeypatch.setattr(threading, "get_native_id", lambda: 202) + monkeypatch.setattr(threading, "current_thread", lambda: current_thread) + monkeypatch.setattr( + threading, + "enumerate", + lambda: [SimpleNamespace(ident=303, native_id=404)], + ) + return thread_ids + + +def test_wasm_threading_synthetic_ids_skip_real_thread_ids( + monkeypatch: pytest.MonkeyPatch, +) -> None: + stdlib_thread_ids = _mock_stdlib_thread_ids(monkeypatch) + + with _mock_pyodide_with_run_sync(): + from marimo._runtime._wasm._concurrency import _state + + unpatch = install_wasm_concurrency_shims() + try: + real_thread_ids = _stdlib_thread_ids(_state.patch_state()) + assert real_thread_ids == stdlib_thread_ids + expected_ident = max(real_thread_ids) + 1000 + monkeypatch.setattr( + _state, + "_IDENTS", + iter([*real_thread_ids, expected_ident]), + ) + thread = threading.Thread(name="synthetic", target=lambda: None) + thread.start() + thread.join(timeout=1) + + assert thread.ident == expected_ident + finally: + unpatch() + + +def test_wasm_threading_synthetic_ids_ignore_finished_thread_records( + monkeypatch: pytest.MonkeyPatch, +) -> None: + stdlib_thread_ids = _mock_stdlib_thread_ids(monkeypatch) + + with _mock_pyodide_with_run_sync(): + from marimo._runtime._wasm._concurrency import _state + + unpatch = install_wasm_concurrency_shims() + try: + real_thread_ids = _stdlib_thread_ids(_state.patch_state()) + assert real_thread_ids == stdlib_thread_ids + expected_ident = max(real_thread_ids) + 1000 + stale_thread = _state.ThreadIdentity() + stale_thread._ident = expected_ident + stale_thread._native_id = expected_ident + _state.live_threads.add(stale_thread) + monkeypatch.setattr( + _state, + "_IDENTS", + iter([*real_thread_ids, expected_ident]), + ) + + thread = threading.Thread(name="synthetic", target=lambda: None) + thread.start() + thread.join(timeout=1) + + assert thread.ident == expected_ident + assert stale_thread not in _state.live_threads + finally: + unpatch() + + +def test_wasm_threading_local_dict_is_read_only() -> None: + with _mock_pyodide_with_run_sync(): + unpatch = install_wasm_concurrency_shims() + try: + local = threading.local() + local.value = 1 + + assert local.__dict__ == {"value": 1} + with pytest.raises(AttributeError, match="__dict__.*read-only"): + local.__dict__ = {} + with pytest.raises(AttributeError, match="__dict__.*read-only"): + del local.__dict__ + assert local.__dict__ == {"value": 1} + finally: + unpatch() + + +def test_wasm_threading_repairs_preimported_runtime_context_storage() -> None: + from marimo._runtime.context import types as context_types + + context_types.teardown_context() + parent_context = object() + child_context = object() + context_types.initialize_context(parent_context) # type: ignore[arg-type] + + with _mock_pyodide_with_run_sync(): + unpatch = install_wasm_concurrency_shims() + try: + assert context_types.safe_get_context() is parent_context + observed: list[object | None] = [] + + def target() -> None: + observed.append(context_types.safe_get_context()) + context_types.initialize_context(child_context) # type: ignore[arg-type] + observed.append(context_types.safe_get_context()) + context_types.teardown_context() + observed.append(context_types.safe_get_context()) + + thread = threading.Thread(target=target) + thread.start() + thread.join(timeout=1) + + assert not thread.is_alive() + assert observed == [None, child_context, None] + assert context_types.safe_get_context() is parent_context + finally: + unpatch() + context_types.teardown_context() + + +def test_wasm_threading_repairs_preimported_stream_proxy_locals() -> None: + from marimo._messaging.thread_local_streams import ThreadLocalStreamProxy + from marimo._runtime._wasm._concurrency._threading import AsyncLocal + + original_stdout = sys.stdout + original_stderr = sys.stderr + stdout_proxy = ThreadLocalStreamProxy(original_stdout, "") + stderr_proxy = ThreadLocalStreamProxy(original_stderr, "") + stdout_local = stdout_proxy._local + stderr_local = stderr_proxy._local + stdout_stream = io.StringIO() + stderr_stream = io.StringIO() + stdout_proxy._set_stream(stdout_stream) + stderr_proxy._set_stream(stderr_stream) + sys.stdout = stdout_proxy # type: ignore[assignment] + sys.stderr = stderr_proxy # type: ignore[assignment] + + try: + with _mock_pyodide_with_run_sync(): + unpatch = install_wasm_concurrency_shims() + try: + assert isinstance(stdout_proxy._local, AsyncLocal) + assert isinstance(stderr_proxy._local, AsyncLocal) + assert stdout_proxy._get_stream() is stdout_stream + assert stderr_proxy._get_stream() is stderr_stream + + thread_stream = io.StringIO() + + def target() -> None: + stdout_proxy._set_stream(thread_stream) + stdout_proxy.write("worker") + + thread = threading.Thread(target=target) + thread.start() + thread.join(timeout=1) + + assert not thread.is_alive() + assert thread_stream.getvalue() == "worker" + assert stdout_stream.getvalue() == "" + finally: + unpatch() + + assert stdout_proxy._local is stdout_local + assert stderr_proxy._local is stderr_local + assert stdout_proxy._get_stream() is stdout_stream + assert stderr_proxy._get_stream() is stderr_stream + finally: + sys.stdout = original_stdout + sys.stderr = original_stderr + + +@pytest.mark.asyncio +async def test_wasm_runtime_shutdown_helper_cancels_live_thread() -> None: + from marimo._runtime._wasm import ( + ensure_wasm_runtime_bootstrapped, + shutdown_wasm_runtime_work_async, + wait_for_wasm_runtime_work_async, + ) + + with _mock_pyodide_with_run_sync(): + unpatch = ensure_wasm_runtime_bootstrapped() + started = asyncio.Event() + + async def target() -> None: + started.set() + await asyncio.Event().wait() + + thread = threading.Thread(target=target) + try: + thread.start() + await asyncio.wait_for(started.wait(), timeout=1) + + assert not await wait_for_wasm_runtime_work_async(timeout=0) + await shutdown_wasm_runtime_work_async(timeout=1) + assert not thread.is_alive() + assert await wait_for_wasm_runtime_work_async(timeout=0) + finally: + unpatch() + + +def test_top_level_marimo_import_bootstraps_wasm_threading_first() -> None: + code = """ +import json +import sys +import types +import threading +import asyncio + +sys.platform = "emscripten" +try: + import posix +except ImportError: + posix = types.ModuleType("posix") + sys.modules["posix"] = posix +if not hasattr(posix, "_emscripten_log"): + posix._emscripten_log = lambda line: None +pyodide = types.ModuleType("pyodide") +ffi = types.ModuleType("pyodide.ffi") +def run_sync(awaitable): + return asyncio.run(awaitable) +ffi.run_sync = run_sync +pyodide.ffi = ffi +sys.modules["pyodide"] = pyodide +sys.modules["pyodide.ffi"] = ffi + +original_thread = threading.Thread +original_local = threading.local + +import marimo +from marimo._runtime.context import types as context_types +from marimo._runtime._wasm._concurrency._threading import AsyncLocal + +events = [] +async def target(): + events.append("start") + await asyncio.sleep(0) + events.append("done") + +thread = marimo.Thread(target=target) +thread.start() +thread.join(timeout=1) + +print(json.dumps({ + "thread_patched": threading.Thread is not original_thread, + "local_patched": threading.local is not original_local, + "public_thread_uses_patched_base": issubclass( + marimo.Thread, + threading.Thread, + ), + "runtime_context_uses_patched_local": isinstance( + context_types._THREAD_LOCAL_CONTEXT, + AsyncLocal, + ), + "async_thread_events": events, + "async_thread_alive": thread.is_alive(), +})) +""" + repo_root = Path(__file__).parents[2] + env = os.environ.copy() + env["PYTHONPATH"] = str(repo_root) + result = subprocess.run( + [sys.executable, "-c", code], + check=True, + cwd=repo_root, + env=env, + capture_output=True, + text=True, + ) + + assert json.loads(result.stdout) == { + "thread_patched": True, + "local_patched": True, + "public_thread_uses_patched_base": True, + "runtime_context_uses_patched_local": True, + "async_thread_events": ["start", "done"], + "async_thread_alive": False, + } diff --git a/tests/_runtime/utils/test_set_ui_element_request_manager.py b/tests/_runtime/utils/test_set_ui_element_request_manager.py new file mode 100644 index 00000000000..a708d2fcec4 --- /dev/null +++ b/tests/_runtime/utils/test_set_ui_element_request_manager.py @@ -0,0 +1,416 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import asyncio +import queue +import threading +import time + +from marimo._runtime.commands import ( + ModelCommand, + ModelCustomMessage, + ModelUpdateMessage, + UpdateUIElementCommand, +) +from marimo._runtime.utils.set_ui_element_request_manager import ( + BatchableCommand, + SetUIElementRequestManager, + merge_batchable_commands, +) + + +def test_process_request_dedupes_by_token() -> None: + """Test that duplicate tokens are properly deduplicated.""" + q: queue.Queue[BatchableCommand] = queue.Queue() + manager = SetUIElementRequestManager(q) + + # Create two requests with the same token + request1 = UpdateUIElementCommand( + object_ids=["obj1"], values=[1], token="token1" + ) + request2 = UpdateUIElementCommand( + object_ids=["obj2"], values=[2], token="token1" + ) + + # Put the duplicate in the queue + q.put(request2) + + # Process the first request + result = manager.process_request(request1) + + # Should only get one request (the second is a duplicate) + assert len(result) == 1 + cmd = result[0] + assert isinstance(cmd, UpdateUIElementCommand) + assert len(cmd.object_ids) == 1 + assert cmd.object_ids[0] == "obj1" + + +def test_process_request_merges_different_tokens() -> None: + """Test that requests with different tokens are merged.""" + q: queue.Queue[BatchableCommand] = queue.Queue() + manager = SetUIElementRequestManager(q) + + request1 = UpdateUIElementCommand( + object_ids=["obj1"], values=[1], token="token1" + ) + request2 = UpdateUIElementCommand( + object_ids=["obj2"], values=[2], token="token2" + ) + + # Put the second request in the queue + q.put(request2) + + # Process the first request + result = manager.process_request(request1) + + # Should merge both requests + assert len(result) == 1 + cmd = result[0] + assert isinstance(cmd, UpdateUIElementCommand) + assert len(cmd.object_ids) == 2 + assert set(cmd.object_ids) == {"obj1", "obj2"} + + +def test_process_request_keeps_latest_value_per_id() -> None: + """Test that when multiple requests update the same UI element, the latest value wins.""" + q: queue.Queue[BatchableCommand] = queue.Queue() + manager = SetUIElementRequestManager(q) + + request1 = UpdateUIElementCommand( + object_ids=["obj1"], values=[1], token="token1" + ) + request2 = UpdateUIElementCommand( + object_ids=["obj1"], values=[2], token="token2" + ) + + # Put the second request in the queue + q.put(request2) + + # Process the first request + result = manager.process_request(request1) + + # Should keep the latest value (from request2) + assert len(result) == 1 + cmd = result[0] + assert isinstance(cmd, UpdateUIElementCommand) + assert len(cmd.object_ids) == 1 + assert cmd.object_ids[0] == "obj1" + assert cmd.values[0] == 2 + + +def test_process_request_handles_concurrent_queue_updates() -> None: + """Test that the manager properly drains the queue even with concurrent updates. + + This simulates the race condition that occurs with ZeroMQ IPC where a + receiver thread continuously adds messages to the queue. + """ + q: queue.Queue[BatchableCommand] = queue.Queue() + manager = SetUIElementRequestManager(q) + + # Track how many requests were added + requests_added = [] + stop_event = threading.Event() + + def producer(): + """Simulate a ZeroMQ receiver thread adding messages to the queue.""" + counter = 0 + while not stop_event.is_set(): + request = UpdateUIElementCommand( + object_ids=[f"obj{counter}"], + values=[counter], + token=f"token{counter}", + ) + q.put(request) + requests_added.append(request) + counter += 1 + time.sleep(0.001) # Small delay to simulate real conditions + + # Start the producer thread + producer_thread = threading.Thread(target=producer, daemon=True) + producer_thread.start() + + # Let it produce some messages + time.sleep(0.05) + + # Process a request while the producer is still running + initial_request = UpdateUIElementCommand( + object_ids=["obj_initial"], values=[999], token="token_initial" + ) + + result = manager.process_request(initial_request) + + # Stop the producer + stop_event.set() + producer_thread.join(timeout=1) + + # Verify that we got a merged result (all UI commands in a contiguous run) + assert len(result) >= 1 + cmd = result[0] + assert isinstance(cmd, UpdateUIElementCommand) + assert len(cmd.object_ids) > 1 # Should have merged multiple requests + assert "obj_initial" in cmd.object_ids + + # Verify the queue is actually empty after processing + # (small delay to let any in-flight messages arrive) + time.sleep(0.01) + remaining = [] + while not q.empty(): + try: + remaining.append(q.get_nowait()) + except queue.Empty: + break + + # There might be a few messages that arrived after we finished processing, + # but it should be small compared to what we processed + assert len(remaining) < 5 # Allow for a few stragglers + + +async def test_process_request_with_asyncio_queue() -> None: + """Test that the manager works with asyncio.Queue.""" + q: asyncio.Queue[BatchableCommand] = asyncio.Queue() + manager = SetUIElementRequestManager(q) + + request1 = UpdateUIElementCommand( + object_ids=["obj1"], values=[1], token="token1" + ) + request2 = UpdateUIElementCommand( + object_ids=["obj2"], values=[2], token="token2" + ) + + # Put the second request in the queue + q.put_nowait(request2) + + # Process the first request + result = manager.process_request(request1) + + # Should merge both requests + assert len(result) == 1 + cmd = result[0] + assert isinstance(cmd, UpdateUIElementCommand) + assert len(cmd.object_ids) == 2 + assert set(cmd.object_ids) == {"obj1", "obj2"} + + +def test_process_request_returns_empty_for_empty_batch() -> None: + """Test that an empty list is returned when all requests are duplicates.""" + q: queue.Queue[BatchableCommand] = queue.Queue() + manager = SetUIElementRequestManager(q) + + # Create a request and process it + request = UpdateUIElementCommand( + object_ids=["obj1"], values=[1], token="token1" + ) + result1 = manager.process_request(request) + assert len(result1) == 1 + + # Process the same token again (duplicate) + result2 = manager.process_request(request) + # Should return empty list since it's a duplicate + assert result2 == [] + + +# --- Model command tests --- + + +def test_model_update_merging_same_model() -> None: + """Test that model updates for the same model merge with last-write-wins.""" + q: queue.Queue[BatchableCommand] = queue.Queue() + manager = SetUIElementRequestManager(q) + + cmd1 = ModelCommand( + model_id="model-1", + message=ModelUpdateMessage(state={"x": 1, "y": 2}, buffer_paths=[]), + buffers=[], + ) + cmd2 = ModelCommand( + model_id="model-1", + message=ModelUpdateMessage(state={"x": 10, "z": 3}, buffer_paths=[]), + buffers=[], + ) + q.put(cmd2) + + result = manager.process_request(cmd1) + + assert len(result) == 1 + cmd = result[0] + assert isinstance(cmd, ModelCommand) + assert cmd.model_id == "model-1" + assert isinstance(cmd.message, ModelUpdateMessage) + # x=10 from cmd2 (last-write-wins), y=2 from cmd1, z=3 from cmd2 + assert cmd.message.state == {"x": 10, "y": 2, "z": 3} + + +def test_model_update_different_models() -> None: + """Test that updates to different models produce separate commands.""" + q: queue.Queue[BatchableCommand] = queue.Queue() + manager = SetUIElementRequestManager(q) + + cmd1 = ModelCommand( + model_id="model-1", + message=ModelUpdateMessage(state={"x": 1}, buffer_paths=[]), + buffers=[], + ) + cmd2 = ModelCommand( + model_id="model-2", + message=ModelUpdateMessage(state={"y": 2}, buffer_paths=[]), + buffers=[], + ) + q.put(cmd2) + + result = manager.process_request(cmd1) + + assert len(result) == 2 + ids = {r.model_id for r in result if isinstance(r, ModelCommand)} + assert ids == {"model-1", "model-2"} + + +def test_custom_messages_pass_through() -> None: + """Test that custom messages are not merged and each appears individually.""" + q: queue.Queue[BatchableCommand] = queue.Queue() + manager = SetUIElementRequestManager(q) + + cmd1 = ModelCommand( + model_id="model-1", + message=ModelCustomMessage(content={"action": "ping"}), + buffers=[], + ) + cmd2 = ModelCommand( + model_id="model-1", + message=ModelCustomMessage(content={"action": "pong"}), + buffers=[], + ) + q.put(cmd2) + + result = manager.process_request(cmd1) + + assert len(result) == 2 + assert all(isinstance(r, ModelCommand) for r in result) + msgs = [r.message for r in result if isinstance(r, ModelCommand)] + assert isinstance(msgs[0], ModelCustomMessage) + assert isinstance(msgs[1], ModelCustomMessage) + assert msgs[0].content == {"action": "ping"} + assert msgs[1].content == {"action": "pong"} + + +def test_buffer_merging_drops_overridden_buffers() -> None: + """Test that buffers for overridden state keys are dropped.""" + q: queue.Queue[BatchableCommand] = queue.Queue() + manager = SetUIElementRequestManager(q) + + cmd1 = ModelCommand( + model_id="model-1", + message=ModelUpdateMessage( + state={"data": None}, + buffer_paths=[["data"]], + ), + buffers=[b"old-buffer"], + ) + cmd2 = ModelCommand( + model_id="model-1", + message=ModelUpdateMessage( + state={"data": None}, + buffer_paths=[["data"]], + ), + buffers=[b"new-buffer"], + ) + q.put(cmd2) + + result = manager.process_request(cmd1) + + assert len(result) == 1 + cmd = result[0] + assert isinstance(cmd, ModelCommand) + assert isinstance(cmd.message, ModelUpdateMessage) + # Only the new buffer should remain + assert cmd.buffers == [b"new-buffer"] + + +def test_mixed_ui_and_model_commands() -> None: + """Test that UI and model commands coexist in the result.""" + q: queue.Queue[BatchableCommand] = queue.Queue() + manager = SetUIElementRequestManager(q) + + ui_cmd = UpdateUIElementCommand( + object_ids=["obj1"], values=[1], token="token1" + ) + model_cmd = ModelCommand( + model_id="model-1", + message=ModelUpdateMessage(state={"x": 1}, buffer_paths=[]), + buffers=[], + ) + q.put(model_cmd) + + result = manager.process_request(ui_cmd) + + assert len(result) == 2 + assert isinstance(result[0], UpdateUIElementCommand) + assert isinstance(result[1], ModelCommand) + + +def test_contiguous_run_ordering() -> None: + """Test that [UI, Model, UI] produces 3 items in that order, not grouped by type.""" + commands: list[BatchableCommand] = [ + UpdateUIElementCommand( + object_ids=["obj1"], values=[1], token="token1" + ), + ModelCommand( + model_id="model-1", + message=ModelUpdateMessage(state={"x": 1}, buffer_paths=[]), + buffers=[], + ), + UpdateUIElementCommand( + object_ids=["obj2"], values=[2], token="token2" + ), + ] + + result = merge_batchable_commands(commands) + + assert len(result) == 3 + assert isinstance(result[0], UpdateUIElementCommand) + assert isinstance(result[1], ModelCommand) + assert isinstance(result[2], UpdateUIElementCommand) + # Verify values preserved + assert result[0].object_ids == ["obj1"] + assert result[2].object_ids == ["obj2"] + + +def test_contiguous_runs_merged_within_run() -> None: + """Test that contiguous same-type commands are merged within their run.""" + commands: list[BatchableCommand] = [ + UpdateUIElementCommand( + object_ids=["obj1"], values=[1], token="token1" + ), + UpdateUIElementCommand( + object_ids=["obj2"], values=[2], token="token2" + ), + ModelCommand( + model_id="model-1", + message=ModelUpdateMessage(state={"x": 1}, buffer_paths=[]), + buffers=[], + ), + ModelCommand( + model_id="model-1", + message=ModelUpdateMessage(state={"x": 2}, buffer_paths=[]), + buffers=[], + ), + UpdateUIElementCommand( + object_ids=["obj3"], values=[3], token="token3" + ), + ] + + result = merge_batchable_commands(commands) + + # UI(merged) Model(merged) UI + assert len(result) == 3 + assert isinstance(result[0], UpdateUIElementCommand) + assert isinstance(result[1], ModelCommand) + assert isinstance(result[2], UpdateUIElementCommand) + + # First UI run merged obj1 and obj2 + assert set(result[0].object_ids) == {"obj1", "obj2"} + # Model run merged x=1 then x=2 → x=2 + assert isinstance(result[1].message, ModelUpdateMessage) + assert result[1].message.state == {"x": 2} + # Last UI is standalone + assert result[2].object_ids == ["obj3"] diff --git a/tests/_runtime/watch/test_watch.py b/tests/_runtime/watch/test_watch.py new file mode 100644 index 00000000000..b4ecc44235d --- /dev/null +++ b/tests/_runtime/watch/test_watch.py @@ -0,0 +1,159 @@ +# Copyright 2026 Marimo. All rights reserved. +import asyncio +from pathlib import Path + +import pytest + +from marimo._runtime.runtime import Kernel +from tests.conftest import ExecReqProvider + + +@pytest.mark.xfail( + True, reason="Flaky in CI, can't repro locally", strict=False +) +async def test_read_and_write_path( + execution_kernel: Kernel, exec_req: ExecReqProvider, tmp_path: Path +) -> None: + k = execution_kernel + await k.run( + [ + exec_req.get("from pathlib import Path"), + exec_req.get( + f'tmp = Path("{tmp_path.as_posix()}") / "test.txt"; tmp.write_text("0")' + ), + exec_req.get( + """ + import time + import marimo as mo + mo.watch._file._TEST_SLEEP_INTERVAL = 0.05 + """ + ), + exec_req.get("path = mo.watch.file(tmp)"), + exec_req.get("x = path.read_text()"), + exec_req.get( + """ + x + if x == "0": + path.write_text("1") + """ + ), + ] + ) + await asyncio.sleep(0.1) + + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, k.stderr + assert k.globals["x"] == "1" + + +@pytest.mark.xfail( + True, reason="Flaky in CI, can't repro locally", strict=False +) +async def test_read_and_write_iteration( + execution_kernel: Kernel, exec_req: ExecReqProvider, tmp_path: Path +) -> None: + k = execution_kernel + await k.run( + [ + exec_req.get("from pathlib import Path"), + exec_req.get( + f'tmp = Path("{tmp_path.as_posix()}") / "test.txt"; tmp.touch()' + ), + exec_req.get( + """ + import asyncio + import marimo as mo + mo.watch._file._TEST_SLEEP_INTERVAL = 0.01 + """ + ), + exec_req.get("path = mo.watch.file(tmp)"), + exec_req.get("x = path.read_bytes()"), + exec_req.get( + """ + x + if len(x) < 3: + print("Writing 0") + path.write_bytes(b"0" * (len(x) + 1)) + """ + ), + ] + ) + await asyncio.sleep(0.1) + + assert not k.stderr.messages, k.stderr + assert k.globals["x"] == b"000" + + +@pytest.mark.xfail( + True, reason="Flaky in CI, can't repro locally", strict=False +) +async def test_allow_self_loops( + execution_kernel: Kernel, exec_req: ExecReqProvider, tmp_path: Path +) -> None: + k = execution_kernel + await k.run( + [ + exec_req.get("from pathlib import Path"), + exec_req.get( + """ + import asyncio + import marimo as mo + mo.watch._file._TEST_SLEEP_INTERVAL = 0.01 + """ + ), + exec_req.get( + f'tmp = Path("{tmp_path.as_posix()}") / "test.txt"; tmp.touch()' + ), + exec_req.get("path = mo.watch.file(tmp)"), + exec_req.get( + """ + path() # Just returns the path + x = len(path.read_bytes()) + if x < 3: + path.write_bytes(b"0" * (x + 1)) + """ + ), + ] + ) + await asyncio.sleep(0.1) + # Flakey in CI 3.13 + await k.run([]) + + assert not k.stderr.messages, k.stderr + assert k.globals["x"] == 3 + + +@pytest.mark.xfail( + True, reason="Flaky in CI, can't repro locally", strict=False +) +async def test_directory_watch( + execution_kernel: Kernel, exec_req: ExecReqProvider, tmp_path: Path +) -> None: + k = execution_kernel + await k.run( + [ + exec_req.get("from pathlib import Path"), + exec_req.get(f'tmp = Path("{tmp_path.as_posix()}")'), + exec_req.get( + """ + import time + import marimo as mo + mo.watch._directory._TEST_SLEEP_INTERVAL = 0.05 + """ + ), + exec_req.get("path = mo.watch.directory(tmp)"), + exec_req.get("x = len(list(path.glob('*')))"), + exec_req.get( + """ + x + if x == 0: + (tmp / "test.txt").write_text("1") + """ + ), + ] + ) + await asyncio.sleep(0.25) + await k.run([]) + + assert not k.stderr.messages, k.stderr + assert k.globals["x"] == 1 diff --git a/tests/_save/cache-dumps/json-dump-v1/E_77bl8j6jcyv_Lupfe4-wtT2LqVfhlYmtdEIjF3dY3pY.json b/tests/_save/cache-dumps/json-dump-v1/E_77bl8j6jcyv_Lupfe4-wtT2LqVfhlYmtdEIjF3dY3pY.json new file mode 100644 index 00000000000..15852a41edf --- /dev/null +++ b/tests/_save/cache-dumps/json-dump-v1/E_77bl8j6jcyv_Lupfe4-wtT2LqVfhlYmtdEIjF3dY3pY.json @@ -0,0 +1,12 @@ +{ + "defs": { + "value": 3 + }, + "hash": "77bl8j6jcyv_Lupfe4-wtT2LqVfhlYmtdEIjF3dY3pY", + "cache_type": "ExecutionPath", + "stateful_refs": [], + "hit": false, + "meta": { + "version": 2 + } +} diff --git a/tests/_save/cache-dumps/json-dump-v2/E_JM3zJ46ZRk9AC7fTCdAB9LsFUCZMcfLp-cDvUsidAsE.json b/tests/_save/cache-dumps/json-dump-v2/E_JM3zJ46ZRk9AC7fTCdAB9LsFUCZMcfLp-cDvUsidAsE.json new file mode 100644 index 00000000000..471b17f4521 --- /dev/null +++ b/tests/_save/cache-dumps/json-dump-v2/E_JM3zJ46ZRk9AC7fTCdAB9LsFUCZMcfLp-cDvUsidAsE.json @@ -0,0 +1,12 @@ +{ + "defs": { + "value": 2 + }, + "hash": "JM3zJ46ZRk9AC7fTCdAB9LsFUCZMcfLp-cDvUsidAsE", + "cache_type": "ExecutionPath", + "stateful_refs": [], + "hit": false, + "meta": { + "version": 1 + } +} diff --git a/tests/_save/cache-dumps/json-dump-v3/E_Gl8RU7GyKYjt8iMk0iIjsMAptXH0xoYfTgL2iidYW0I.json b/tests/_save/cache-dumps/json-dump-v3/E_Gl8RU7GyKYjt8iMk0iIjsMAptXH0xoYfTgL2iidYW0I.json new file mode 100644 index 00000000000..96778e55781 --- /dev/null +++ b/tests/_save/cache-dumps/json-dump-v3/E_Gl8RU7GyKYjt8iMk0iIjsMAptXH0xoYfTgL2iidYW0I.json @@ -0,0 +1,12 @@ +{ + "defs": { + "value": 3 + }, + "hash": "Gl8RU7GyKYjt8iMk0iIjsMAptXH0xoYfTgL2iidYW0I", + "cache_type": "ExecutionPath", + "stateful_refs": [], + "hit": false, + "meta": { + "version": 3 + } +} diff --git a/tests/_save/cache-dumps/json-dump-v4/E_le-zRML4XfcOLHYcruA5WYVltqeZ2yDH7yOv8sUkIYA.json b/tests/_save/cache-dumps/json-dump-v4/E_le-zRML4XfcOLHYcruA5WYVltqeZ2yDH7yOv8sUkIYA.json new file mode 100644 index 00000000000..438772b9328 --- /dev/null +++ b/tests/_save/cache-dumps/json-dump-v4/E_le-zRML4XfcOLHYcruA5WYVltqeZ2yDH7yOv8sUkIYA.json @@ -0,0 +1,13 @@ +{ + "defs": { + "value": 3 + }, + "hash": "le-zRML4XfcOLHYcruA5WYVltqeZ2yDH7yOv8sUkIYA", + "cache_type": "ExecutionPath", + "stateful_refs": [], + "hit": false, + "meta": { + "runtime": 7.390975952148438e-05, + "version": 4 + } +} \ No newline at end of file diff --git a/tests/_save/cache-dumps/pickle-dump-v1/E_77bl8j6jcyv_Lupfe4-wtT2LqVfhlYmtdEIjF3dY3pY.pickle b/tests/_save/cache-dumps/pickle-dump-v1/E_77bl8j6jcyv_Lupfe4-wtT2LqVfhlYmtdEIjF3dY3pY.pickle new file mode 100644 index 00000000000..348a1fb78d1 Binary files /dev/null and b/tests/_save/cache-dumps/pickle-dump-v1/E_77bl8j6jcyv_Lupfe4-wtT2LqVfhlYmtdEIjF3dY3pY.pickle differ diff --git a/tests/_save/cache-dumps/pickle-dump-v2/E_JM3zJ46ZRk9AC7fTCdAB9LsFUCZMcfLp-cDvUsidAsE.pickle b/tests/_save/cache-dumps/pickle-dump-v2/E_JM3zJ46ZRk9AC7fTCdAB9LsFUCZMcfLp-cDvUsidAsE.pickle new file mode 100644 index 00000000000..dff683157b3 Binary files /dev/null and b/tests/_save/cache-dumps/pickle-dump-v2/E_JM3zJ46ZRk9AC7fTCdAB9LsFUCZMcfLp-cDvUsidAsE.pickle differ diff --git a/tests/_save/cache-dumps/pickle-dump-v3/E_Gl8RU7GyKYjt8iMk0iIjsMAptXH0xoYfTgL2iidYW0I.pickle b/tests/_save/cache-dumps/pickle-dump-v3/E_Gl8RU7GyKYjt8iMk0iIjsMAptXH0xoYfTgL2iidYW0I.pickle new file mode 100644 index 00000000000..0bc552c7b34 Binary files /dev/null and b/tests/_save/cache-dumps/pickle-dump-v3/E_Gl8RU7GyKYjt8iMk0iIjsMAptXH0xoYfTgL2iidYW0I.pickle differ diff --git a/tests/_save/cache-dumps/pickle-dump-v4/E_le-zRML4XfcOLHYcruA5WYVltqeZ2yDH7yOv8sUkIYA.pickle b/tests/_save/cache-dumps/pickle-dump-v4/E_le-zRML4XfcOLHYcruA5WYVltqeZ2yDH7yOv8sUkIYA.pickle new file mode 100644 index 00000000000..26cea3dc9a7 Binary files /dev/null and b/tests/_save/cache-dumps/pickle-dump-v4/E_le-zRML4XfcOLHYcruA5WYVltqeZ2yDH7yOv8sUkIYA.pickle differ diff --git a/tests/_save/external_decorators/app.py b/tests/_save/external_decorators/app.py new file mode 100644 index 00000000000..5c6337e88ab --- /dev/null +++ b/tests/_save/external_decorators/app.py @@ -0,0 +1,36 @@ +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "marimo", +# ] +# /// +import marimo + +__generated_with = "0.14.16" +app = marimo.App(width="medium") + +with app.setup: + import marimo as mo + + +@app.cell +def decorator_wrap(): + @mo.cache + def cache(x): + return x + 1 + + bar = cache(1) + return (bar, cache) + + +@app.cell +def block_wrap(mo): + with mo.cache("random") as cache_block: + x = [] + + a = "need a final line to trigger invalid block capture" + return (x, cache_block) + + +if __name__ == "__main__": + app.run() diff --git a/tests/_save/external_decorators/module_0/__init__.py b/tests/_save/external_decorators/module_0/__init__.py new file mode 100644 index 00000000000..6c8e6b979c5 --- /dev/null +++ b/tests/_save/external_decorators/module_0/__init__.py @@ -0,0 +1 @@ +__version__ = "0.0.0" diff --git a/tests/_save/external_decorators/module_1/__init__.py b/tests/_save/external_decorators/module_1/__init__.py new file mode 100644 index 00000000000..5becc17c04a --- /dev/null +++ b/tests/_save/external_decorators/module_1/__init__.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/tests/_save/external_decorators/transitive_imports.py b/tests/_save/external_decorators/transitive_imports.py new file mode 100644 index 00000000000..399f5bd4294 --- /dev/null +++ b/tests/_save/external_decorators/transitive_imports.py @@ -0,0 +1,38 @@ +import marimo + +__generated_with = "0.14.15" +app = marimo.App(width="medium") + +with app.setup: + import math + + import marimo as mo + import tests._save.external_decorators.module_1 as my_module + + +@app.function +@mo.cache +def has_import(): + return len([mo]) + + +@app.function +@mo.cache +def doesnt_have_import(): + return len([mo, math]) + + +@app.function +@mo.cache(pin_modules=True) +def doesnt_have_namespace_pinned() -> None: + return my_module.__version__ + + +@app.function +@mo.cache +def doesnt_have_namespace() -> None: + return my_module.__version__ + + +if __name__ == "__main__": + app.run() diff --git a/tests/_save/external_decorators/transitive_wrappers_1.py b/tests/_save/external_decorators/transitive_wrappers_1.py new file mode 100644 index 00000000000..8cd7d6435b1 --- /dev/null +++ b/tests/_save/external_decorators/transitive_wrappers_1.py @@ -0,0 +1,67 @@ +import marimo + +__generated_with = "0.14.12" +app = marimo.App() + +with app.setup: + import functools + from typing import Any + + import marimo as mo + + # This will be an impure decorator (contains non-primitive objects) + impure_state = [object()] + + +@app.function +def my_impure_decorator(func): + """An impure decorator that depends on impure_state""" + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any): + # Decorator depends on impure_state + wrapper._call_count = len(impure_state) + return func(*args, **kwargs) + + return wrapper + + +@app.function +def my_pure_decorator(func): + """Same pure decorator""" + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any): + return func(*args, **kwargs) + + return wrapper + + +@app.function +@my_impure_decorator +def pure_function(): + # This function itself is pure (no external dependencies) + return 42 + + +@app.function +@my_pure_decorator +def impure_function(): + # Same function, but now depends on different impure_dependency + return len(impure_state) + + +@app.function +@mo.cache +def impure_wrapped_pure(): + return pure_function() + + +@app.function +@mo.cache +def pure_wrapped_impure(): + return impure_function() + + +if __name__ == "__main__": + app.run() diff --git a/tests/_save/external_decorators/transitive_wrappers_2.py b/tests/_save/external_decorators/transitive_wrappers_2.py new file mode 100644 index 00000000000..b4a7fda121c --- /dev/null +++ b/tests/_save/external_decorators/transitive_wrappers_2.py @@ -0,0 +1,70 @@ +# NB! The only difference between this file and transitive_wrappers_1.py is that +# impure_state is tweaked. +import marimo + +__generated_with = "0.14.12" +app = marimo.App() + +with app.setup: + import functools + from typing import Any + + import marimo as mo + + # This will be an impure decorator (contains non-primitive objects) + # impure_state = [object()] in transitive_wrappers_1.py + impure_state = [object(), object()] + + +@app.function +def my_impure_decorator(func): + """An impure decorator that depends on impure_state""" + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any): + # Decorator depends on impure_state + wrapper._call_count = len(impure_state) + return func(*args, **kwargs) + + return wrapper + + +@app.function +def my_pure_decorator(func): + """Same pure decorator""" + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any): + return func(*args, **kwargs) + + return wrapper + + +@app.function +@my_impure_decorator +def pure_function(): + # This function itself is pure (no external dependencies) + return 42 + + +@app.function +@my_pure_decorator +def impure_function(): + # Same function, but now depends on different impure_dependency + return len(impure_state) + + +@app.function +@mo.cache +def impure_wrapped_pure(): + return pure_function() + + +@app.function +@mo.cache +def pure_wrapped_impure(): + return impure_function() + + +if __name__ == "__main__": + app.run() diff --git a/tests/_save/loaders/__init__.py b/tests/_save/loaders/__init__.py new file mode 100644 index 00000000000..fe2d2e388e5 --- /dev/null +++ b/tests/_save/loaders/__init__.py @@ -0,0 +1 @@ +# Copyright 2026 Marimo. All rights reserved. diff --git a/tests/_save/loaders/mocks.py b/tests/_save/loaders/mocks.py new file mode 100644 index 00000000000..895205a3225 --- /dev/null +++ b/tests/_save/loaders/mocks.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from typing import Any + +from marimo._save.cache import Cache +from marimo._save.loaders import Loader + + +class MockLoader(Loader): + def __init__( + self, + name: str = "mock", + save_path: str = "", + data: dict[str, Any] | None = None, + stateful_refs: set[str] | None = None, + config_value: Any = None, + strict: bool = False, + ) -> None: + self.save_path = save_path + self._data = data or {} + self._cache_hit = data is not None + self._loaded = False + self._saved = False + self._stateful_refs = stateful_refs or set() + self.config_value = config_value + # "Strict" is more than just spoofing the response, but actually going + # through the hydration process. + self.strict = strict + super().__init__(name) + + def cache_hit(self, _) -> bool: + return self._cache_hit + + def load_cache(self, key, glbls=None) -> Cache | None: + del glbls + if not self._cache_hit: + return None + self._loaded = True + data = self._data + if self.strict: + data = {key: None for key in self._data} + cache = Cache( + defs=data, + hash=key.hash, + cache_type=key.cache_type, + stateful_refs=self._stateful_refs, + hit=True, + meta={}, + ) + if self.strict: + cache.update(self._data) + return cache + + def save_cache(self, _cache: Cache) -> bool: + self._saved = True + self._cache_hit = True + return True diff --git a/tests/_save/loaders/test_cache_export.py b/tests/_save/loaders/test_cache_export.py new file mode 100644 index 00000000000..3fc6ddc9ef5 --- /dev/null +++ b/tests/_save/loaders/test_cache_export.py @@ -0,0 +1,113 @@ +# Copyright 2026 Marimo. All rights reserved. +"""Export-cache plumbing: manifest dump, gate, and name derivation. + +The end-to-end path (cell caching populates the store, kernel dumps the +manifest on shutdown, exporter bundles it) is exercised once the cache_cells +lifecycle lands. These unit tests cover the pieces in isolation. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +from marimo._runtime.callbacks.cache import ( + CacheCallbacks, + cache_cells_enabled, +) +from marimo._save.loaders import dump_cache_manifests +from marimo._save.loaders.lazy import LazyLoader, LazyStore, _cache_state +from marimo._save.stores.file import FileStore, export_manifest_name + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + + +def _clear_loaders() -> None: + _cache_state().active_lazy_loaders.clear() + + +def test_export_manifest_name_is_per_notebook() -> None: + assert export_manifest_name("dir/my nb.py") == ".my-nb-export.json" + assert export_manifest_name("a/foo.py") != export_manifest_name("a/bar.py") + # No filename → deterministic fallback, still a hidden json dotfile. + name = export_manifest_name(None) + assert name.startswith(".") + assert name.endswith("-export.json") + + +def test_dump_cache_manifests_writes_keys(tmp_path: Path) -> None: + _clear_loaders() + store = LazyStore(FileStore(save_path=str(tmp_path))) + LazyLoader("blk", store=store) + store.put("blk/hash/a.pickle", b"x") + store.put("blk/hash/return.pickle", b"y") + try: + dump_cache_manifests(export_manifest_name("nb.py")) + finally: + _clear_loaders() + + manifest = tmp_path / export_manifest_name("nb.py") + assert manifest.exists() + assert set(json.loads(manifest.read_text())) == { + "blk/hash/a.pickle", + "blk/hash/return.pickle", + } + + +def test_dump_cache_manifests_skips_non_file_stores(tmp_path: Path) -> None: + """In-memory (WASM) stores have nothing on disk — no manifest written.""" + from marimo._save.stores.dict_store import DictStore + + _clear_loaders() + store = LazyStore(DictStore()) + LazyLoader("blk", store=store) + store.put("k", b"x") + try: + dump_cache_manifests(export_manifest_name("nb.py")) + finally: + _clear_loaders() + + assert not (tmp_path / export_manifest_name("nb.py")).exists() + + +def test_cache_cells_enabled_reads_config() -> None: + assert cache_cells_enabled({"runtime": {"cache_cells": True}}) is True + assert cache_cells_enabled({"runtime": {"cache_cells": False}}) is False + assert cache_cells_enabled({"runtime": {}}) is False + assert cache_cells_enabled({}) is False + + +def test_teardown_dumps_only_when_caching_enabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import marimo._save.loaders as loaders_pkg + + dumped: list[str] = [] + flushed: list[bool] = [] + monkeypatch.setattr( + loaders_pkg, "dump_cache_manifests", lambda name: dumped.append(name) + ) + monkeypatch.setattr( + loaders_pkg, "flush_active_caches", lambda: flushed.append(True) + ) + + on = CacheCallbacks( + MagicMock(), caching_enabled=lambda: True, notebook_filename="nb.py" + ) + on.teardown() + assert flushed == [True] + assert dumped == [export_manifest_name("nb.py")] + + dumped.clear() + flushed.clear() + off = CacheCallbacks( + MagicMock(), caching_enabled=lambda: False, notebook_filename="nb.py" + ) + off.teardown() + # Flush still runs (durability); the manifest dump does not. + assert flushed == [True] + assert dumped == [] diff --git a/tests/_save/loaders/test_class_roundtrip.py b/tests/_save/loaders/test_class_roundtrip.py new file mode 100644 index 00000000000..3e047bc8b64 --- /dev/null +++ b/tests/_save/loaders/test_class_roundtrip.py @@ -0,0 +1,122 @@ +# Copyright 2026 Marimo. All rights reserved. +"""End-to-end: a cell-defined class survives a cache save/restore cycle as +a usable class (not a leftover ClassStub) through both the pickle and lazy +loaders. + +This is the behavior behind `with mo.persistent_cache(...): class Foo: ...` +— on a cache hit the second run must restore `Foo` as a real class so +`Foo()` / `isinstance(..., Foo)` work. +""" + +from __future__ import annotations + +import linecache +from typing import Any + +import pytest + +from marimo._save.cache import Cache +from marimo._save.loaders import LazyLoader, PickleLoader +from marimo._save.stores.file import FileStore + +CELL_FILENAME = "__marimo__cell_RtRt_.py" + +# Source for a fake marimo cell defining three flavors of class. +CELL_SRC = ( + "class WithMethod:\n" + " def __init__(self, v: int = 0) -> None:\n" + " self.v = v\n" + "\n" + " def doubled(self) -> int:\n" + " return self.v * 2\n" + "\n" + "class OnlyStatic:\n" + " @staticmethod\n" + " def f() -> int:\n" + " return 7\n" + "\n" + "class JustAttrs:\n" + " x = 1\n" +) + + +@pytest.fixture +def cell_namespace() -> dict[str, Any]: + linecache.cache[CELL_FILENAME] = ( + len(CELL_SRC), + None, + [line + "\n" for line in CELL_SRC.splitlines()], + CELL_FILENAME, + ) + glbls: dict[str, Any] = {"__name__": "__main__"} + exec(compile(CELL_SRC, CELL_FILENAME, "exec"), glbls) + yield glbls + linecache.cache.pop(CELL_FILENAME, None) + + +def _make_loader(kind: str, save_path: str): + store = FileStore(save_path=save_path) + if kind == "pickle": + return PickleLoader("test", store=store) + return LazyLoader("test", store=store) + + +def _round_trip(loader: Any, cache: Cache) -> Cache: + """Persist `cache` through `loader` and read it back.""" + if isinstance(loader, PickleLoader): + blob = loader.to_blob(cache) + return loader.restore_cache(cache.key, blob) + # LazyLoader writes blobs on a background thread. + assert loader.save_cache(cache) + loader.flush() + loaded = loader.load_cache(cache.key) + assert loaded is not None + return loaded + + +@pytest.mark.parametrize("kind", ["pickle", "lazy"]) +@pytest.mark.parametrize("var", ["WithMethod", "OnlyStatic", "JustAttrs"]) +def test_class_round_trip( + kind: str, + var: str, + cell_namespace: dict[str, Any], + tmp_path: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + cls = cell_namespace[var] + # Simulate the runtime context's cell filename so attribute-only + # classes (no method code object) source from linecache. + monkeypatch.setattr( + Cache, "_cell_filename", staticmethod(lambda: CELL_FILENAME) + ) + + cache = Cache( + defs={var: cls}, + hash="classhash", + cache_type="Pure", + stateful_refs=set(), + hit=False, + meta={}, + ) + cache.update({var: cls}) + # The class was converted to a ClassStub for serialization. + from marimo._save.stubs import ClassStub + + assert isinstance(cache.defs[var], ClassStub) + + loader = _make_loader(kind, str(tmp_path)) + loaded = _round_trip(loader, cache) + + scope: dict[str, Any] = {"__name__": "__main__"} + loaded.restore(scope) + + restored = scope[var] + assert isinstance(restored, type), ( + f"{var} restored as {type(restored)}, expected a class" + ) + if var == "WithMethod": + assert restored(5).doubled() == 10 + elif var == "OnlyStatic": + assert restored.f() == 7 + else: + assert restored.x == 1 diff --git a/tests/_save/loaders/test_lazy_signing.py b/tests/_save/loaders/test_lazy_signing.py new file mode 100644 index 00000000000..9b75ebcd500 --- /dev/null +++ b/tests/_save/loaders/test_lazy_signing.py @@ -0,0 +1,1186 @@ +# Copyright 2026 Marimo. All rights reserved. +"""Integration tests for LazyLoader cache signing.""" + +from __future__ import annotations + +import pickle +import tempfile +from pathlib import Path +from typing import Any + +import msgspec +import pytest + +pytest.importorskip("cryptography", reason="cryptography not installed") + +from marimo._save.cache import MARIMO_CACHE_VERSION, Cache +from marimo._save.hash import HashKey +from marimo._save.loaders import LazyLoader +from marimo._save.signing import ( + CacheSignatureError, + CacheSigner, + _sha256hex, + fingerprint, + generate_keypair, +) +from marimo._save.stores.file import FileStore +from marimo._save.stubs.lazy_stub import ( + Cache as CacheSchema, + CacheType, + Item, + Meta, +) +from tests._save.store.mocks import MockStore + + +# Module-level class so pickle can resolve it. +class _Point: + def __init__(self, x: int, y: int) -> None: + self.x, self.y = x, y + + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- + + +def key(h: str, cache_type: str = "Pure") -> HashKey: + return HashKey(h, cache_type) + + +def _simple_cache(hash_val: str = "abc123", **defs: Any) -> Cache: + if not defs: + defs = {"x": 42, "msg": "hello"} + return Cache( + defs=defs, + hash=hash_val, + cache_type="Pure", + stateful_refs=set(), + hit=False, + meta={"version": MARIMO_CACHE_VERSION}, + ) + + +def _keypair() -> tuple[CacheSigner, CacheSigner]: + """Return (full_signer_with_private, verifier_with_public_only).""" + private_pem, public_pem = generate_keypair() + return ( + CacheSigner.from_private_key_pem(private_pem), + CacheSigner.from_public_key_pem(public_pem), + ) + + +# --------------------------------------------------------------------------- +# Round-trip tests +# --------------------------------------------------------------------------- + + +class _FileStoreLoaderTest: + """Shared scaffolding: a temp-dir FileStore and a loader factory over it.""" + + def setup_method(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.store = FileStore(save_path=self.temp_dir.name) + + def teardown_method(self) -> None: + self.temp_dir.cleanup() + + def _loader(self, **kwargs: Any) -> LazyLoader: + return LazyLoader("test", store=self.store, **kwargs) + + +class TestSignedRoundTrip(_FileStoreLoaderTest): + def test_signer_and_mode_settable_via_setattr(self) -> None: + """Reconfiguring via setattr (as LoaderPartial.create_or_reconfigure + does) works for signer and mode.""" + signer, verifier = _keypair() + # Start in off mode with no signer, then reconfigure to a signing + # loader — mirroring how create_or_reconfigure applies kwargs. + loader = self._loader(signer=None, mode="off") + assert loader.signer is None + assert loader.mode == "off" + + loader.signer = signer + loader.mode = "verify" + assert loader._signer is signer + assert loader._mode == "verify" + + # Full round-trip with the reconfigured signer + cache = _simple_cache(hash_val="reconfig_hash", v=99) + assert loader.save_cache(cache) + loader.flush() + + reader = self._loader(signer=verifier) + loaded = reader.load_cache(key("reconfig_hash")) + assert loaded is not None + assert loaded.defs["v"] == 99 + + def test_reconfigure_unset_signer_does_not_automint_for_shared_store( + self, + ) -> None: + """Re-setting the signer to unset on a non-local (shared) store must + not auto-generate a machine-local key when the deferred resolution + fires — only explicit/env keys apply, matching __init__'s + local-vs-shared distinction. The setter stores the sentinel unresolved; + resolution happens lazily on the first `signer` property access.""" + from unittest import mock + + from marimo._save.loaders.lazy import _SIGNER_UNSET + + loader = LazyLoader( + "ns", store=MockStore(), signer=None, mode="verify" + ) + with mock.patch( + "marimo._save.loaders.lazy._get_default_signer" + ) as gds: + gds.return_value = None + loader.signer = _SIGNER_UNSET + # Setter defers: nothing resolved yet. + gds.assert_not_called() + # Property access (non-off mode) triggers the single resolution. + _ = loader.signer + gds.assert_called_once() + assert gds.call_args.kwargs.get("auto_generate") is False + + def test_default_round_trip(self) -> None: + """The default loader auto-resolves a local signing key, so a plain + save/load round-trips (signs on write, verifies own key on read).""" + loader = self._loader() + cache = _simple_cache() + assert loader.save_cache(cache) + loader.flush() + + loaded = loader.load_cache(key("abc123")) + assert loaded is not None + assert loaded.defs["x"] == 42 + assert loaded.defs["msg"] == "hello" + + def test_signed_round_trip(self) -> None: + """Full signer → save signs, load with public key verifies (a loader + trusts its own key's fingerprint, so no trusted_signers needed).""" + signer, verifier = _keypair() + + writer = self._loader(signer=signer) + cache = _simple_cache(hash_val="signed_hash", x=99, label="signed") + assert writer.save_cache(cache) + writer.flush() + + reader = self._loader(signer=verifier) + loaded = reader.load_cache(key("signed_hash")) + assert loaded is not None + assert loaded.defs["x"] == 99 + assert loaded.defs["label"] == "signed" + + def test_signed_round_trip_with_pickle_object(self) -> None: + """Pickle blobs are hash-verified before deserialization.""" + signer, verifier = _keypair() + + pt = _Point(3, 4) + writer = self._loader(signer=signer) + cache = _simple_cache(hash_val="pt_hash", pt=pt) + assert writer.save_cache(cache) + writer.flush() + + reader = self._loader(signer=verifier) + loaded = reader.load_cache(key("pt_hash")) + assert loaded is not None + result = loaded.defs["pt"] + assert result.x == 3 + assert result.y == 4 + + def test_self_verify_with_private_key(self) -> None: + """A loader with a private-key signer can also verify its own entries.""" + signer, _ = _keypair() + loader = self._loader(signer=signer) + + cache = _simple_cache(hash_val="self_verify", val=7) + assert loader.save_cache(cache) + loader.flush() + + loaded = loader.load_cache(key("self_verify")) + assert loaded is not None + assert loaded.defs["val"] == 7 + + def test_trusted_third_party_direct_signer(self) -> None: + """A reader trusts a third party's direct signature by adding the + writer's fingerprint to trusted_signers (reader has no own key).""" + signer, verifier = _keypair() + writer = self._loader(signer=signer) + writer.save_cache(_simple_cache(hash_val="third_party", n=5)) + writer.flush() + + reader = self._loader( + signer=None, trusted_signers={verifier.fingerprint()} + ) + loaded = reader.load_cache(key("third_party")) + assert loaded is not None + assert loaded.defs["n"] == 5 + + def test_mock_store_signed_round_trip(self) -> None: + """Works with any Store backend (MockStore here).""" + signer, verifier = _keypair() + store = MockStore() + + writer = LazyLoader("ns", store=store, signer=signer) + cache = _simple_cache(hash_val="mock_hash", n=123) + assert writer.save_cache(cache) + writer.flush() + + reader = LazyLoader("ns", store=store, signer=verifier) + loaded = reader.load_cache(key("mock_hash")) + assert loaded is not None + assert loaded.defs["n"] == 123 + + +# --------------------------------------------------------------------------- +# Unsigned-entry behavior (mode-governed) +# --------------------------------------------------------------------------- + + +class TestUnsignedEntries(_FileStoreLoaderTest): + def _write_unsigned(self, hash_val: str = "unsigned_hash") -> None: + """Write a valid but unsigned cache entry via an off-mode loader + (off is the only mode that writes unsigned).""" + loader = self._loader(signer=None, mode="off") + cache = _simple_cache(hash_val=hash_val, z=55) + assert loader.save_cache(cache) + loader.flush() + + def test_unsigned_served_in_off_mode(self) -> None: + """off mode neither signs nor verifies: unsigned entries load fine.""" + self._write_unsigned() + loader = self._loader(signer=None, mode="off") + loaded = loader.load_cache(key("unsigned_hash")) + assert loaded is not None + assert loaded.defs["z"] == 55 + + def test_unsigned_misses_in_verify_mode(self) -> None: + """verify mode (default): an unsigned entry is unverifiable, so it + misses (fail-safe) rather than being served.""" + self._write_unsigned() + _, verifier = _keypair() + loader = self._loader(signer=verifier) # mode="verify" default + assert loader.load_cache(key("unsigned_hash")) is None + + def test_unsigned_rejected_in_strict_mode(self) -> None: + """strict mode: unsigned entries raise CacheSignatureError.""" + self._write_unsigned() + _, verifier = _keypair() + loader = self._loader(signer=verifier, mode="strict") + with pytest.raises(CacheSignatureError, match="unsigned"): + loader.load_cache(key("unsigned_hash")) + + def test_verify_only_signer_write_is_skipped_and_warns(self) -> None: + """A verify-only signer (no private key) in verify mode cannot sign, so + the write is skipped (no unsigned entry written) and a warning logged.""" + from unittest.mock import patch + + _, verifier = _keypair() + loader = self._loader(signer=verifier) # verify mode, no private key + cache = _simple_cache(hash_val="warn_hash", z=1) + with patch("marimo._save.loaders.lazy.LOGGER") as mock_log: + assert loader.save_cache(cache) is False + loader.flush() + warning_msgs = [str(call) for call in mock_log.warning.call_args_list] + assert any("cannot sign" in msg.lower() for msg in warning_msgs) + # Nothing was written. + assert self.store.get(str(loader.build_path(key("warn_hash")))) is None + + +# --------------------------------------------------------------------------- +# Tampering tests +# --------------------------------------------------------------------------- + + +class TestTampering(_FileStoreLoaderTest): + def _write_signed( + self, hash_val: str = "tamper_hash", **defs: Any + ) -> CacheSigner: + if not defs: + defs = {"v": 1} + signer, _ = _keypair() + loader = LazyLoader("test", store=self.store, signer=signer) + cache = _simple_cache(hash_val=hash_val, **defs) + loader.save_cache(cache) + loader.flush() + return signer + + def _read_loader(self, signer: CacheSigner, **kwargs: Any) -> LazyLoader: + # Build a verifier from the same public key + verifier = CacheSigner.from_public_key_pem(signer.public_key_pem()) + return LazyLoader("test", store=self.store, signer=verifier, **kwargs) + + def test_tampered_manifest_raises(self) -> None: + """Flipping a byte in the manifest invalidates the Ed25519 signature. + In strict mode this raises rather than degrading to miss.""" + signer = self._write_signed(hash_val="manip_manifest") + + # Locate and corrupt the manifest file + manifest_key = str( + LazyLoader("test", store=self.store, mode="off").build_path( + key("manip_manifest") + ) + ) + raw = self.store.get(manifest_key) + assert raw is not None + # Decode, mutate, re-encode without updating the signature + schema = msgspec.json.decode(raw, type=CacheSchema) + tampered_meta = msgspec.structs.replace(schema.meta, version=9999) + tampered = msgspec.json.encode( + msgspec.structs.replace(schema, meta=tampered_meta) + ) + self.store.put(manifest_key, tampered) + + reader = self._read_loader(signer, mode="strict") + with pytest.raises(CacheSignatureError): + reader.load_cache(key("manip_manifest")) + + def test_tampered_blob_raises_before_unpickling(self) -> None: + """A replaced blob raises CacheSignatureError before pickle.loads.""" + signer = self._write_signed( + hash_val="blob_tamper", secret={"key": "val"} + ) + + # Find the pickle blob and overwrite it with a different payload + blob_dir = Path(self.temp_dir.name) / "test" / "blob_tamper" + blobs = list(blob_dir.glob("*.pickle")) + assert blobs, "expected a .pickle blob" + evil_bytes = pickle.dumps({"injected": True}) + blobs[0].write_bytes(evil_bytes) + + reader = self._read_loader(signer, mode="strict") + with pytest.raises(CacheSignatureError, match="checksum"): + reader.load_cache(key("blob_tamper")) + + def test_signature_error_not_swallowed_by_load_cache(self) -> None: + """load_cache re-raises CacheSignatureError in strict mode.""" + signer = self._write_signed(hash_val="sig_swallow") + + manifest_key = str( + LazyLoader("test", store=self.store, mode="off").build_path( + key("sig_swallow") + ) + ) + raw = self.store.get(manifest_key) + assert raw is not None + schema = msgspec.json.decode(raw, type=CacheSchema) + tampered_meta = msgspec.structs.replace(schema.meta, version=0) + tampered = msgspec.json.encode( + msgspec.structs.replace(schema, meta=tampered_meta) + ) + self.store.put(manifest_key, tampered) + + reader = self._read_loader(signer, mode="strict") + # Must raise, not return None + with pytest.raises(CacheSignatureError): + reader.load_cache(key("sig_swallow")) + + def test_bad_signature_degrades_to_miss_in_verify_mode(self) -> None: + """In verify mode a tampered manifest returns None (cache miss).""" + signer = self._write_signed(hash_val="verify_tamper") + + manifest_key = str( + LazyLoader("test", store=self.store, mode="off").build_path( + key("verify_tamper") + ) + ) + raw = self.store.get(manifest_key) + assert raw is not None + schema = msgspec.json.decode(raw, type=CacheSchema) + tampered_meta = msgspec.structs.replace(schema.meta, version=9999) + tampered = msgspec.json.encode( + msgspec.structs.replace(schema, meta=tampered_meta) + ) + self.store.put(manifest_key, tampered) + + # verify mode (default) — degrade to cache miss + reader = self._read_loader(signer) + assert reader.load_cache(key("verify_tamper")) is None + + def test_wrong_key_cannot_verify(self) -> None: + """An entry signed by key A cannot be verified by key B.""" + signer_a, _ = _keypair() + _, verifier_b = _keypair() + + writer = LazyLoader("test", store=self.store, signer=signer_a) + cache = _simple_cache(hash_val="wrong_key", val=1) + writer.save_cache(cache) + writer.flush() + + reader = LazyLoader( + "test", store=self.store, signer=verifier_b, mode="strict" + ) + with pytest.raises(CacheSignatureError): + reader.load_cache(key("wrong_key")) + + def test_foreign_key_misses_in_verify_mode(self) -> None: + """A different key's signature does not verify against this loader's + key, so the entry misses (recomputes) rather than being served + unverified — even in verify mode. Sharing a cache across machines + requires a shared/trusted key, not silent acceptance.""" + signer_a, _ = _keypair() + _, verifier_b = _keypair() + + writer = LazyLoader("test", store=self.store, signer=signer_a) + writer.save_cache(_simple_cache(hash_val="foreign_ok", val=7)) + writer.flush() + + # verify mode (default) with an unrelated key B: the foreign signature + # fails verification -> fail-safe cache miss. + reader = LazyLoader("test", store=self.store, signer=verifier_b) + assert reader.load_cache(key("foreign_ok")) is None + + def test_signed_manifest_missing_blob_hash_rejected(self) -> None: + """A validly-signed manifest that omits the integrity hash for a blob + it references is rejected (writer-drift / integrity-bypass guard), + rather than letting the blob reach pickle.loads unverified.""" + from marimo._save.loaders.lazy import _signable_bytes + + signer = self._write_signed(hash_val="missing_hash", secret={"k": "v"}) + manifest_key = str( + LazyLoader("test", store=self.store, mode="off").build_path( + key("missing_hash") + ) + ) + raw = self.store.get(manifest_key) + assert raw is not None + schema = msgspec.json.decode(raw, type=CacheSchema) + # Drop one referenced blob's hash, then re-sign so the manifest + # signature itself remains valid (the attack we guard against is a + # signed-but-hash-incomplete manifest, not a broken signature). + blob_key = next(iter(schema.meta.blob_hashes)) + pruned = { + k: v for k, v in schema.meta.blob_hashes.items() if k != blob_key + } + base_meta = msgspec.structs.replace( + schema.meta, blob_hashes=pruned, signature=None + ) + base = msgspec.structs.replace(schema, meta=base_meta) + sig = signer.sign(_signable_bytes(base)) + resigned = msgspec.json.encode( + msgspec.structs.replace( + base, meta=msgspec.structs.replace(base_meta, signature=sig) + ) + ) + self.store.put(manifest_key, resigned) + + reader = self._read_loader(signer, mode="strict") + with pytest.raises(CacheSignatureError, match="integrity hash"): + reader.load_cache(key("missing_hash")) + + def test_manifest_hash_mismatch_misses_before_blob_io(self) -> None: + """A manifest whose internal hash disagrees with its store path is a + corrupt/misfiled entry: miss before fetching any blob (otherwise the + mismatch is only caught after every blob is pickle.loads()-ed).""" + loader = LazyLoader("test", store=self.store, mode="off") + ref = (Path("test") / "wrong" / "v.pickle").as_posix() + manifest = msgspec.json.encode( + CacheSchema( + hash="different_hash", + cache_type=CacheType.PURE, + defs={"v": Item(reference=ref)}, + stateful_refs=[], + meta=Meta(version=MARIMO_CACHE_VERSION), + ) + ) + self.store.put(str(loader.build_path(key("asked_hash"))), manifest) + + fetched: list[str] = [] + orig = self.store.get + + def spy(k: str) -> Any: + fetched.append(k) + return orig(k) + + self.store.get = spy # type: ignore[method-assign] + try: + assert loader.load_cache(key("asked_hash")) is None + finally: + self.store.get = orig # type: ignore[method-assign] + assert ref not in fetched + + +# --------------------------------------------------------------------------- +# Blob-hash content in manifest +# --------------------------------------------------------------------------- + + +class TestBlobHashesInManifest(_FileStoreLoaderTest): + def test_unsigned_manifest_has_empty_blob_hashes(self) -> None: + loader = LazyLoader("test", store=self.store, signer=None, mode="off") + cache = _simple_cache(hash_val="no_hashes", x=1) + loader.save_cache(cache) + loader.flush() + + manifest_key = str(loader.build_path(key("no_hashes"))) + raw = self.store.get(manifest_key) + schema = msgspec.json.decode(raw, type=CacheSchema) + assert schema.meta.blob_hashes == {} + assert schema.meta.signature is None + assert schema.meta.signer_public_key is None + + def test_signed_manifest_has_blob_hashes_and_signature(self) -> None: + signer, _ = _keypair() + loader = LazyLoader("test", store=self.store, signer=signer) + cache = _simple_cache(hash_val="with_hashes", obj={"a": 1}) + loader.save_cache(cache) + loader.flush() + + manifest_key = str(loader.build_path(key("with_hashes"))) + raw = self.store.get(manifest_key) + schema = msgspec.json.decode(raw, type=CacheSchema) + # Blob hashes should be populated + assert len(schema.meta.blob_hashes) > 0 + # Each value is a 64-char hex string + for h in schema.meta.blob_hashes.values(): + assert len(h) == 64 + # Signature and public key present + assert schema.meta.signature is not None + assert schema.meta.signer_public_key is not None + assert "PUBLIC KEY" in schema.meta.signer_public_key + + def test_blob_hashes_match_actual_blobs(self) -> None: + signer, _ = _keypair() + loader = LazyLoader("test", store=self.store, signer=signer) + cache = _simple_cache(hash_val="verify_hash_content", n=42) + loader.save_cache(cache) + loader.flush() + + manifest_key = str(loader.build_path(key("verify_hash_content"))) + raw = self.store.get(manifest_key) + schema = msgspec.json.decode(raw, type=CacheSchema) + + for blob_key, expected_hex in schema.meta.blob_hashes.items(): + blob_data = self.store.get(blob_key) + assert blob_data is not None + assert _sha256hex(blob_data) == expected_hex + + +# --------------------------------------------------------------------------- +# Signable bytes stability (regression test) +# --------------------------------------------------------------------------- + + +class TestSignableBytesStability: + """_signable_bytes must produce identical output across runs for the same + schema. If msgspec changes field ordering or encoding, signatures written + by one version become unverifiable by another.""" + + def test_signable_bytes_strips_signature(self) -> None: + """The signature envelope field is cleared regardless of its value.""" + from marimo._save.loaders.lazy import _signable_bytes + + meta = Meta( + version=1, + blob_hashes={"k": "a" * 64}, + signature="should_be_stripped", + ) + schema = CacheSchema( + hash="h", + cache_type=CacheType.PURE, + defs={}, + stateful_refs=[], + meta=meta, + ) + from marimo._save.loaders.lazy import _MANIFEST_SIG_CONTEXT + + out = _signable_bytes(schema) + # Signable bytes carry a domain-separation prefix before the JSON. + assert out.startswith(_MANIFEST_SIG_CONTEXT) + decoded = msgspec.json.decode( + out[len(_MANIFEST_SIG_CONTEXT) :], type=CacheSchema + ) + assert decoded.meta.signature is None + # Non-envelope fields preserved + assert decoded.meta.blob_hashes == {"k": "a" * 64} + assert decoded.meta.version == 1 + + def test_signable_bytes_deterministic(self) -> None: + """Same input always produces identical bytes.""" + from marimo._save.loaders.lazy import _signable_bytes + + meta = Meta(version=1, blob_hashes={"x": "b" * 64}) + schema = CacheSchema( + hash="det", + cache_type=CacheType.PURE, + defs={}, + stateful_refs=[], + meta=meta, + ) + assert _signable_bytes(schema) == _signable_bytes(schema) + + +# --------------------------------------------------------------------------- +# Mode + capability validation +# --------------------------------------------------------------------------- + + +class TestModeAndCapabilityValidation(_FileStoreLoaderTest): + def test_strict_with_no_capability_raises(self) -> None: + """strict + signer=None + no trusted_signers → ValueError at init.""" + with pytest.raises(ValueError, match="strict"): + LazyLoader("test", store=self.store, signer=None, mode="strict") + + def test_strict_with_signer_is_fine(self) -> None: + _, verifier = generate_keypair() + signer = CacheSigner.from_public_key_pem(verifier) + loader = LazyLoader( + "test", store=self.store, signer=signer, mode="strict" + ) + assert loader.mode == "strict" + + def test_strict_with_trusted_signers_only_is_fine(self) -> None: + _, root_pub = generate_keypair() + loader = LazyLoader( + "test", + store=self.store, + signer=None, + trusted_signers={fingerprint(root_pub)}, + mode="strict", + ) + assert loader.mode == "strict" + + def test_verify_with_no_capability_degrades_to_off(self) -> None: + """verify + nothing to verify with → degrade to off (no raise); an + unsigned entry is then served.""" + # Write an unsigned entry. + w = LazyLoader("test", store=self.store, signer=None, mode="off") + w.save_cache(_simple_cache(hash_val="degrade", z=3)) + w.flush() + # Reader with verify but no signer and no trusted_signers degrades. + reader = LazyLoader("test", store=self.store, signer=None) + loaded = reader.load_cache(key("degrade")) + assert loaded is not None + assert loaded.defs["z"] == 3 + + def test_invalid_mode_raises(self) -> None: + with pytest.raises(ValueError, match="Invalid cache signing mode"): + LazyLoader("test", store=self.store, mode="paranoid") + + def test_bare_string_trusted_signers_raises(self) -> None: + """A bare str would iterate into single characters — rejected.""" + with pytest.raises(TypeError, match="iterable of fingerprint"): + LazyLoader( + "test", + store=self.store, + trusted_signers="SHA256:abc", # type: ignore[arg-type] + ) + + def test_malformed_fingerprint_raises(self) -> None: + with pytest.raises(ValueError, match="Invalid trusted_signers"): + LazyLoader("test", store=self.store, trusted_signers={"not-a-fp"}) + + def test_bad_signer_type_raises(self) -> None: + with pytest.raises(TypeError, match="signer must be a CacheSigner"): + LazyLoader("test", store=self.store, signer="nope") # type: ignore[arg-type] + + def test_no_cryptography_strict_raises(self) -> None: + """strict must fail closed when cryptography is unavailable.""" + from unittest import mock + + signer, _ = _keypair() + with mock.patch( + "marimo._dependencies.dependencies.DependencyManager." + "cryptography.has", + return_value=False, + ): + with pytest.raises(ValueError, match="cryptography"): + LazyLoader( + "test", store=self.store, signer=signer, mode="strict" + ) + + def test_no_cryptography_verify_degrades_to_off(self) -> None: + """verify degrades to off (serves unsigned) when cryptography is + unavailable — unsigned caching still works without the package.""" + from unittest import mock + + # Write an unsigned entry (off mode). + w = LazyLoader("test", store=self.store, signer=None, mode="off") + w.save_cache(_simple_cache(hash_val="nocrypto", z=8)) + w.flush() + + signer, _ = _keypair() + with mock.patch( + "marimo._dependencies.dependencies.DependencyManager." + "cryptography.has", + return_value=False, + ): + reader = LazyLoader("test", store=self.store, signer=signer) + assert reader._effective_mode() == "off" + loaded = reader.load_cache(key("nocrypto")) + assert loaded is not None + assert loaded.defs["z"] == 8 + + +# --------------------------------------------------------------------------- +# Review fixes: trust/sign composition, remote posture, strict hash guard, +# fingerprint normalization, frozen trust set +# --------------------------------------------------------------------------- + + +class TestReviewFixes(_FileStoreLoaderTest): + def test_trusted_signers_still_signs_own_writes_on_local_store( + self, + ) -> None: + """Configuring trusted_signers must not turn off signing of our own + writes on a local store: trust composes with signing (a loader that + also trusts a teammate keeps its auto-resolved key and round-trips its + own cache).""" + _, other_pub = generate_keypair() + loader = LazyLoader( + "test", + store=self.store, + trusted_signers={fingerprint(other_pub)}, + ) + # Own signing key auto-resolved despite trusted_signers being set. + assert loader.signer is not None + assert loader.save_cache(_simple_cache(hash_val="compose", k=1)) + loader.flush() + loaded = loader.load_cache(key("compose")) + assert loaded is not None + assert loaded.defs["k"] == 1 + + def test_remote_store_verify_does_not_degrade_to_off(self) -> None: + """On a shared/remote store, verify with no signer/trusted must NOT + degrade to off (which would serve unverified bytes from exactly the + backend signing protects); it stays verify, so an unsigned entry + misses.""" + store = MockStore() + w = LazyLoader("ns", store=store, signer=None, mode="off") + w.save_cache(_simple_cache(hash_val="remote_unsigned", z=3)) + w.flush() + + reader = LazyLoader("ns", store=store, signer=None) + assert reader._effective_mode() == "verify" + assert reader.load_cache(key("remote_unsigned")) is None + + def test_local_store_verify_still_degrades_to_off(self) -> None: + """A local file store keeps the benign degrade-to-off when there is + nothing to verify with (low-threat, single-machine).""" + w = LazyLoader("test", store=self.store, signer=None, mode="off") + w.save_cache(_simple_cache(hash_val="local_unsigned", z=4)) + w.flush() + reader = LazyLoader("test", store=self.store, signer=None) + assert reader._effective_mode() == "off" + loaded = reader.load_cache(key("local_unsigned")) + assert loaded is not None + assert loaded.defs["z"] == 4 + + def test_strict_hash_mismatch_raises(self) -> None: + """Under strict, a manifest whose internal hash disagrees with its + store path is a trust anomaly and must raise (fail-closed), not miss.""" + signer, _ = _keypair() + loader = LazyLoader( + "test", store=self.store, signer=signer, mode="strict" + ) + manifest = msgspec.json.encode( + CacheSchema( + hash="different_hash", + cache_type=CacheType.PURE, + defs={}, + stateful_refs=[], + meta=Meta(version=MARIMO_CACHE_VERSION), + ) + ) + self.store.put(str(loader.build_path(key("asked_hash"))), manifest) + with pytest.raises(CacheSignatureError, match="does not match"): + loader.load_cache(key("asked_hash")) + + def test_fingerprint_normalization_accepts_padded_and_urlsafe( + self, + ) -> None: + """A padded or urlsafe fingerprint paste is canonicalized so it still + matches fingerprint() output (rather than validating but never + matching → permanent silent miss).""" + signer, _ = _keypair() + writer = LazyLoader("test", store=self.store, signer=signer) + writer.save_cache(_simple_cache(hash_val="norm", n=5)) + writer.flush() + + canonical = signer.fingerprint() + body = canonical[len("SHA256:") :] + padded = "SHA256:" + body + "=" * (-len(body) % 4) + urlsafe = "SHA256:" + body.replace("+", "-").replace("/", "_") + + for variant in (padded, urlsafe): + reader = LazyLoader( + "test", + store=self.store, + signer=None, + trusted_signers={variant}, + ) + assert reader.trusted_signers == {canonical} + loaded = reader.load_cache(key("norm")) + assert loaded is not None + assert loaded.defs["n"] == 5 + + def test_trusted_signers_property_is_frozen_copy(self) -> None: + """The property returns a frozenset copy so mutating it cannot bypass + _normalize_fingerprints.""" + _, pub = generate_keypair() + loader = LazyLoader( + "test", + store=self.store, + signer=None, + trusted_signers={fingerprint(pub)}, + mode="off", + ) + ts = loader.trusted_signers + assert isinstance(ts, frozenset) + assert not hasattr(ts, "add") + + def test_no_crypto_remote_store_stays_verify(self) -> None: + """Without cryptography, a shared/remote store must NOT degrade to off + (which would deserialize unverified bytes from the very backend signing + protects); it stays verify so an unsigned entry misses.""" + from unittest import mock + + store = MockStore() + w = LazyLoader("ns", store=store, signer=None, mode="off") + w.save_cache(_simple_cache(hash_val="nocrypto_remote", z=9)) + w.flush() + + with mock.patch( + "marimo._dependencies.dependencies.DependencyManager." + "cryptography.has", + return_value=False, + ): + reader = LazyLoader("ns", store=store, signer=None) + assert reader._effective_mode() == "verify" + assert reader.load_cache(key("nocrypto_remote")) is None + + def test_non_finite_floats_round_trip_and_keep_signature(self) -> None: + """nan/inf/-inf can't be inlined via `primitive` (msgspec encodes them + as JSON null, which corrupts the value and breaks the signature once + re-encoded). They round-trip through a compact inline `special_float` + token — no pickle blob — and survive a strict round-trip.""" + import math + + signer, verifier = _keypair() + writer = LazyLoader( + "test", store=self.store, signer=signer, mode="verify" + ) + writer.save_cache( + _simple_cache( + hash_val="nonfinite", + a=float("nan"), + b=float("inf"), + c=float("-inf"), + d=1.5, + ) + ) + writer.flush() + + # No pickle blob is written for the non-finite values — they live + # inline in the manifest as tokens. + manifest = msgspec.json.decode( + self.store.get(str(writer.build_path(key("nonfinite")))), + type=CacheSchema, + ) + assert manifest.defs["a"].special_float == "nan" + assert manifest.defs["b"].special_float == "inf" + assert manifest.defs["c"].special_float == "-inf" + assert manifest.defs["d"].primitive == 1.5 + + # strict → load returns a value only if verification passes; a broken + # signature would raise instead. + reader = LazyLoader( + "test", store=self.store, signer=verifier, mode="strict" + ) + loaded = reader.load_cache(key("nonfinite")) + assert loaded is not None + assert math.isnan(loaded.defs["a"]) + assert loaded.defs["b"] == float("inf") + assert loaded.defs["c"] == float("-inf") + assert loaded.defs["d"] == 1.5 + + def test_fingerprint_slack_bits_canonicalized(self) -> None: + """base64 of a 32-byte digest has 2 slack bits in its final char, so + several final chars decode to the same digest. Normalization must + re-encode to the canonical form (not echo the pasted char), else a + slack-bit variant validates but never matches → permanent silent miss.""" + import base64 + import string + + from marimo._save.loaders.lazy import _normalize_fingerprint + + signer, _ = _keypair() + canonical = signer.fingerprint() + body = canonical[len("SHA256:") :] + raw = base64.b64decode(body + "=" * (-len(body) % 4)) + alphabet = ( + string.ascii_uppercase + + string.ascii_lowercase + + string.digits + + "+/" + ) + variant_char = next( + c + for c in alphabet + if c != body[-1] + and base64.b64decode(body[:-1] + c + "=", validate=True) == raw + ) + variant = "SHA256:" + body[:-1] + variant_char + assert variant != canonical + assert _normalize_fingerprint(variant) == canonical + + def test_strict_raises_on_unsupported_declared_key(self) -> None: + """If parsing the manifest's declared key raises (e.g. cryptography's + UnsupportedAlgorithm, which is not a ValueError), a strict loader must + still raise 'unverifiable' rather than letting it escape to a silent + miss.""" + from unittest import mock + + from cryptography.exceptions import UnsupportedAlgorithm + + signer, verifier = _keypair() + writer = LazyLoader( + "test", store=self.store, signer=signer, mode="verify" + ) + writer.save_cache(_simple_cache(hash_val="unsupported", z=1)) + writer.flush() + + # Reader trusts the declared key by fingerprint but has no own key, so + # the declared-key path is the only route to verification. + reader = LazyLoader( + "test", + store=self.store, + signer=None, + trusted_signers={signer.fingerprint()}, + mode="strict", + ) + with mock.patch( + "marimo._save.loaders.lazy.fingerprint", + side_effect=UnsupportedAlgorithm("bad OID"), + ): + with pytest.raises(CacheSignatureError): + reader.load_cache(key("unsupported")) + + def test_off_mode_does_not_resolve_or_mint_signer(self) -> None: + """mode='off' never signs or verifies, so it must not load or mint a + machine-local signing key (avoiding a stray key file / read-only + state-dir warnings for a caller who opted out).""" + from unittest import mock + + from marimo._save.loaders.lazy import _Unset + + with mock.patch( + "marimo._save.loaders.lazy._get_default_signer" + ) as get_signer: + loader = LazyLoader("test", store=self.store, mode="off") + assert loader.signer is None + loader.save_cache(_simple_cache(hash_val="offkey", z=1)) + loader.flush() + get_signer.assert_not_called() + # Still unresolved, so a later reconfigure to verify can resolve. + assert isinstance(loader._signer, _Unset) + + def test_wasm_store_verify_degrades_to_off(self) -> None: + """The WASM HTTP store is same-origin as the notebook code, so a verify + loader with no key/anchor degrades to off (serves) rather than missing + every read — otherwise the bundled-cache restore feature this stack + ships is silently disabled in the browser.""" + from marimo._save.loaders.lazy import WasmLazyStore + from marimo._save.stores.dict_store import DictStore + + store = WasmLazyStore(DictStore()) + w = LazyLoader("ns", store=store, signer=None, mode="off") + assert w.save_cache(_simple_cache(hash_val="wasm_unsigned", z=7)) + w.flush() + + reader = LazyLoader("ns", store=store, signer=None, mode="verify") + assert reader._effective_mode() == "off" + loaded = reader.load_cache(key("wasm_unsigned")) + assert loaded is not None + assert loaded.defs["z"] == 7 + + def test_wasm_store_no_crypto_degrades_to_off(self) -> None: + """Same same-origin rationale under the no-cryptography branch: the + WASM store degrades to off rather than missing every read.""" + from unittest import mock + + from marimo._save.loaders.lazy import WasmLazyStore + from marimo._save.stores.dict_store import DictStore + + store = WasmLazyStore(DictStore()) + w = LazyLoader("ns", store=store, signer=None, mode="off") + w.save_cache(_simple_cache(hash_val="wasm_nocrypto", z=8)) + w.flush() + + with mock.patch( + "marimo._dependencies.dependencies.DependencyManager." + "cryptography.has", + return_value=False, + ): + reader = LazyLoader("ns", store=store, signer=None, mode="verify") + assert reader._effective_mode() == "off" + loaded = reader.load_cache(key("wasm_nocrypto")) + assert loaded is not None + assert loaded.defs["z"] == 8 + + def test_strict_raises_on_undecodable_manifest(self) -> None: + """A manifest that fails to decode (malformed JSON, or a tampered + one-of Item) is a trust anomaly under strict and must raise + (fail-closed), not fall through to a silent generic miss. verify + (fail-safe) treats the same garbage as a plain miss.""" + signer, _ = _keypair() + loader = LazyLoader( + "test", store=self.store, signer=signer, mode="strict" + ) + path = str(loader.build_path(key("garbage"))) + self.store.put(path, b"{ this is not valid json") + with pytest.raises(CacheSignatureError): + loader.load_cache(key("garbage")) + + verify_loader = LazyLoader( + "test", store=self.store, signer=signer, mode="verify" + ) + assert verify_loader.load_cache(key("garbage")) is None + + def test_bytes_forced_inline_falls_through_to_blob_reference(self) -> None: + """to_item must never inline bytes via `primitive` (msgspec base64s it + to a str, silently corrupting the value). Even when inline is forced, + bytes fall through to a blob reference instead.""" + from pathlib import Path + + from marimo._save.loaders.lazy import to_item + + item = to_item( + Path("ns/h"), + b"\x00\x01raw", + var_name="b", + loader="inline", + hash="h", + ) + assert item.primitive is None + assert item.reference == "ns/h/b.pickle" + + def test_strict_missing_blob_under_verified_manifest_raises(self) -> None: + """A verified manifest whose blob is gone is an integrity failure: + strict raises (fail-closed), verify misses (recompute). Otherwise a + strict loader would silently miss an incomplete signed entry.""" + signer, verifier = _keypair() + writer = self._loader(signer=signer) + writer.save_cache( + _simple_cache(hash_val="missing_blob", pt=_Point(1, 2)) + ) + writer.flush() + # Drop the blob but leave the (still validly-signed) manifest. + assert self.store.clear("test/missing_blob/pt.pickle") + + strict = self._loader(signer=verifier, mode="strict") + with pytest.raises(CacheSignatureError): + strict.load_cache(key("missing_blob")) + + verify = self._loader(signer=verifier, mode="verify") + assert verify.load_cache(key("missing_blob")) is None + + def test_strict_deserialize_failure_is_miss_not_tampering(self) -> None: + """A signed, hash-verified blob whose deserializer fails (e.g. a CUDA + tensor on a CPU-only host) is authentic — not tampering. It must + recompute (miss) in every mode, including strict, rather than raise a + spurious CacheSignatureError blaming corruption.""" + from unittest import mock + + signer, verifier = _keypair() + writer = self._loader(signer=signer) + writer.save_cache( + _simple_cache(hash_val="deser_fail", pt=_Point(1, 2)) + ) + writer.flush() + + def _boom(_data: bytes, _type_hint: str | None = None) -> Any: + raise RuntimeError("deserializer cannot run in this environment") + + reader = self._loader(signer=verifier, mode="strict") + with mock.patch.dict( + "marimo._save.loaders.lazy.BLOB_DESERIALIZERS", + {".pickle": _boom}, + ): + # Hash verifies (bytes untouched); only deserialization fails → + # miss, not CacheSignatureError. + assert reader.load_cache(key("deser_fail")) is None + + +# --------------------------------------------------------------------------- +# fingerprint primitive +# --------------------------------------------------------------------------- + + +class TestFingerprint: + def test_fingerprint_format_and_stability(self) -> None: + _, pub = generate_keypair() + fp = fingerprint(pub) + assert fp.startswith("SHA256:") + assert "=" not in fp # unpadded + assert fingerprint(pub) == fp # deterministic + + def test_fingerprint_matches_signer_method(self) -> None: + _, pub = generate_keypair() + signer = CacheSigner.from_public_key_pem(pub) + assert signer.fingerprint() == fingerprint(pub) + + def test_distinct_keys_distinct_fingerprints(self) -> None: + _, pub_a = generate_keypair() + _, pub_b = generate_keypair() + assert fingerprint(pub_a) != fingerprint(pub_b) + + +# --------------------------------------------------------------------------- +# WASM: signature failures evict the fetched bytes +# --------------------------------------------------------------------------- + + +class TestWasmSignatureEviction: + """A signed-blob mismatch in WASM must evict the HTTP-fetched bytes from + the session store (and poison the keys) so tampered data is neither + re-served nor swept into an export bundle via export_keys().""" + + @pytest.fixture(autouse=True) + def _isolate_poisoned_keys(self) -> Any: + from marimo._save.loaders.lazy import _cache_state + + poisoned = _cache_state().poisoned_keys + snapshot = set(poisoned) + yield + poisoned.clear() + poisoned.update(snapshot) + + def test_tampered_blob_evicted_and_poisoned_in_verify(self) -> None: + from unittest import mock + + from marimo._save.loaders.lazy import ( + WasmLazyLoader, + WasmLazyStore, + _cache_state, + ) + from marimo._save.stores.dict_store import DictStore + + signer, verifier = _keypair() + store = WasmLazyStore(inner=DictStore()) + writer = WasmLazyLoader("wasm_sig", store=store, signer=signer) + writer.save_cache( + _simple_cache(hash_val="wasm_tamper", secret={"k": "v"}) + ) + writer.flush() + + # Overwrite the pickle blob in the session store with different bytes. + blob_key = next( + k for k in store.export_keys() if k.endswith(".pickle") + ) + store._inner.put(blob_key, pickle.dumps({"injected": True})) + + reader = WasmLazyLoader("wasm_sig", store=store, signer=verifier) + # No network: the tampered blob is already resident in the inner store, + # and evicted keys must not trigger a real fetch. + with mock.patch.object( + store, "_http_get_batch", return_value=iter([]) + ): + assert reader.load_cache(key("wasm_tamper")) is None + + manifest_path = str(reader.build_path(key("wasm_tamper"))) + assert blob_key in _cache_state().poisoned_keys + assert manifest_path in _cache_state().poisoned_keys + assert not store.hit(blob_key) + # The rejected blob is no longer advertised for export bundling. + assert blob_key not in store.export_keys() diff --git a/tests/_save/loaders/test_lazy_wasm.py b/tests/_save/loaders/test_lazy_wasm.py new file mode 100644 index 00000000000..5f04598829a --- /dev/null +++ b/tests/_save/loaders/test_lazy_wasm.py @@ -0,0 +1,819 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import pickle +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING +from unittest import mock + +import msgspec +import pytest + +if TYPE_CHECKING: + from collections.abc import Iterator + + from marimo._save.hash import HashKey + +from marimo._save.cache import MARIMO_CACHE_VERSION, Cache +from marimo._save.loaders.lazy import ( + LazyLoader, + LazyStore, + WasmLazyLoader, + WasmLazyStore, + _cache_state, +) +from marimo._save.stores.dict_store import DictStore +from marimo._save.stores.file import FileStore +from marimo._save.stores.store import Store +from marimo._save.stubs.lazy_stub import ( + Cache as CacheSchema, + CacheType, + Item, + Meta, +) + + +class TestDictStore: + def test_get_put_hit(self) -> None: + store = DictStore() + assert store.get("k") is None + assert not store.hit("k") + + assert store.put("k", b"v") + assert store.get("k") == b"v" + assert store.hit("k") + + def test_clear(self) -> None: + store = DictStore() + store.put("k", b"v") + assert store.clear("k") + assert not store.hit("k") + assert not store.clear("k") # already gone + + def test_get_batch_default_sequential(self) -> None: + # DictStore inherits the base Store sequential get_batch. + store = DictStore() + store.put("a", b"1") + assert dict(store.get_batch(["a", "missing"])) == { + "a": b"1", + "missing": None, + } + + def test_export_keys_defaults_empty(self) -> None: + # Non-tracking stores inherit the inert base Store default. + store = DictStore() + store.put("a", b"1") + assert store.export_keys() == [] + + +class TestLazyStoreNative: + """Test LazyStore in native (non-Pyodide) mode.""" + + def test_is_store(self) -> None: + store = LazyStore(inner=DictStore()) + assert isinstance(store, Store) + + def test_delegates_to_inner(self) -> None: + with tempfile.TemporaryDirectory() as td: + inner = FileStore(td) + store = LazyStore(inner=inner) + + assert store.put("blob.bin", b"data") + assert store.get("blob.bin") == b"data" + assert store.hit("blob.bin") + + # Verify it was written to the inner FileStore + assert (Path(td) / "blob.bin").read_bytes() == b"data" + + def test_get_returns_none_for_missing(self) -> None: + store = LazyStore(inner=DictStore()) + assert store.get("nonexistent") is None + + def test_get_batch_sequential(self) -> None: + inner = DictStore() + store = LazyStore(inner=inner) + store.put("a.bin", b"aa") + store.put("b.bin", b"bb") + store.put("c.bin", b"cc") + + results = dict(store.get_batch(["a.bin", "b.bin", "c.bin"])) + assert results == { + "a.bin": b"aa", + "b.bin": b"bb", + "c.bin": b"cc", + } + + def test_get_batch_missing_key(self) -> None: + store = LazyStore(inner=DictStore()) + store.put("a.bin", b"aa") + results = dict(store.get_batch(["a.bin", "missing"])) + assert results["a.bin"] == b"aa" + assert results["missing"] is None + + def test_export_keys_tracks_puts(self) -> None: + store = LazyStore(inner=DictStore()) + assert store.export_keys() == [] + + store.put("x.bin", b"x") + store.put("y.bin", b"y") + assert store.export_keys() == ["x.bin", "y.bin"] + + def test_export_keys_clear_removes(self) -> None: + store = LazyStore(inner=DictStore()) + store.put("x.bin", b"x") + store.clear("x.bin") + assert store.export_keys() == [] + + def test_default_inner_is_filestore(self) -> None: + """Without Pyodide, default inner store is FileStore.""" + store = LazyStore() + assert isinstance(store._inner, FileStore) + + +class TestLazyStoreWasm: + """Test the WASM store variant (`WasmLazyStore`). + + The native/WASM decision is made once via the dual-loader registry + (`resolve_loader` in `loaders/__init__.py`), so the WASM behaviour lives + in the `WasmLazyStore` subclass rather than runtime `is_pyodide()` + branching inside `LazyStore`. + """ + + def test_wasm_default_inner_is_dictstore(self) -> None: + store = WasmLazyStore() + assert isinstance(store._inner, DictStore) + + def test_wasm_put_writes_to_dict(self) -> None: + store = WasmLazyStore(inner=DictStore()) + assert store.put("k", b"v") + # Read from inner directly (no HTTP) + assert store._inner.get("k") == b"v" + + def test_wasm_get_inner_first(self) -> None: + store = WasmLazyStore(inner=DictStore()) + store._inner.put("k", b"cached") + # Should return from inner without HTTP + assert store.get("k") == b"cached" + + @mock.patch("urllib.request.urlopen") + def test_wasm_get_falls_back_to_http( + self, mock_urlopen: mock.Mock + ) -> None: + store = WasmLazyStore(inner=DictStore()) + + # Mock HTTP response + mock_resp = mock.MagicMock() + mock_resp.__enter__ = mock.Mock(return_value=mock_resp) + mock_resp.__exit__ = mock.Mock(return_value=False) + mock_resp.status = 200 + mock_resp.read.return_value = b"from_http" + mock_urlopen.return_value = mock_resp + + # Mock notebook_location for _base_url + with mock.patch( + "marimo._save.loaders.lazy.WasmLazyStore._base_url", + return_value="http://example.com/public/cache", + ): + result = store.get("some/blob.bin") + + assert result == b"from_http" + mock_urlopen.assert_called_once() + # Successful fetch is cached in-session and recorded for export, + # so repeat reads stay local and the bundle ships it. + assert store._inner.get("some/blob.bin") == b"from_http" + assert "some/blob.bin" in store.export_keys() + + def test_wasm_http_error_returns_none(self) -> None: + store = WasmLazyStore(inner=DictStore()) + with ( + mock.patch( + "urllib.request.urlopen", + side_effect=Exception("network"), + ), + mock.patch( + "marimo._save.loaders.lazy.WasmLazyStore._base_url", + return_value="http://example.com/public/cache", + ), + ): + assert store.get("missing.bin") is None + + +class TestKeySanitization: + def test_valid_key(self) -> None: + assert ( + WasmLazyStore._sanitize_key("hash1/var.pickle") + == "hash1/var.pickle" + ) + + def test_rejects_parent_traversal(self) -> None: + with pytest.raises(ValueError, match="Invalid cache key"): + WasmLazyStore._sanitize_key("../etc/passwd") + + def test_rejects_absolute_path(self) -> None: + with pytest.raises(ValueError, match="Invalid cache key"): + WasmLazyStore._sanitize_key("/etc/passwd") + + def test_rejects_embedded_dotdot(self) -> None: + with pytest.raises(ValueError, match="Invalid cache key"): + WasmLazyStore._sanitize_key("foo/../../bar") + + +class TestLazyLoaderBatchPath: + """Test that LazyLoader uses get_batch via the store.get_batch path.""" + + def test_restore_uses_batch_path(self) -> None: + inner = DictStore() + store = LazyStore(inner=inner) + # mode="off": this exercises the batch-restore mechanics, not signing + # (covered in test_lazy_signing.py); the seeded manifest is unsigned. + loader = LazyLoader("test_batch", store=store, mode="off") + + # Seed a cache manually + base = Path("test_batch") / "hash1" + var_ref = (base / "var1.pickle").as_posix() + store.put(var_ref, pickle.dumps("value1")) + + manifest = msgspec.json.encode( + CacheSchema( + hash="hash1", + cache_type=CacheType("Pure"), + defs={"var1": Item(reference=var_ref)}, + stateful_refs=[], + meta=Meta(version=MARIMO_CACHE_VERSION), + ) + ) + cache_path = loader.build_path( + type("Key", (), {"hash": "hash1", "cache_type": "Pure"})() + ) + store.put(str(cache_path), manifest) + + # Load — should use get_batch path (no threads) + from marimo._save.hash import HashKey + + key = HashKey(hash="hash1", cache_type="Pure") + loaded = loader.load_cache(key) + assert loaded is not None + assert loaded.defs["var1"] == "value1" + + def test_save_cache_sync_in_wasm(self) -> None: + # `WasmLazyLoader` is the WASM variant: it writes synchronously + # (no threads in Pyodide) via `_dispatch_write`. + inner = DictStore() + store = WasmLazyStore(inner=inner) + # mode="off": exercises the synchronous WASM write path, not signing. + loader = WasmLazyLoader("test_sync", store=store, mode="off") + + cache = Cache( + defs={"x": 42, "y": "hello"}, + hash="sync_hash", + cache_type="Pure", + stateful_refs=set(), + hit=False, + meta={"version": MARIMO_CACHE_VERSION}, + ) + assert loader.save_cache(cache) + + # Verify blobs were written to the DictStore + assert store.export_keys() # something was written + + # Load back in native mode (so we don't need js module). + # The data is in the DictStore inner, and get_batch uses + # the native sequential path. + from marimo._save.hash import HashKey + + key = HashKey(hash="sync_hash", cache_type="Pure") + loaded = loader.load_cache(key) + assert loaded is not None + assert loaded.defs["x"] == 42 + assert loaded.defs["y"] == "hello" + + +class TestRestoreTripwire: + """A cached def whose codec needs an absent package binds as a use-site + tripwire instead of aborting the whole restore. The return value is + excluded so a stubbed return never becomes the cell's output.""" + + @pytest.fixture(autouse=True) + def _isolate_poisoned_keys(self) -> Iterator[None]: + # A restore failure (e.g. the WASM return-value test) poisons keys on + # the shared process-local CacheState; snapshot/restore so it doesn't + # leak into other classes' exact-poison-set assertions. + poisoned = _cache_state().poisoned_keys + snapshot = set(poisoned) + yield + poisoned.clear() + poisoned.update(snapshot) + + @staticmethod + def _patch_failing_codec(monkeypatch: pytest.MonkeyPatch) -> None: + # A `.faildep` codec that always raises ModuleNotFoundError, standing + # in for e.g. a torch tensor restored where torch is not installed. + from marimo._save.loaders import lazy as lazy_mod + + def _boom(_data: bytes, _type_hint: str | None = None) -> object: + raise ModuleNotFoundError("No module named 'torch'") + + monkeypatch.setitem(lazy_mod.BLOB_DESERIALIZERS, ".faildep", _boom) + + @staticmethod + def _patch_failing_pickle(monkeypatch: pytest.MonkeyPatch) -> None: + # Fail `.pickle` deserialization only for a sentinel payload, so a + # single blob (e.g. shared `ui.pickle`) can be made undeserializable + # while other pickled blobs still load normally. + from marimo._save.loaders import lazy as lazy_mod + + real = lazy_mod.BLOB_DESERIALIZERS[".pickle"] + + def _maybe_boom(data: bytes, type_hint: str | None = None) -> object: + if data == b"__FAIL__": + raise ModuleNotFoundError("No module named 'torch'") + return real(data, type_hint) + + monkeypatch.setitem( + lazy_mod.BLOB_DESERIALIZERS, ".pickle", _maybe_boom + ) + + @staticmethod + def _seed( + store: Store, + loader: LazyLoader, + defs: dict, + meta: Meta, + ui_defs: list[str] | None = None, + ) -> None: + from marimo._save.hash import HashKey + + manifest = msgspec.json.encode( + CacheSchema( + hash="h", + cache_type=CacheType("Pure"), + defs=defs, + stateful_refs=[], + meta=meta, + ui_defs=ui_defs or [], + ) + ) + key = HashKey(hash="h", cache_type="Pure") + store.put(str(loader.build_path(key)), manifest) + + def _assert_tripwire(self, loader: LazyLoader) -> None: + from marimo._runtime.exceptions import MarimoUnhashableCacheError + from marimo._save.hash import HashKey + + loaded = loader.load_cache(HashKey(hash="h", cache_type="Pure")) + assert loaded is not None + assert loaded.hit is True + # The healthy def restored normally. + assert loaded.defs["good"] == "ok" + # The missing-dep def is a use-site tripwire naming itself. + stub = loaded.defs["bad"] + assert getattr(type(stub), "__marimo_unhashable__", False) is True + assert stub.var_name == "bad" + assert stub.type_name == "torch.Tensor" + # Inert until touched; a real access raises a clear unhydratable error. + with pytest.raises(MarimoUnhashableCacheError): + stub() + + def test_def_blob_missing_dep_binds_tripwire_native( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + self._patch_failing_codec(monkeypatch) + store = LazyStore(inner=DictStore()) + loader = LazyLoader("trip_native", store=store, mode="off") + + base = Path("trip_native") / "h" + good_ref = (base / "good.pickle").as_posix() + bad_ref = (base / "bad.faildep").as_posix() + store.put(good_ref, pickle.dumps("ok")) + store.put(bad_ref, b"unused") + self._seed( + store, + loader, + { + "good": Item(reference=good_ref), + "bad": Item(reference=bad_ref, type_hint="torch.Tensor"), + }, + Meta(version=MARIMO_CACHE_VERSION), + ) + self._assert_tripwire(loader) + + def test_def_blob_missing_dep_binds_tripwire_wasm( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Exercises the WASM `_read_blobs` variant (get_batch, no threads). + self._patch_failing_codec(monkeypatch) + store = WasmLazyStore(inner=DictStore()) + loader = WasmLazyLoader("trip_wasm", store=store, mode="off") + + base = Path("trip_wasm") / "h" + good_ref = (base / "good.pickle").as_posix() + bad_ref = (base / "bad.faildep").as_posix() + store.put(good_ref, pickle.dumps("ok")) + store.put(bad_ref, b"unused") + self._seed( + store, + loader, + { + "good": Item(reference=good_ref), + "bad": Item(reference=bad_ref, type_hint="torch.Tensor"), + }, + Meta(version=MARIMO_CACHE_VERSION), + ) + self._assert_tripwire(loader) + + def test_return_blob_missing_dep_is_not_stubbed( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # A return value that can't deserialize must NOT become a stubbed + # output; the restore fails cleanly (miss) instead. + self._patch_failing_codec(monkeypatch) + from marimo._save.hash import HashKey + + store = LazyStore(inner=DictStore()) + loader = LazyLoader("trip_return", store=store, mode="off") + + base = Path("trip_return") / "h" + good_ref = (base / "good.pickle").as_posix() + ret_ref = (base / "return.faildep").as_posix() + store.put(good_ref, pickle.dumps("ok")) + store.put(ret_ref, b"unused") + self._seed( + store, + loader, + {"good": Item(reference=good_ref)}, + Meta( + version=MARIMO_CACHE_VERSION, + return_value=Item(reference=ret_ref, type_hint="torch.Tensor"), + ), + ) + # Clean miss — no stub leaks out as the cell output. + assert loader.load_cache(HashKey(hash="h", cache_type="Pure")) is None + + def test_return_blob_missing_dep_is_not_stubbed_wasm( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Same exclusion on the WASM restore path (get_batch): the re-raised + # ModuleNotFoundError bubbles to load_cache, which returns None. + self._patch_failing_codec(monkeypatch) + from marimo._save.hash import HashKey + + store = WasmLazyStore(inner=DictStore()) + loader = WasmLazyLoader("trip_return_wasm", store=store, mode="off") + + base = Path("trip_return_wasm") / "h" + good_ref = (base / "good.pickle").as_posix() + ret_ref = (base / "return.faildep").as_posix() + store.put(good_ref, pickle.dumps("ok")) + store.put(ret_ref, b"unused") + self._seed( + store, + loader, + {"good": Item(reference=good_ref)}, + Meta( + version=MARIMO_CACHE_VERSION, + return_value=Item(reference=ret_ref, type_hint="torch.Tensor"), + ), + ) + assert loader.load_cache(HashKey(hash="h", cache_type="Pure")) is None + + def test_shared_ui_blob_labels_each_def( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # All UI vars share one `ui.pickle` blob. When it can't deserialize, + # each UI def must become its OWN tripwire naming itself — not one + # shared stub reporting an arbitrary sibling's name. + self._patch_failing_pickle(monkeypatch) + from marimo._runtime.exceptions import MarimoUnhashableCacheError + from marimo._save.hash import HashKey + + store = LazyStore(inner=DictStore()) + loader = LazyLoader("trip_ui", store=store, mode="off") + + ui_ref = (Path("trip_ui") / "h" / "ui.pickle").as_posix() + store.put(ui_ref, b"__FAIL__") + self._seed( + store, + loader, + { + "ui_a": Item(reference=ui_ref), + "ui_b": Item(reference=ui_ref), + }, + Meta(version=MARIMO_CACHE_VERSION), + ui_defs=["ui_a", "ui_b"], + ) + + loaded = loader.load_cache(HashKey(hash="h", cache_type="Pure")) + assert loaded is not None + assert loaded.hit is True + stub_a, stub_b = loaded.defs["ui_a"], loaded.defs["ui_b"] + # Distinct stubs, each naming the def it backs. + assert stub_a is not stub_b + assert stub_a.var_name == "ui_a" + assert stub_b.var_name == "ui_b" + for stub in (stub_a, stub_b): + with pytest.raises(MarimoUnhashableCacheError): + stub() + + def test_import_error_is_not_downgraded( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # The catch is narrow: only ModuleNotFoundError becomes a tripwire. + # A plain ImportError is a genuine failure — it must abort the restore + # (clean miss), not silently bind a stub. + from marimo._save.hash import HashKey + from marimo._save.loaders import lazy as lazy_mod + + def _import_error( + _data: bytes, _type_hint: str | None = None + ) -> object: + raise ImportError("cannot import name 'X'") + + monkeypatch.setitem( + lazy_mod.BLOB_DESERIALIZERS, ".faildep", _import_error + ) + + store = LazyStore(inner=DictStore()) + loader = LazyLoader("trip_import", store=store, mode="off") + bad_ref = (Path("trip_import") / "h" / "bad.faildep").as_posix() + store.put(bad_ref, b"unused") + self._seed( + store, + loader, + {"bad": Item(reference=bad_ref)}, + Meta(version=MARIMO_CACHE_VERSION), + ) + assert loader.load_cache(HashKey(hash="h", cache_type="Pure")) is None + + +class TestModuleVersionPin: + """A module def restored where the module is absent must replay its + pinned version onto the `MissingModule` placeholder, so a version-pinned + content hash reproduces instead of collapsing to an empty version (which + would miss against a natively-exported cache).""" + + def test_module_version_round_trips_through_manifest(self) -> None: + from types import ModuleType + + from marimo._save.loaders.lazy import from_item, to_item + from marimo._save.stubs.module_stub import MissingModule, ModuleStub + + fake = ModuleType("torch") + fake.__version__ = "2.9.1" + + # Capture at cache time. + stub = ModuleStub(fake) + assert stub.version == "2.9.1" + + # Persist through the manifest. + item = to_item(Path("base"), stub, var_name="torch", loader="inline") + assert item.module == "torch" + assert item.module_version == "2.9.1" + + # Restore the stub with its version intact. + restored = from_item(item, "torch") + assert isinstance(restored, ModuleStub) + assert restored.version == "2.9.1" + + # With the real module absent, load() yields a MissingModule that + # still reports the pinned version, so `getattr(mod, "__version__")` + # reproduces the pinned hash rather than an empty string. + restored.name = "definitely_not_a_real_module_xyz" + missing = restored.load() + assert isinstance(missing, MissingModule) + assert missing.__version__ == "2.9.1" + + def test_missing_module_absent_version_is_empty(self) -> None: + # No pinned version (e.g. a submodule with no __version__) degrades to + # an empty string, not an error, mirroring the pre-existing behavior. + from marimo._save.stubs.module_stub import MissingModule + + missing = MissingModule("torch.nn") + assert missing.__version__ == "" + + +class TestStaleKeys: + """A manifest marked stale misses without being served or re-fetched, so + the producing cell re-runs live instead of restoring the same value.""" + + def test_mark_stale_forces_miss_without_fetch(self) -> None: + from marimo._save.hash import HashKey + from marimo._save.loaders.lazy import _cache_state + + store = LazyStore(inner=DictStore()) + loader = LazyLoader("stale_test", store=store, mode="off") + + base = Path("stale_test") / "h" + var_ref = (base / "v.pickle").as_posix() + store.put(var_ref, pickle.dumps("value")) + manifest = msgspec.json.encode( + CacheSchema( + hash="h", + cache_type=CacheType("Pure"), + defs={"v": Item(reference=var_ref)}, + stateful_refs=[], + meta=Meta(version=MARIMO_CACHE_VERSION), + ) + ) + key = HashKey(hash="h", cache_type="Pure") + manifest_key = str(loader.build_path(key)) + store.put(manifest_key, manifest) + + # Hits before marking stale. + assert loader.load_cache(key) is not None + + # Marking stale forces a miss even though the manifest is present. + stale = _cache_state().stale_keys + try: + loader.mark_stale(manifest_key) + assert loader.load_cache(key) is None + finally: + stale.discard(manifest_key) + + def test_save_cache_clears_stale_mark(self) -> None: + # stale_keys is session-scoped, so a recovered cell must un-stale its + # own manifest on save or it would re-run live every round forever. + from marimo._save.hash import HashKey + from marimo._save.loaders.lazy import _cache_state + + store = LazyStore(inner=DictStore()) + loader = LazyLoader("stale_save", store=store) + key = HashKey(hash="h", cache_type="Pure") + manifest_key = str(loader.build_path(key)) + + stale = _cache_state().stale_keys + try: + loader.mark_stale(manifest_key) + assert manifest_key in stale + # Saving a fresh manifest for that key clears the stale mark. + loader.save_cache( + Cache( + defs={"v": 1}, + hash="h", + cache_type="Pure", + stateful_refs=set(), + hit=False, + meta={"version": MARIMO_CACHE_VERSION}, + ) + ) + assert manifest_key not in stale + finally: + stale.discard(manifest_key) + loader.flush() + + +class TestFlushAll: + def test_flush_all_flushes_active_loaders(self) -> None: + inner = DictStore() + store1 = LazyStore(inner=inner) + store2 = LazyStore(inner=DictStore()) + loader1 = LazyLoader("flush_a", store=store1) + loader2 = LazyLoader("flush_b", store=store2) + + loaders = _cache_state().active_lazy_loaders + assert loaders.get("flush_a") is loader1 + assert loaders.get("flush_b") is loader2 + + # flush_all should not raise + LazyLoader.flush_all() + + def test_loaders_tracked_by_name(self) -> None: + """Loaders are tracked in the active dict by name.""" + loader = LazyLoader("track_test", store=LazyStore(inner=DictStore())) + assert _cache_state().active_lazy_loaders["track_test"] is loader + + def test_store_reused_across_recreations(self) -> None: + """When a loader is recreated with the same name, it reuses the + previous loader's store (preserving DictStore data).""" + store1 = LazyStore(inner=DictStore()) + loader1 = LazyLoader("reuse_test", store=store1) + store1.put("key1", b"data1") + + # Recreate without explicit store — should reuse store1 + loader2 = LazyLoader("reuse_test") + assert loader2.store is store1 + assert loader2.store.get("key1") == b"data1" + + +class TestCacheStateResolution: + def test_resolves_to_root_context(self) -> None: + """Child contexts (embedded apps) share the root's cache state.""" + from types import SimpleNamespace + + from marimo._save.cache import CacheState + + root_cache = CacheState(store=DictStore()) + root = SimpleNamespace(parent=None, cache=root_cache) + child = SimpleNamespace( + parent=root, cache=CacheState(store=DictStore()) + ) + + with mock.patch( + "marimo._save.loaders.lazy.safe_get_context", return_value=child + ): + assert _cache_state() is root_cache + + +class TestOnRestoreFailure: + """`WasmLazyLoader._on_restore_failure`: on a corrupt restore, evict the + manifest and its referenced blobs from the store and poison their keys so + the HTTP fallback never re-fetches the same broken data.""" + + @pytest.fixture(autouse=True) + def _isolate_poisoned_keys(self) -> Iterator[None]: + # Snapshot/restore so poison doesn't leak across tests. + poisoned = _cache_state().poisoned_keys + snapshot = set(poisoned) + yield + poisoned.clear() + poisoned.update(snapshot) + + @staticmethod + def _manifest(refs: list[str], return_ref: str | None = None) -> bytes: + meta = Meta(version=MARIMO_CACHE_VERSION) + if return_ref is not None: + meta = Meta( + version=MARIMO_CACHE_VERSION, + return_value=Item(reference=return_ref), + ) + return msgspec.json.encode( + CacheSchema( + hash="h", + cache_type=CacheType("Pure"), + defs={f"v{i}": Item(reference=r) for i, r in enumerate(refs)}, + stateful_refs=[], + meta=meta, + ) + ) + + def _loader_and_key( + self, + ) -> tuple[WasmLazyStore, WasmLazyLoader, HashKey]: + from marimo._save.hash import HashKey + + store = WasmLazyStore(inner=DictStore()) + loader = WasmLazyLoader("restore_fail", store=store) + return store, loader, HashKey(hash="h", cache_type="Pure") + + def test_evicts_and_poisons_manifest_and_blobs(self) -> None: + store, loader, key = self._loader_and_key() + manifest_path = str(loader.build_path(key)) + blob_a, blob_b = "h/a.pickle", "h/b.pickle" + for k in (manifest_path, blob_a, blob_b): + store.put(k, b"x") + + loader._on_restore_failure(key, self._manifest([blob_a, blob_b])) + + for k in (manifest_path, blob_a, blob_b): + assert not store.hit(k), f"{k} not evicted" + assert k in _cache_state().poisoned_keys, f"{k} not poisoned" + + def test_poisons_return_value_reference(self) -> None: + store, loader, key = self._loader_and_key() + ret_ref = "h/return.pickle" + store.put(ret_ref, b"x") + + loader._on_restore_failure(key, self._manifest([], return_ref=ret_ref)) + + assert not store.hit(ret_ref) + assert ret_ref in _cache_state().poisoned_keys + + def test_none_manifest_poisons_only_manifest_path(self) -> None: + store, loader, key = self._loader_and_key() + manifest_path = str(loader.build_path(key)) + + loader._on_restore_failure(key, None) + + assert manifest_path in _cache_state().poisoned_keys + # No blob keys to discover, so nothing else is poisoned. + assert _cache_state().poisoned_keys == {manifest_path} + + def test_undecodable_manifest_poisons_only_manifest_path(self) -> None: + store, loader, key = self._loader_and_key() + manifest_path = str(loader.build_path(key)) + + # Garbage bytes must not raise; manifest path is still poisoned. + loader._on_restore_failure(key, b"not valid msgpack/json") + + assert manifest_path in _cache_state().poisoned_keys + assert _cache_state().poisoned_keys == {manifest_path} + + def test_corrupt_restore_via_load_cache_poisons_keys(self) -> None: + # End-to-end: a manifest referencing a missing blob makes restore + # raise; load_cache swallows it and invokes _on_restore_failure. + store, loader, key = self._loader_and_key() + manifest_path = str(loader.build_path(key)) + missing_ref = "h/missing.pickle" + store._inner.put(manifest_path, self._manifest([missing_ref])) + + # The missing blob would otherwise be fetched over HTTP; stub it to + # a miss so the restore fails deterministically without network. + with mock.patch.object( + store, + "_http_get_batch", + return_value=iter([(missing_ref, None)]), + ): + assert loader.load_cache(key) is None + + assert manifest_path in _cache_state().poisoned_keys + assert missing_ref in _cache_state().poisoned_keys diff --git a/tests/_save/loaders/test_loader.py b/tests/_save/loaders/test_loader.py new file mode 100644 index 00000000000..7047d53b4d3 --- /dev/null +++ b/tests/_save/loaders/test_loader.py @@ -0,0 +1,650 @@ +# Copyright 2026 Marimo. All rights reserved. + +import json +import os +import pickle +import tempfile +from abc import ABC, abstractmethod +from pathlib import Path + +import msgspec +import pytest + +from marimo._dependencies.dependencies import DependencyManager +from marimo._save.cache import MARIMO_CACHE_VERSION, Cache +from marimo._save.hash import HashKey +from marimo._save.loaders import ( + JsonLoader, + LazyLoader, + MemoryLoader, + PickleLoader, +) +from marimo._save.loaders.loader import ( + BasePersistenceLoader, + Loader, + LoaderPartial, +) +from marimo._save.stores.file import FileStore +from marimo._save.stubs.lazy_stub import ( + Cache as CacheSchema, + CacheType, + Item, + Meta, + UnhashableStub, +) +from tests._save.loaders.mocks import MockLoader + + +def key(a, b): + return HashKey(a, b) + + +class TestLoaderPartial: + def test_init(self) -> None: + """Test initialization.""" + partial = LoaderPartial(MockLoader, config_value="custom") + assert partial.loader_type == MockLoader + assert partial.kwargs == {"config_value": "custom"} + + def test_call(self) -> None: + """Test calling the partial to create a loader.""" + partial = LoaderPartial(MockLoader, config_value="custom") + loader = partial("test_name") + + assert isinstance(loader, MockLoader) + assert loader.name == "test_name" + assert loader.config_value == "custom" + + def test_call_with_invalid_args(self) -> None: + """Test calling with invalid arguments.""" + partial = LoaderPartial(MockLoader, invalid_arg="value") + + with pytest.raises(TypeError, match="Could not create"): + partial("test_name") + + +class ABCTestLoader(ABC): + suffix = None + value = None + + def setup_method(self) -> None: + """Set up a temporary directory for each test.""" + self.temp_dir = tempfile.TemporaryDirectory() + self.save_path = self.temp_dir.name + self.store = FileStore(save_path=self.save_path) + + def teardown_method(self) -> None: + """Clean up the temporary directory.""" + self.temp_dir.cleanup() + + def instance(self) -> Loader: + if self.value is None: + self.value = self._instance() + return self.value + + def test_init(self) -> None: + """Test initialization.""" + loader = self.instance() + assert loader.name == "test" + if self.suffix: + assert loader.suffix == self.suffix + if isinstance(loader, BasePersistenceLoader): + assert self.store.save_path.name == Path(self.temp_dir.name).name + # Check that the directory was created + assert os.path.exists(self.store.save_path) + + def test_build_path(self) -> None: + """Test building the path for a cache file.""" + loader = self.instance() + path = loader.build_path(key("hash1", "Pure")) + suffix = f".{self.suffix}" if self.suffix else "" + assert str(path).endswith(f"P_hash1{suffix}") + + path = loader.build_path(key("hash2", "Deferred")) + assert str(path).endswith(f"D_hash2{suffix}") + + def test_cache_hit_miss(self) -> None: + """Test cache hit and miss.""" + loader = self.instance() + + # No file exists yet + assert not loader.cache_hit(key("hash1", "Pure")) + + # Create a cache file + cache_path = loader.build_path(key("hash1", "Pure")) + assert cache_path + + # Create a valid JSON cache + self.seed_cache() + + # Now it should hit + assert ( + loader.cache_attempt({"var1"}, key("hash1", "Pure"), set()) + is not None + ) + + # Different hash should miss + assert not loader.cache_hit(key("hash2", "Pure")) + + # Different cache type should miss + assert not loader.cache_hit(key("hash1", "Deferred")) + + # Empty file should miss + empty_key = str(loader.build_path(key("empty", "Pure"))) + self.store.put(empty_key, b"") + assert not loader.cache_hit(key("empty", "Pure")) + + assert loader.hits == 1 + + @abstractmethod + def _instance(self) -> Loader: + pass + + @abstractmethod + def seed_cache(self) -> None: + pass + + +class TestMemoryLoader(ABCTestLoader): + def _instance(self) -> Loader: + return MemoryLoader("test") + + def seed_cache(self) -> None: + cache_path = self.instance().build_path(key("hash1", "Pure")) + self.instance()._cache[cache_path] = Cache( + defs={"var1": "value1"}, + hash="hash1", + cache_type="Pure", + stateful_refs=set(), + hit=True, + meta={}, + ) + + +class TestJsonLoader(ABCTestLoader): + suffix = "json" + + def _instance(self) -> Loader: + return JsonLoader("test", store=self.store) + + def seed_cache(self): + cache_path = self.instance().build_path(key("hash1", "Pure")) + cache_dict = { + "defs": {"var1": "value1"}, + "key": { + "hash": "hash1", + "cache_type": "Pure", + }, + "stateful_refs": [], + "hit": True, + "meta": {}, + } + + self.store.put(str(cache_path), json.dumps(cache_dict).encode("utf-8")) + + +class TestPickleLoader(ABCTestLoader): + suffix = "pickle" + + def _instance(self) -> Loader: + return PickleLoader("test", store=self.store) + + def seed_cache(self) -> None: + cache_path = self.instance().build_path(key("hash1", "Pure")) + cache = Cache( + defs={"var1": "value1"}, + hash="hash1", + cache_type="Pure", + stateful_refs=set(), + hit=True, + meta={}, + ) + + self.store.put(str(cache_path), pickle.dumps(cache)) + + +class TestLazyLoader(ABCTestLoader): + suffix = "jsonl" + + def _instance(self) -> Loader: + # These exercise generic loader mechanics (hit/miss, blob handling) + # with hand-written unsigned manifests; signing behavior is covered in + # test_lazy_signing.py. mode="off" serves unsigned entries as-is. + return LazyLoader("test", store=self.store, mode="off") + + def teardown_method(self) -> None: + if self.value and hasattr(self.value, "flush"): + self.value.flush() + super().teardown_method() + + def seed_cache(self) -> None: + loader = self.instance() + cache_path = loader.build_path(key("hash1", "Pure")) + base = Path("test") / "hash1" + + # Write the pickle blob for var1 + var_ref = (base / "var1.pickle").as_posix() + self.store.put(var_ref, pickle.dumps("value1")) + + # Write the manifest + manifest = msgspec.json.encode( + CacheSchema( + hash="hash1", + cache_type=CacheType("Pure"), + defs={"var1": Item(reference=var_ref)}, + stateful_refs=[], + meta=Meta(version=MARIMO_CACHE_VERSION), + ) + ) + self.store.put(str(cache_path), manifest) + + def test_round_trip(self) -> None: + """Test save_cache -> flush -> load_cache round-trip.""" + loader = self.instance() + + cache = Cache( + defs={"x": 42, "y": "hello", "z": [1, 2, 3]}, + hash="round_trip_hash", + cache_type="Pure", + stateful_refs=set(), + hit=False, + meta={"version": MARIMO_CACHE_VERSION}, + ) + assert loader.save_cache(cache) + loader.flush() + + loaded = loader.load_cache(key("round_trip_hash", "Pure")) + assert loaded is not None + assert loaded.hash == "round_trip_hash" + assert loaded.defs["x"] == 42 + assert loaded.defs["y"] == "hello" + assert loaded.defs["z"] == [1, 2, 3] + + def test_import_reference_stays_inline(self) -> None: + """Re-importable references (`from typing import Optional`, an + imported function/class) are stored inline in the manifest, not as + per-variable blobs, and restore to the original object by identity.""" + from collections import OrderedDict + from typing import Optional + + loader = self.instance() + cache = Cache( + defs={"Optional": Optional, "OrderedDict": OrderedDict}, + hash="import_ref_hash", + cache_type="Pure", + stateful_refs=set(), + hit=False, + meta={"version": MARIMO_CACHE_VERSION}, + ) + cache.update({"Optional": Optional, "OrderedDict": OrderedDict}) + assert loader.save_cache(cache) + loader.flush() + + # No blob files — only the manifest is written. + blobs = [ + p + for p in Path(self.store.save_path).rglob("*") + if p.is_file() and p.suffix != ".jsonl" + ] + assert not blobs, f"expected no blobs, found {blobs}" + + loaded = loader.load_cache(key("import_ref_hash", "Pure")) + assert loaded is not None + assert loaded.defs["Optional"] is Optional + assert loaded.defs["OrderedDict"] is OrderedDict + + def test_unserializable_def_marks_manifest_no_blob(self) -> None: + """A def that can't be serialized (a lambda) writes no blob; the + manifest `Item` carries `unserializable_type`, and load reconstructs + an `UnhashableStub` tripwire in-memory.""" + loader = self.instance() + cache = Cache( + # `ok` is a list (pickle-blob path); `f` is a lambda (unpicklable). + defs={"ok": [1, 2, 3], "f": lambda x: x + 1}, + hash="unserializable_hash", + cache_type="Pure", + stateful_refs=set(), + hit=False, + meta={"version": MARIMO_CACHE_VERSION}, + ) + assert loader.save_cache(cache) + loader.flush() + + # No blob written for the lambda — only the serializable `ok` blob + # and the manifest exist. + blob_names = sorted( + p.name + for p in Path(self.store.save_path).rglob("*") + if p.is_file() and p.suffix != ".jsonl" + ) + assert blob_names == ["ok.pickle"], blob_names + + # Manifest marks the lambda's Item, with no dangling reference. + cache_path = loader.build_path(key("unserializable_hash", "Pure")) + manifest = self.store.get(str(cache_path)) + decoded = msgspec.json.decode(manifest, type=CacheSchema) + f_item = decoded.defs["f"] + assert f_item.reference is None + assert f_item.unserializable_type is not None + assert "function" in f_item.unserializable_type.lower() + + # Load: `ok` restores; `f` becomes an in-memory tripwire. + loaded = loader.load_cache(key("unserializable_hash", "Pure")) + assert loaded is not None + assert loaded.defs["ok"] == [1, 2, 3] + assert isinstance(loaded.defs["f"], UnhashableStub) + assert loaded.defs["f"].var_name == "f" + + def test_absent_module_blob_not_fetched(self) -> None: + """A def blob whose type's root module can't be imported here is + never fetched: restore stands in an `UnhashableStub` carrying the + persisted content digest so a downstream consumer still reproduces + its key without the (unmaterializable) value.""" + loader = self.instance() + base = Path("test") / "absent_hash" + # Reference a blob intentionally NOT written to the store — if the + # gate fetched it, restore would miss on the absent blob. + ref = (base / "t.pt").as_posix() + manifest = msgspec.json.encode( + CacheSchema( + hash="absent_hash", + cache_type=CacheType("Pure"), + defs={ + "t": Item( + reference=ref, + type_hint="faketorch.Tensor", + hash="deadbeef", + ) + }, + stateful_refs=[], + meta=Meta(version=MARIMO_CACHE_VERSION), + ) + ) + cache_path = loader.build_path(key("absent_hash", "Pure")) + self.store.put(str(cache_path), manifest) + + requested: list[str] = [] + original_get = self.store.get + + def spy(k: str): + requested.append(k) + return original_get(k) + + self.store.get = spy # type: ignore[method-assign] + + loaded = loader.load_cache(key("absent_hash", "Pure")) + assert loaded is not None + stub = loaded.defs["t"] + assert isinstance(stub, UnhashableStub) + assert stub.var_name == "t" + assert stub.type_name == "faketorch.Tensor" + assert stub.content_hash == "deadbeef" + # The blob was skipped — only the manifest was fetched. + assert ref not in requested + + def test_unserializable_ui_clears_ui_defs_and_stale_blob(self) -> None: + """A UI def that can't be pickled must not leave `ui_defs` pointing + at `ui.pickle`: restore loads UI defs via `ui_defs`, bypassing the + per-Item marks, so a stale `ui.pickle` from a prior run would load as + a phantom hit. On failure `ui_defs` is cleared, each UI Item is + marked, and the stale blob is removed.""" + from marimo._save.stubs.ui_element_stub import UIElementStub + + loader = self.instance() + # A UIElementStub instance routes to the "ui" loader by type; give it + # an unpicklable attribute so the shared ui.pickle write fails. + ui_stub = UIElementStub.__new__(UIElementStub) + ui_stub.data = {"bad": lambda: 1} # type: ignore[attr-defined] + + # Pre-seed a stale ui.pickle at the hash path from a "prior run". + base = Path("test") / "ui_fail_hash" + ui_key = (base / "ui.pickle").as_posix() + self.store.put(ui_key, pickle.dumps({"u": "stale"})) + + cache = Cache( + defs={"ok": [1, 2, 3], "u": ui_stub}, + hash="ui_fail_hash", + cache_type="Pure", + stateful_refs=set(), + hit=False, + meta={"version": MARIMO_CACHE_VERSION}, + ) + assert loader.save_cache(cache) + loader.flush() + + # Stale ui.pickle removed; the serializable `ok` blob remains. + assert self.store.get(ui_key) is None + cache_path = loader.build_path(key("ui_fail_hash", "Pure")) + decoded = msgspec.json.decode( + self.store.get(str(cache_path)), type=CacheSchema + ) + assert decoded.ui_defs == [] + assert decoded.defs["u"].reference is None + assert decoded.defs["u"].unserializable_type is not None + + # Load: `u` is an in-memory tripwire, not the stale value. + loaded = loader.load_cache(key("ui_fail_hash", "Pure")) + assert loaded is not None + assert loaded.defs["ok"] == [1, 2, 3] + assert isinstance(loaded.defs["u"], UnhashableStub) + + def test_corrupt_cache_returns_none(self) -> None: + """Corrupt manifest triggers cache miss, not crash.""" + loader = self.instance() + cache_path = loader.build_path(key("bad", "Pure")) + self.store.put(str(cache_path), b"not valid json") + + result = loader.load_cache(key("bad", "Pure")) + assert result is None + + def test_missing_blob_returns_none(self) -> None: + """Missing pickle blob triggers cache miss.""" + loader = self.instance() + cache_path = loader.build_path(key("missing", "Pure")) + base = Path("test") / "missing" + + # Manifest references a blob that doesn't exist + manifest = msgspec.json.encode( + CacheSchema( + hash="missing", + cache_type=CacheType("Pure"), + defs={ + "var1": Item(reference=(base / "var1.pickle").as_posix()) + }, + stateful_refs=[], + meta=Meta(version=MARIMO_CACHE_VERSION), + ) + ) + self.store.put(str(cache_path), manifest) + + result = loader.load_cache(key("missing", "Pure")) + assert result is None + + @pytest.mark.skipif( + not DependencyManager.numpy.has(), reason="numpy required" + ) + def test_numpy_object_dtype_round_trip(self) -> None: + """Object-dtype numpy arrays survive save → flush → load via .npy.""" + import numpy as np + + loader = self.instance() + arr = np.array(["a", "b"], dtype=object) + cache = Cache( + defs={"arr": arr}, + hash="np_obj_hash", + cache_type="Pure", + stateful_refs=set(), + hit=False, + meta={"version": MARIMO_CACHE_VERSION}, + ) + assert loader.save_cache(cache) + loader.flush() + + loaded = loader.load_cache(key("np_obj_hash", "Pure")) + assert loaded is not None + np.testing.assert_array_equal(loaded.defs["arr"], arr) + + @pytest.mark.skipif( + not DependencyManager.numpy.has(), reason="numpy required" + ) + def test_numpy_round_trip(self) -> None: + """numpy arrays survive save → flush → load via .npy format.""" + import numpy as np + + loader = self.instance() + arr = np.array([1.0, 2.0, 3.0]) + cache = Cache( + defs={"arr": arr}, + hash="np_hash", + cache_type="Pure", + stateful_refs=set(), + hit=False, + meta={"version": MARIMO_CACHE_VERSION}, + ) + assert loader.save_cache(cache) + loader.flush() + + assert list(Path(self.store.save_path).rglob("*.npy")), ( + "expected .npy blob, got pickle fallback" + ) + loaded = loader.load_cache(key("np_hash", "Pure")) + assert loaded is not None + np.testing.assert_array_equal(loaded.defs["arr"], arr) + + @pytest.mark.skipif( + not DependencyManager.has("torch"), reason="torch required" + ) + def test_torch_round_trip(self) -> None: + """torch tensors survive save → flush → load via the .pt format — + the manifest reference must match the blob extension.""" + import torch + + loader = self.instance() + tensor = torch.tensor([1.0, 2.0, 3.0]) + cache = Cache( + defs={"t": tensor}, + hash="pt_hash", + cache_type="Pure", + stateful_refs=set(), + hit=False, + meta={"version": MARIMO_CACHE_VERSION}, + ) + assert loader.save_cache(cache) + loader.flush() + + assert list(Path(self.store.save_path).rglob("*.pt")), ( + "expected .pt blob, got pickle fallback" + ) + loaded = loader.load_cache(key("pt_hash", "Pure")) + assert loaded is not None + assert torch.equal(loaded.defs["t"], tensor) + + @pytest.mark.skipif( + not DependencyManager.has("polars"), reason="polars required" + ) + def test_polars_round_trip(self) -> None: + """polars DataFrames survive save → flush → load via .arrow format.""" + import polars as pl + + loader = self.instance() + df = pl.DataFrame({"a": [1, 2], "b": [3.0, 4.0]}) + cache = Cache( + defs={"df": df}, + hash="pl_hash", + cache_type="Pure", + stateful_refs=set(), + hit=False, + meta={"version": MARIMO_CACHE_VERSION}, + ) + assert loader.save_cache(cache) + loader.flush() + + assert list(Path(self.store.save_path).rglob("*.arrow")), ( + "expected .arrow blob, got pickle fallback" + ) + loaded = loader.load_cache(key("pl_hash", "Pure")) + assert loaded is not None + assert loaded.defs["df"].equals(df) + + @pytest.mark.skipif( + not DependencyManager.has("polars"), reason="polars required" + ) + def test_polars_series_round_trip(self) -> None: + """polars Series survive save → flush → load via .arrow format.""" + import polars as pl + + loader = self.instance() + s = pl.Series("vals", [10, 20, 30]) + cache = Cache( + defs={"s": s}, + hash="pl_series_hash", + cache_type="Pure", + stateful_refs=set(), + hit=False, + meta={"version": MARIMO_CACHE_VERSION}, + ) + assert loader.save_cache(cache) + loader.flush() + + assert list(Path(self.store.save_path).rglob("*.arrow")), ( + "expected .arrow blob, got pickle fallback" + ) + loaded = loader.load_cache(key("pl_series_hash", "Pure")) + assert loaded is not None + assert isinstance(loaded.defs["s"], pl.Series) + assert loaded.defs["s"].to_list() == s.to_list() + + @pytest.mark.skipif( + not DependencyManager.has("pandas"), reason="pandas required" + ) + def test_pandas_round_trip(self) -> None: + """pandas DataFrames survive save → flush → load via .arrow format.""" + import pandas as pd + + loader = self.instance() + df = pd.DataFrame({"x": [1, 2], "y": [3.0, 4.0]}) + cache = Cache( + defs={"df": df}, + hash="pd_hash", + cache_type="Pure", + stateful_refs=set(), + hit=False, + meta={"version": MARIMO_CACHE_VERSION}, + ) + assert loader.save_cache(cache) + loader.flush() + + assert list(Path(self.store.save_path).rglob("*.arrow")), ( + "expected .arrow blob, got pickle fallback" + ) + loaded = loader.load_cache(key("pd_hash", "Pure")) + assert loaded is not None + pd.testing.assert_frame_equal(loaded.defs["df"], df) + + @pytest.mark.skipif( + not DependencyManager.has("pandas"), reason="pandas required" + ) + def test_pandas_series_round_trip(self) -> None: + """pandas Series survive save → flush → load via .arrow format.""" + import pandas as pd + + loader = self.instance() + s = pd.Series([10, 20, 30], name="vals") + cache = Cache( + defs={"s": s}, + hash="pd_series_hash", + cache_type="Pure", + stateful_refs=set(), + hit=False, + meta={"version": MARIMO_CACHE_VERSION}, + ) + assert loader.save_cache(cache) + loader.flush() + + assert list(Path(self.store.save_path).rglob("*.arrow")), ( + "expected .arrow blob, got pickle fallback" + ) + loaded = loader.load_cache(key("pd_series_hash", "Pure")) + assert loaded is not None + assert isinstance(loaded.defs["s"], pd.Series) + pd.testing.assert_series_equal(loaded.defs["s"], s) diff --git a/tests/_save/mocks.py b/tests/_save/mocks.py deleted file mode 100644 index 0b99f3374b3..00000000000 --- a/tests/_save/mocks.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict, Optional - -from marimo._save.cache import Cache, CacheType -from marimo._save.loaders import Loader - - -class MockLoader(Loader): - def __init__( - self, - name: str = "mock", - save_path: str = "", - data: Optional[Dict[str, Any]] = None, - stateful_refs: Optional[set[str]] = None, - ) -> None: - self.name = name - self.save_path = save_path - self._data = data or {} - self._cache_hit = data is not None - self._loaded = False - self._saved = False - self._stateful_refs = stateful_refs or set() - - def cache_hit(self, _hashed_context: str, _cache_type: CacheType) -> bool: - return self._cache_hit - - def load_cache(self, hashed_context: str, cache_type: CacheType) -> Cache: - self._loaded = True - return Cache( - self._data, - hashed_context, - self._stateful_refs, - cache_type, - True, - {}, - ) - - def save_cache(self, _cache: Cache) -> None: - self._saved = True diff --git a/tests/_save/store/mocks.py b/tests/_save/store/mocks.py new file mode 100644 index 00000000000..42324dfd605 --- /dev/null +++ b/tests/_save/store/mocks.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import Any + +from marimo._save.stores import Store + + +class MockStore(Store): + def __init__(self) -> None: + super().__init__() + self._cache: dict[str, Any] = {} + + def get(self, key: str) -> bytes | None: + return self._cache.get(key, None) + + def put(self, key: str, value: bytes) -> bool: + self._cache[key] = value + return True + + def hit(self, key: str) -> bool: + return key in self._cache diff --git a/tests/_save/store/test_store.py b/tests/_save/store/test_store.py new file mode 100644 index 00000000000..6f73631fdf5 --- /dev/null +++ b/tests/_save/store/test_store.py @@ -0,0 +1,29 @@ +# Copyright 2026 Marimo. All rights reserved. + +from __future__ import annotations + +from marimo._ast.app import App + + +class TestStore: + def test_store(self, app: App) -> None: + @app.cell + def _(): + import marimo as mo + from tests._save.store.mocks import MockStore + + store = MockStore() + return store, mo + + @app.cell + def _(mo, store): + assert store._cache == {} + with mo.persistent_cache("mock", store=store) as cache: + a = 1 + b = 2 + + from pathlib import Path + + key = str(Path(f"mock/P_{cache._cache.key.hash}.pickle")) + assert key in store._cache + assert store._cache[key] diff --git a/tests/_save/stores/test_file.py b/tests/_save/stores/test_file.py new file mode 100644 index 00000000000..9427b260744 --- /dev/null +++ b/tests/_save/stores/test_file.py @@ -0,0 +1,53 @@ +# Copyright 2026 Marimo. All rights reserved. + +from __future__ import annotations + +from marimo._save.stores.file import FileStore + + +class TestFileStore: + def test_init_doesnt_make_file(self, tmp_path) -> None: + """Test that initializing FileStore does not create a file.""" + _store = FileStore(tmp_path / "test_store") + # Should not be created just on initialization + assert not (tmp_path / "test_store").exists() + + def test_get_put(self, tmp_path) -> None: + """Test put and get functionality of FileStore.""" + store = FileStore(tmp_path / "test_store") + assert not (tmp_path / "test_store").exists() + data = b"hello world" + store.put("key", data) + assert store.get("key") == data + # Store is actually created + assert (tmp_path / "test_store").exists() + assert (tmp_path / "test_store" / "key").exists() + + def test_clear(self, tmp_path) -> None: + """Test clear functionality of FileStore.""" + store = FileStore(tmp_path / "test_store") + data = b"test data" + + # Put some data + store.put("key1", data) + assert store.hit("key1") + assert store.get("key1") == data + + # Clear the key + result = store.clear("key1") + assert result is True + assert not store.hit("key1") + assert store.get("key1") is None + + # Clear non-existent key + result = store.clear("nonexistent") + assert result is False + + def test_default_path_is_lazy(self) -> None: + """Default save_path is not resolved at construction time.""" + store = FileStore() + # The backing attribute should be None until first access. + assert store._resolved_save_path is None + # Accessing the property triggers resolution. + _ = store.save_path + assert store._resolved_save_path is not None diff --git a/tests/_save/stores/test_store_config.py b/tests/_save/stores/test_store_config.py new file mode 100644 index 00000000000..56eba1f7e39 --- /dev/null +++ b/tests/_save/stores/test_store_config.py @@ -0,0 +1,72 @@ +# Copyright 2026 Marimo. All rights reserved. + +from __future__ import annotations + +from marimo._save.stores import ( + DEFAULT_STORE, + FileStore, + TieredStore, + _get_store_from_config, +) + + +class TestGetStoreFromConfig: + def test_none_config(self) -> None: + """Test that None config returns the default store.""" + store = _get_store_from_config(None) + assert isinstance(store, DEFAULT_STORE) + + def test_empty_list_config(self) -> None: + """Test that an empty list config returns the default store.""" + store = _get_store_from_config([]) + assert isinstance(store, DEFAULT_STORE) + + def test_list_with_none_config(self) -> None: + """Test that a list with None items returns the default store.""" + store = _get_store_from_config([None]) + assert isinstance(store, DEFAULT_STORE) + + def test_single_item_list(self) -> None: + """Test that a list with a single valid item returns that item's store.""" + config = [{"type": "file", "args": {"save_path": "/tmp/test"}}] + store = _get_store_from_config(config) + assert isinstance(store, FileStore) + assert store.save_path.as_posix() == "/tmp/test" + + def test_multi_item_list(self) -> None: + """Test that a list with multiple items returns a TieredStore.""" + config = [ + {"type": "file", "args": {"save_path": "/tmp/test1"}}, + {"type": "file", "args": {"save_path": "/tmp/test2"}}, + ] + store = _get_store_from_config(config) + assert isinstance(store, TieredStore) + assert len(store.stores) == 2 + assert all(isinstance(s, FileStore) for s in store.stores) + assert store.stores[0].save_path.as_posix() == "/tmp/test1" + assert store.stores[1].save_path.as_posix() == "/tmp/test2" + + def test_dict_config(self) -> None: + """Test that a dict config returns the appropriate store.""" + config = {"type": "file", "args": {"save_path": "/tmp/test"}} + store = _get_store_from_config(config) + assert isinstance(store, FileStore) + assert store.save_path.as_posix() == "/tmp/test" + + def test_invalid_store_type(self) -> None: + """Test that an invalid store type returns the default store.""" + config = {"type": "invalid", "args": {}} + store = _get_store_from_config(config) + assert isinstance(store, DEFAULT_STORE) + + def test_store_creation_error(self) -> None: + """Test that an error during store creation returns the default store.""" + config = {"type": "file", "args": {"invalid_arg": "value"}} + store = _get_store_from_config(config) + assert isinstance(store, DEFAULT_STORE) + + def test_missing_store_type_uses_default(self) -> None: + """Test that a missing store type uses the default store type.""" + config = {"args": {}} + store = _get_store_from_config(config) + assert isinstance(store, DEFAULT_STORE) diff --git a/tests/_save/stores/test_tiered.py b/tests/_save/stores/test_tiered.py new file mode 100644 index 00000000000..91aff00ea70 --- /dev/null +++ b/tests/_save/stores/test_tiered.py @@ -0,0 +1,175 @@ +# Copyright 2026 Marimo. All rights reserved. + +from __future__ import annotations + +from typing import Any +from unittest.mock import patch + +import pytest + +from marimo._save.stores.tiered import TieredStore +from tests._save.store.mocks import MockStore + + +def throw_exception(*args: Any, **kwargs: Any) -> None: + del args, kwargs + raise ValueError("Test exception") + + +class TestTieredStore: + def test_init_with_empty_stores_raises_error(self) -> None: + """Test that initializing with empty stores raises an error.""" + with pytest.raises(ValueError, match="At least one store is required"): + TieredStore([]) + + def test_init_with_multiple_stores(self) -> None: + """Test that initializing with multiple stores works.""" + stores = [MockStore(), MockStore()] + tiered_store = TieredStore(stores) + assert tiered_store.stores == stores + + def test_get_from_first_store(self) -> None: + """Test getting a value that exists in the first store.""" + store1 = MockStore() + store2 = MockStore() + key = "test_key" + value = b"test_value" + store1._cache[key] = value + + tiered_store = TieredStore([store1, store2]) + result = tiered_store.get(key) + + assert result == value + # Second store should remain empty + assert key not in store2._cache + + def test_get_from_second_store_updates_first(self) -> None: + """Test getting a value from the second store updates the first store.""" + store1 = MockStore() + store2 = MockStore() + key = "test_key" + value = b"test_value" + store2._cache[key] = value + + tiered_store = TieredStore([store1, store2]) + result = tiered_store.get(key) + + assert result == value + # First store should be updated + assert key in store1._cache + assert store1._cache[key] == value + + def test_get_not_found(self) -> None: + """Test that get returns None when the key is not found in any store.""" + store1 = MockStore() + store2 = MockStore() + key = "test_key" + + tiered_store = TieredStore([store1, store2]) + result = tiered_store.get(key) + + assert result is None + + def test_put_updates_all_stores(self) -> None: + """Test that put updates all stores.""" + store1 = MockStore() + store2 = MockStore() + key = "test_key" + value = b"test_value" + + tiered_store = TieredStore([store1, store2]) + result = tiered_store.put(key, value) + + assert result is True + assert key in store1._cache + assert store1._cache[key] == value + assert key in store2._cache + assert store2._cache[key] == value + + def test_hit_returns_true_if_any_store_has_key(self) -> None: + """Test that hit returns True if any store has the key.""" + store1 = MockStore() + store2 = MockStore() + key = "test_key" + value = b"test_value" + store2._cache[key] = value + + tiered_store = TieredStore([store1, store2]) + result = tiered_store.hit(key) + + assert result is True + + def test_hit_returns_false_if_no_store_has_key(self) -> None: + """Test that hit returns False if no store has the key.""" + store1 = MockStore() + store2 = MockStore() + key = "test_key" + + tiered_store = TieredStore([store1, store2]) + result = tiered_store.hit(key) + + assert result is False + + @patch("marimo._save.stores.tiered.LOGGER") + def test_get_with_exception(self, mock_logger) -> None: + """Test handling exceptions during get operation.""" + store1 = MockStore() + # Create a store that will raise an exception + store2 = MockStore() + store2.get = throw_exception + + tiered_store = TieredStore([store1, store2]) + result = tiered_store.get("test_key") + + assert result is None + mock_logger.error.assert_called_once() + assert "Test exception" in mock_logger.error.call_args[0][0] + + @patch("marimo._save.stores.tiered.LOGGER") + def test_put_with_exception(self, mock_logger) -> None: + """Test handling exceptions during put operation.""" + store1 = MockStore() + # Create a store that will raise an exception + store2 = MockStore() + store2.put = throw_exception + + tiered_store = TieredStore([store1, store2]) + result = tiered_store.put("test_key", b"test_value") + + # Should still return True because store1 succeeded + assert result is True + mock_logger.error.assert_called_once() + assert "Test exception" in mock_logger.error.call_args[0][0] + + @patch("marimo._save.stores.tiered.LOGGER") + def test_hit_with_exception(self, mock_logger) -> None: + """Test handling exceptions during hit operation.""" + store1 = MockStore() + # Create a store that will raise an exception + store2 = MockStore() + store2.hit = throw_exception + + tiered_store = TieredStore([store1, store2]) + result = tiered_store.hit("test_key") + + assert result is False + mock_logger.error.assert_called_once() + assert "Test exception" in mock_logger.error.call_args[0][0] + + @patch("marimo._save.stores.tiered.LOGGER") + def test_update_preceding_stores_with_exception(self, mock_logger) -> None: + """Test handling exceptions during update_preceding_stores operation.""" + store1 = MockStore() + store1.put = throw_exception + store2 = MockStore() + key = "test_key" + value = b"test_value" + store2._cache[key] = value + + tiered_store = TieredStore([store1, store2]) + # This should trigger _update_preceding_stores + result = tiered_store.get(key) + + assert result == value + mock_logger.error.assert_called_once() + assert "Test exception" in mock_logger.error.call_args[0][0] diff --git a/tests/_save/stubs/__init__.py b/tests/_save/stubs/__init__.py new file mode 100644 index 00000000000..fe2d2e388e5 --- /dev/null +++ b/tests/_save/stubs/__init__.py @@ -0,0 +1 @@ +# Copyright 2026 Marimo. All rights reserved. diff --git a/tests/_save/stubs/test_arrow_codecs.py b/tests/_save/stubs/test_arrow_codecs.py new file mode 100644 index 00000000000..2802fe232b4 --- /dev/null +++ b/tests/_save/stubs/test_arrow_codecs.py @@ -0,0 +1,52 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import warnings + +import pytest + +from marimo._dependencies.dependencies import DependencyManager +from marimo._save.stubs.lazy_stub import BLOB_DESERIALIZERS, BLOB_SERIALIZERS + +HAS_PANDAS_ARROW = ( + DependencyManager.pandas.has() and DependencyManager.pyarrow.has() +) + + +def _has_feather_deprecation_warning( + caught: list[warnings.WarningMessage], +) -> bool: + return any( + issubclass(w.category, FutureWarning) + and "write_feather" in str(w.message) + for w in caught + ) + + +@pytest.mark.skipif(not HAS_PANDAS_ARROW, reason="pandas and pyarrow required") +def test_pandas_dataframe_arrow_round_trip_without_feather_warning() -> None: + import pandas as pd + + df = pd.DataFrame({"x": [1, 2], "y": [3.0, 4.0]}) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + data = BLOB_SERIALIZERS["arrow"](df) + assert not _has_feather_deprecation_warning(caught) + + restored = BLOB_DESERIALIZERS[".arrow"](data, "pandas.DataFrame") + pd.testing.assert_frame_equal(restored, df) + + +@pytest.mark.skipif(not HAS_PANDAS_ARROW, reason="pandas and pyarrow required") +def test_pandas_series_arrow_round_trip_without_feather_warning() -> None: + import pandas as pd + + series = pd.Series([10, 20, 30], name="vals") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + data = BLOB_SERIALIZERS["arrow"](series) + assert not _has_feather_deprecation_warning(caught) + + restored = BLOB_DESERIALIZERS[".arrow"](data, "pandas.Series") + assert isinstance(restored, pd.Series) + pd.testing.assert_series_equal(restored, series) diff --git a/tests/_save/stubs/test_cache_function_stub.py b/tests/_save/stubs/test_cache_function_stub.py new file mode 100644 index 00000000000..af2a6716917 --- /dev/null +++ b/tests/_save/stubs/test_cache_function_stub.py @@ -0,0 +1,163 @@ +# Copyright 2026 Marimo. All rights reserved. +"""A `@mo.cache` / `@mo.persistent_cache` wrapper survives a cache save/restore +cycle as a *working* wrapper (not an inert tripwire), captured by `FunctionStub` +with `is_cached=True`. + +On a cache hit the restored wrapper serves the wrapper's own persistent-cache +entries without re-importing the body's heavy dependencies (the motivating case +is a torch notebook exported to WASM). Only a genuine miss reaches the body. +""" + +from __future__ import annotations + +import ast +from pathlib import Path +from typing import Any + +import marimo as mo +from marimo._ast.transformers import get_hashable_ast +from marimo._save.cache import Cache +from marimo._save.loaders.lazy import from_item, to_item +from marimo._save.stubs import FunctionStub +from marimo._save.stubs.lazy_stub import UnhashableStub + +SKIP = {"cache", "persistent_cache"} + + +def _cache() -> Cache: + return Cache( + defs={}, + hash="h", + cache_type="Pure", + stateful_refs=set(), + hit=False, + meta={}, + ) + + +def _as_notebook(wrapper: Any) -> Any: + """Cell code runs as `__main__`; mimic that so the convert guard fires.""" + wrapper.__wrapped__.__module__ = "__main__" + return wrapper + + +def test_cache_call_converts_to_cached_stub() -> None: + @mo.cache + def add(a, b): + return a + b + + stub = _cache()._convert_to_stub_if_needed(_as_notebook(add)) + assert isinstance(stub, FunctionStub) + assert stub.is_cached + assert stub.filename == "" + # The decorator line is captured so re-exec re-applies it. + assert "@mo.cache" in stub.code + assert "def add" in stub.code + + +def test_async_cache_call_converts_to_cached_stub() -> None: + @mo.cache + async def afetch(a): + return a + + stub = _cache()._convert_to_stub_if_needed(_as_notebook(afetch)) + assert isinstance(stub, FunctionStub) + assert stub.is_cached + assert "async def afetch" in stub.code + + +def test_lambda_wrapper_not_converted() -> None: + wrapper = _as_notebook(mo.cache(lambda x: x + 1)) + result = _cache()._convert_to_stub_if_needed(wrapper) + assert not (isinstance(result, FunctionStub) and result.is_cached) + + +def test_library_module_function_not_converted() -> None: + # __module__ is the test module (not "__main__"): a library-owned cache + # wrapper must not be captured as notebook source. + @mo.cache + def add(a, b): + return a + b + + result = _cache()._convert_to_stub_if_needed(add) + assert not (isinstance(result, FunctionStub) and result.is_cached) + + +def test_manifest_round_trip() -> None: + @mo.cache + def add(a, b): + return a + b + + stub = _cache()._convert_to_stub_if_needed(_as_notebook(add)) + item = to_item(Path("cache/add"), stub, var_name="add") + # Inlined in the function field, carrying the is_cached flag. + assert item.function == (stub.code, stub.filename, stub.lineno, True) + assert item.reference is None + + back = from_item(item, "add") + assert isinstance(back, FunctionStub) + assert back.is_cached + assert back.code == stub.code + assert back.filename == "" + + +def test_load_rebuilds_working_wrapper() -> None: + @mo.cache + def add(a, b): + return a + b + + stub = _cache()._convert_to_stub_if_needed(_as_notebook(add)) + rebuilt = stub.load({"mo": mo}) + + # A live cache wrapper, not the stub. + assert type(rebuilt).__name__ == "_cache_call" + assert rebuilt(2, 3) == 5 + # Second identical call is served from the wrapper's own cache. + assert rebuilt(2, 3) == 5 + assert rebuilt.cache_info().hits >= 1 + + +def test_restored_wrapper_hashes_to_same_ast() -> None: + # The wrapper's own AST is re-hashed after restore to derive per-call keys. + # The synthetic `` filename + linecache registration must reproduce + # the native hashable AST even though this function is not at line 1 of the + # source file (a naive real-filename re-hash would read the wrong lines). + @mo.persistent_cache(method="lazy") + def compute(a, b): + return a + b + + orig_ast = ast.dump( + get_hashable_ast(compute.__wrapped__, skip_decorators=SKIP) + ) + + stub = FunctionStub(compute, is_cached=True) + rebuilt = stub.load({"mo": mo}) + restored_ast = ast.dump( + get_hashable_ast(rebuilt.__wrapped__, skip_decorators=SKIP) + ) + + assert restored_ast == orig_ast + + +def test_body_ref_degraded_does_not_gate_rebuild() -> None: + # The regression this design fixes: a cache wrapper resolves its body refs + # at call time, so a degraded (unavailable) body ref must NOT block the + # rebuild the way it (correctly) does for a plain function / class. The + # is_cached flag keeps `_restore_deps` empty for the wrapper, so the + # degraded-dep gate never fires. + def heavy(a): # stands in for a torch-backed helper + return a * 100 + + @mo.cache + def needy(a): + return heavy(a) + + cache = _cache() + stub = cache._convert_to_stub_if_needed(_as_notebook(needy)) + assert isinstance(stub, FunctionStub) + assert stub.is_cached + + # No cross-def dependency gating for the wrapper. + assert cache._restore_deps(stub) == set() + scope = {"heavy": UnhashableStub(var_name="heavy")} + assert cache._first_degraded_dep(stub, scope) is None diff --git a/tests/_save/stubs/test_class_stub.py b/tests/_save/stubs/test_class_stub.py new file mode 100644 index 00000000000..be123815e5d --- /dev/null +++ b/tests/_save/stubs/test_class_stub.py @@ -0,0 +1,203 @@ +# Copyright 2026 Marimo. All rights reserved. +"""Tests for ClassStub — source-based serialization of cell-defined classes.""" + +from __future__ import annotations + +import linecache +import sys +import tempfile +import textwrap +from pathlib import Path +from typing import Any + +import pytest + +from marimo._save.stubs.class_stub import ClassStub + + +class TestClassStubBasics: + @staticmethod + def test_load_populates_glbls() -> None: + """ClassStub.load returns the class AND leaves it in glbls + so subsequent pickle deserialization can find it.""" + # Build a ClassStub by hand from a known dump. + code = textwrap.dedent( + """ + class Bar: + value = 42 + """ + ) + stub = ClassStub.from_dump((code, "Bar")) + glbls: dict[str, Any] = {"__name__": "__main__"} + cls = stub.load(glbls) + assert cls is not None + assert cls.__name__ == "Bar" + assert glbls["Bar"] is cls + assert cls.value == 42 + + @staticmethod + def test_load_seeds_linecache_for_traceback() -> None: + """Outside a kernel context, load compiles under a synthetic + per-class filename and seeds linecache so tracebacks render the + class source.""" + code = textwrap.dedent( + """ + class Baz: + def hello(self) -> str: + return "hi" + """ + ) + synth_name = "" + linecache.cache.pop(synth_name, None) + stub = ClassStub.from_dump((code, "Baz")) + glbls: dict[str, Any] = {"__name__": "__main__"} + stub.load(glbls) + assert synth_name in linecache.cache + assert glbls["Baz"]().hello() == "hi" + + +class TestClassStubFromLiveClass: + @staticmethod + def test_from_live_class_in_temp_module() -> None: + """End-to-end: take a live class from a temp module, ClassStub it, + round-trip through dump/from_dump, and verify the class works.""" + src = textwrap.dedent( + """ + class Widget: + def __init__(self, n: int) -> None: + self.n = n + + def squared(self) -> int: + return self.n * self.n + """ + ) + with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f: + f.write(src) + tmp_path = f.name + module_name = "_test_class_stub_live" + try: + module = type(sys)(module_name) + module.__file__ = tmp_path + sys.modules[module_name] = module + # Populate linecache so inspect.getsource works. + linecache.cache[tmp_path] = ( + len(src), + None, + [line + "\n" for line in src.splitlines()], + tmp_path, + ) + exec(compile(src, tmp_path, "exec"), module.__dict__) + cls = module.Widget + + stub = ClassStub(cls) + dump = stub.dump() + assert "class Widget" in dump[0] + + # Reload in a vacuum. + fresh_stub = ClassStub.from_dump(dump) + fresh_glbls: dict[str, Any] = {"__name__": "__main__"} + fresh_stub.load(fresh_glbls) + assert fresh_glbls["Widget"](5).squared() == 25 + finally: + sys.modules.pop(module_name, None) + linecache.cache.pop(tmp_path, None) + Path(tmp_path).unlink(missing_ok=True) + + +class TestClassStubSourceResolution: + """Filename/lineno resolution for awkward cell-defined classes.""" + + @staticmethod + def _seed_cell(src: str) -> tuple[str, dict[str, Any]]: + """Compile `src` under a fake cell filename, registering it in + linecache the way marimo's compiler does, and return + (filename, namespace).""" + filename = "__marimo__cell_TeSt_.py" + linecache.cache[filename] = ( + len(src), + None, + [line + "\n" for line in src.splitlines()], + filename, + ) + glbls: dict[str, Any] = {"__name__": "__main__"} + exec(compile(src, filename, "exec"), glbls) + return filename, glbls + + def test_redefined_class_captures_live_definition(self) -> None: + """When a class name is defined twice, the stub must capture the + live (latest) definition's source, not a stale earlier one.""" + src = textwrap.dedent( + """ + class Dup: + def tag(self) -> str: + return "old" + + class Dup: + def tag(self) -> str: + return "new" + """ + ).strip() + filename, glbls = self._seed_cell(src) + try: + stub = ClassStub(glbls["Dup"], filename=filename) + assert '"new"' in stub.code + assert '"old"' not in stub.code + fresh: dict[str, Any] = {"__name__": "__main__"} + assert stub.load(fresh)().tag() == "new" + finally: + linecache.cache.pop(filename, None) + + def test_staticmethod_only_class_resolves(self) -> None: + """A class whose only callable is a staticmethod has no plain + method `__code__`; the stub must unwrap the descriptor to find + the source filename.""" + src = textwrap.dedent( + """ + class OnlyStatic: + @staticmethod + def f() -> int: + return 7 + """ + ).strip() + filename, glbls = self._seed_cell(src) + try: + stub = ClassStub(glbls["OnlyStatic"], filename=filename) + assert "class OnlyStatic" in stub.code + fresh: dict[str, Any] = {"__name__": "__main__"} + assert stub.load(fresh).f() == 7 + finally: + linecache.cache.pop(filename, None) + + def test_attribute_only_class_resolves_with_hint(self) -> None: + """A body-only class (no callables at all) has no code object to + read a filename from; the cache layer's filename hint lets it + source from linecache anyway.""" + src = textwrap.dedent( + """ + class JustAttrs: + x = 1 + y = 2 + """ + ).strip() + filename, glbls = self._seed_cell(src) + try: + stub = ClassStub(glbls["JustAttrs"], filename=filename) + fresh: dict[str, Any] = {"__name__": "__main__"} + restored = stub.load(fresh) + assert restored.x == 1 + assert restored.y == 2 + finally: + linecache.cache.pop(filename, None) + + +class TestClassStubFailures: + @staticmethod + def test_unsourcable_class_raises_on_init() -> None: + """A class with no discoverable source (e.g. built via type()) + cannot be ClassStub'd. The error must surface at construction + time so the save path can fall back to UnhashableStub before + writing a corrupt blob.""" + # type(...) classes have no source file inspect can find. + DynCls = type("DynCls", (), {"x": 1}) + with pytest.raises((TypeError, OSError)): + ClassStub(DynCls) diff --git a/tests/_save/stubs/test_lazy_asset_codec.py b/tests/_save/stubs/test_lazy_asset_codec.py new file mode 100644 index 00000000000..3fbe17fe93a --- /dev/null +++ b/tests/_save/stubs/test_lazy_asset_codec.py @@ -0,0 +1,72 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from pathlib import Path + +from marimo._save.cache import MARIMO_CACHE_VERSION, Cache +from marimo._save.hash import HashKey +from marimo._save.loaders import LazyLoader +from marimo._save.loaders.lazy import maybe_update_lazy_stub +from marimo._save.stubs.lazy_stub import ( + BLOB_DESERIALIZERS, + BLOB_SERIALIZERS, + BlobAsset, +) +from tests._save.store.mocks import MockStore + + +def test_blob_asset_resolves_to_bin_codec() -> None: + asset = BlobAsset( + data=b"", + media_type="image/svg+xml", + filename="chart.svg", + ) + + assert maybe_update_lazy_stub(asset) == "bin" + + +def test_blob_asset_codec_round_trip() -> None: + asset = BlobAsset( + data=b'{"ok": true}', + media_type="application/json", + filename="data.json", + metadata={"format_id": "example.json.v1"}, + ) + + encoded = BLOB_SERIALIZERS["bin"](asset) + + assert BLOB_DESERIALIZERS[".bin"](encoded, None) == asset + + +def test_lazy_loader_round_trips_blob_asset() -> None: + store = MockStore() + loader = LazyLoader("asset-test", store=store, mode="off") + asset = BlobAsset( + data=b"\x89PNG\r\n\x1a\n", + media_type="image/png", + filename="preview.png", + metadata={"format_id": "image.png.v1"}, + ) + cache = Cache( + defs={"preview": asset}, + hash="asset_hash", + cache_type="Pure", + stateful_refs=set(), + hit=False, + meta={"version": MARIMO_CACHE_VERSION, "return": asset}, + ) + + assert loader.save_cache(cache) + loader.flush() + + assert ( + Path("asset-test") / "asset_hash" / "preview.bin" + ).as_posix() in store._cache + assert ( + Path("asset-test") / "asset_hash" / "return.bin" + ).as_posix() in store._cache + loaded = loader.load_cache(HashKey("asset_hash", "Pure")) + + assert loaded is not None + assert loaded.defs == {"preview": asset} + assert loaded.meta["return"] == asset diff --git a/tests/_save/stubs/test_lazy_codecs.py b/tests/_save/stubs/test_lazy_codecs.py new file mode 100644 index 00000000000..aa7dada9dae --- /dev/null +++ b/tests/_save/stubs/test_lazy_codecs.py @@ -0,0 +1,37 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import pytest + +torch = pytest.importorskip("torch") + +from marimo._save.loaders.lazy import maybe_update_lazy_stub # noqa: E402 +from marimo._save.stubs.lazy_stub import ( # noqa: E402 + BLOB_DESERIALIZERS, + BLOB_SERIALIZERS, +) + + +def test_tensor_resolves_to_pt_codec() -> None: + assert maybe_update_lazy_stub(torch.ones(4)) == "pt" + + +def test_parameter_resolves_to_pt_codec_via_mro() -> None: + param = torch.nn.Parameter(torch.ones(4)) + assert maybe_update_lazy_stub(param) == "pt" + + +def test_pt_round_trip() -> None: + tensor = torch.randn(8, 3) + data = BLOB_SERIALIZERS["pt"](tensor) + restored = BLOB_DESERIALIZERS[".pt"](data, "torch.Tensor") + assert torch.equal(tensor, restored) + assert restored.dtype == tensor.dtype + + +def test_pt_round_trip_preserves_parameter_subclass() -> None: + param = torch.nn.Parameter(torch.randn(4)) + data = BLOB_SERIALIZERS["pt"](param) + restored = BLOB_DESERIALIZERS[".pt"](data, "torch.nn.parameter.Parameter") + assert isinstance(restored, torch.nn.Parameter) + assert torch.equal(param.data, restored.data) diff --git a/tests/_save/stubs/test_module_stub.py b/tests/_save/stubs/test_module_stub.py new file mode 100644 index 00000000000..78d7aeefba6 --- /dev/null +++ b/tests/_save/stubs/test_module_stub.py @@ -0,0 +1,94 @@ +# Copyright 2026 Marimo. All rights reserved. +"""Tests for ModuleStub — name-based serialization of module objects.""" + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +import pytest + +from marimo._save.stubs.module_stub import MissingModule, ModuleStub + +if TYPE_CHECKING: + from pathlib import Path + + +class TestModuleStubVersion: + @staticmethod + def test_submodule_alias_captures_root_version( + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A submodule with no own `__version__` pins the root package's, + so a version-pinned cache reproduces its hash on restore even when + the package is absent (e.g. `import torch.nn as nn` in WASM).""" + import types + + root = types.ModuleType("_marimo_pkg_ver_test") + root.__version__ = "9.9.9" + sub = types.ModuleType("_marimo_pkg_ver_test.sub") # no __version__ + monkeypatch.setitem(sys.modules, root.__name__, root) + monkeypatch.setitem(sys.modules, sub.__name__, sub) + + assert ModuleStub(sub).version == "9.9.9" + # An explicit version still wins over the fallback. + assert ModuleStub(sub, version="1.2.3").version == "1.2.3" + + @staticmethod + def test_no_version_anywhere_is_empty() -> None: + import types + + mod = types.ModuleType("_marimo_no_ver_test") + assert ModuleStub(mod).version == "" + + +class TestModuleStubLoad: + @staticmethod + def test_load_existing_module() -> None: + """An importable module is returned as the real module.""" + import json + + stub = ModuleStub(json) + assert stub.load() is json + + @staticmethod + def test_missing_module_returns_placeholder() -> None: + """A module absent from this environment restores to a + MissingModule placeholder rather than raising.""" + stub = ModuleStub.__new__(ModuleStub) + stub.name = "marimo_definitely_not_a_real_module_xyz" + stub.hash = "" + stub.version = "" + result = stub.load() + assert isinstance(result, MissingModule) + assert result.__missing__ is True + # Accessing an attribute surfaces the deferred import error. + with pytest.raises(ModuleNotFoundError): + _ = result.some_attr + + @staticmethod + def test_transitive_import_failure_reraises( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A module that *exists* but fails on an internal import of a + different missing module must NOT be masked as "this module is + missing" — the real ModuleNotFoundError is re-raised.""" + mod_name = "_marimo_transitive_stub_test" + (tmp_path / f"{mod_name}.py").write_text( + "import a_module_that_truly_does_not_exist_zzz\n" + ) + monkeypatch.syspath_prepend(str(tmp_path)) + sys.modules.pop(mod_name, None) + try: + stub = ModuleStub.__new__(ModuleStub) + stub.name = mod_name + stub.hash = "" + stub.version = "" + with pytest.raises(ModuleNotFoundError) as exc_info: + stub.load() + # The error is about the transitive dep, not our module. + assert ( + exc_info.value.name == "a_module_that_truly_does_not_exist_zzz" + ) + finally: + sys.modules.pop(mod_name, None) diff --git a/tests/_save/stubs/test_pydantic_stub.py b/tests/_save/stubs/test_pydantic_stub.py new file mode 100644 index 00000000000..11e6cbecb36 --- /dev/null +++ b/tests/_save/stubs/test_pydantic_stub.py @@ -0,0 +1,200 @@ +# Copyright 2026 Marimo. All rights reserved. + +from __future__ import annotations + +import pytest + +pytest.importorskip("pydantic_core") + + +from marimo._save.stubs.pydantic_stub import PydanticStub + + +class TestPydanticStub: + """Tests for PydanticStub serialization and deserialization.""" + + @staticmethod + def test_basic_model() -> None: + """Test stub with basic pydantic model.""" + from pydantic import BaseModel + + class BasicModel(BaseModel): + name: str + value: int + + model = BasicModel(name="test", value=42) + + # Create stub + stub = PydanticStub(model) + + # Verify stub attributes + assert stub.model_class == BasicModel + assert stub.pydantic_dict == {"name": "test", "value": 42} + assert stub.pydantic_fields_set == ["name", "value"] + assert stub.pydantic_extra is None + assert stub.pydantic_private is None + + # Restore and verify + restored = stub.load({}) + assert isinstance(restored, BasicModel) + assert restored.name == model.name + assert restored.value == model.value + assert restored.model_fields_set == model.model_fields_set + + @staticmethod + def test_model_with_private_fields() -> None: + """Test stub with model containing private fields.""" + from pydantic import BaseModel, PrivateAttr + + class ModelWithPrivate(BaseModel): + name: str + _private: int = PrivateAttr(default=0) + _secret: str = PrivateAttr(default="secret") + + model = ModelWithPrivate(name="test") + model._private = 99 + model._secret = "my_secret" + + # Create stub + stub = PydanticStub(model) + + # Verify private fields captured + assert stub.pydantic_private is not None + assert "_private" in stub.pydantic_private + assert "_secret" in stub.pydantic_private + assert stub.pydantic_private["_private"] == 99 + assert stub.pydantic_private["_secret"] == "my_secret" + + # Restore and verify private fields + restored = stub.load({}) + assert restored._private == model._private + assert restored._secret == model._secret + + @staticmethod + def test_model_with_extra_fields() -> None: + """Test stub with model allowing extra fields.""" + from pydantic import BaseModel, ConfigDict + + class ModelWithExtra(BaseModel): + model_config = ConfigDict(extra="allow") + name: str + + model = ModelWithExtra(name="test", extra_field="bonus", another=123) + + # Create stub + stub = PydanticStub(model) + + # Verify extra fields captured + assert stub.pydantic_extra is not None + assert "extra_field" in stub.pydantic_extra + assert "another" in stub.pydantic_extra + assert stub.pydantic_extra["extra_field"] == "bonus" + assert stub.pydantic_extra["another"] == 123 + + # Restore and verify extra fields + restored = stub.load({}) + assert restored.__pydantic_extra__ == model.__pydantic_extra__ + # Access extra fields via __pydantic_extra__ + assert restored.__pydantic_extra__["extra_field"] == "bonus" + assert restored.__pydantic_extra__["another"] == 123 + + @staticmethod + def test_complex_model() -> None: + """Test stub with model having all features.""" + from pydantic import BaseModel, ConfigDict, PrivateAttr + + class ComplexModel(BaseModel): + model_config = ConfigDict(extra="allow") + name: str + value: int + _private: str = PrivateAttr(default="secret") + + model = ComplexModel(name="test", value=42, extra="bonus") + model._private = "my_secret" + + # Create stub + stub = PydanticStub(model) + + # Verify all state captured + assert stub.pydantic_dict == {"name": "test", "value": 42} + assert "extra" in stub.pydantic_extra + assert stub.pydantic_private["_private"] == "my_secret" + assert "extra" in stub.pydantic_fields_set + assert "name" in stub.pydantic_fields_set + assert "value" in stub.pydantic_fields_set + + # Restore and verify everything + restored = stub.load({}) + assert restored.name == model.name + assert restored.value == model.value + assert restored._private == model._private + assert restored.__pydantic_extra__ == model.__pydantic_extra__ + assert restored.model_fields_set == model.model_fields_set + + @staticmethod + def test_deterministic_fields_set() -> None: + """Test that fields_set is sorted for deterministic serialization.""" + from pydantic import BaseModel + + class Model(BaseModel): + a: int + z: int + m: int + + # Create multiple instances with different field order + model1 = Model(z=1, a=2, m=3) + model2 = Model(a=2, m=3, z=1) + + stub1 = PydanticStub(model1) + stub2 = PydanticStub(model2) + + # fields_set should be sorted and identical + assert stub1.pydantic_fields_set == stub2.pydantic_fields_set + assert stub1.pydantic_fields_set == ["a", "m", "z"] + + @staticmethod + def test_nested_models() -> None: + """Test stub with nested pydantic models.""" + from pydantic import BaseModel + + class InnerModel(BaseModel): + inner_value: int + + class OuterModel(BaseModel): + name: str + inner: InnerModel + + inner = InnerModel(inner_value=99) + outer = OuterModel(name="test", inner=inner) + + # Create stub + stub = PydanticStub(outer) + + # Restore and verify nested structure + restored = stub.load({}) + assert restored.name == outer.name + assert isinstance(restored.inner, InnerModel) + assert restored.inner.inner_value == outer.inner.inner_value + + @staticmethod + def test_partial_fields_set() -> None: + """Test model where not all fields are set.""" + from pydantic import BaseModel + + class Model(BaseModel): + required: str + optional: int = 42 + + # Only set required field + model = Model(required="test") + + stub = PydanticStub(model) + + # Only required field should be in fields_set + assert stub.pydantic_fields_set == ["required"] + + # Restore and verify + restored = stub.load({}) + assert restored.required == model.required + assert restored.optional == model.optional + assert restored.model_fields_set == {"required"} diff --git a/tests/_save/stubs/test_stubs.py b/tests/_save/stubs/test_stubs.py new file mode 100644 index 00000000000..ed0d19581d6 --- /dev/null +++ b/tests/_save/stubs/test_stubs.py @@ -0,0 +1,295 @@ +# Copyright 2026 Marimo. All rights reserved. + +from __future__ import annotations + +from typing import Any + +import pytest + +from marimo._save.stubs import ( + CUSTOM_STUBS, + STUB_REGISTRATIONS, + CustomStub, + maybe_register_stub, + register_stub, +) + + +class TestStubRegistration: + """Tests for stub registration mechanism.""" + + @staticmethod + def test_stub_registrations_dict() -> None: + """Test that STUB_REGISTRATIONS contains expected entries.""" + # Should have pydantic.main.BaseModel + assert "pydantic.main.BaseModel" in STUB_REGISTRATIONS + + @staticmethod + @pytest.mark.skipif( + not pytest.importorskip( + "pydantic_core", reason="pydantic not installed" + ), + reason="pydantic required", + ) + def test_maybe_register_stub_pydantic() -> None: + """Test registering a pydantic model.""" + from pydantic import BaseModel + + from marimo._save.stubs import _REGISTERED_NAMES + from marimo._save.stubs.pydantic_stub import PydanticStub + + class TestModel(BaseModel): + value: int + + # Clear any existing registration + if BaseModel in CUSTOM_STUBS: + del CUSTOM_STUBS[BaseModel] + if TestModel in CUSTOM_STUBS: + del CUSTOM_STUBS[TestModel] + # Also clear registered names + _REGISTERED_NAMES.discard("pydantic.main.BaseModel") + + model = TestModel(value=42) + + # Register the stub + result = maybe_register_stub(model) + + # Should return True (registered) + assert result is True + + # BaseModel should now be in CUSTOM_STUBS + assert BaseModel in CUSTOM_STUBS + assert CUSTOM_STUBS[BaseModel] is PydanticStub + + # Subclass should also be registered + assert TestModel in CUSTOM_STUBS + assert CUSTOM_STUBS[TestModel] is PydanticStub + + @staticmethod + @pytest.mark.skipif( + not pytest.importorskip( + "pydantic_core", reason="pydantic not installed" + ), + reason="pydantic required", + ) + def test_maybe_register_stub_already_registered() -> None: + """Test that already registered stubs return True immediately.""" + from pydantic import BaseModel + + from marimo._save.stubs import _REGISTERED_NAMES + + class TestModel(BaseModel): + value: int + + # Ensure clean state + if BaseModel in CUSTOM_STUBS: + del CUSTOM_STUBS[BaseModel] + if TestModel in CUSTOM_STUBS: + del CUSTOM_STUBS[TestModel] + _REGISTERED_NAMES.discard("pydantic.main.BaseModel") + + model = TestModel(value=42) + + # First registration + result1 = maybe_register_stub(model) + assert result1 is True + + # Verify it's registered + assert BaseModel in CUSTOM_STUBS + assert TestModel in CUSTOM_STUBS + + # Second call should return True immediately (already in CUSTOM_STUBS) + result2 = maybe_register_stub(model) + assert result2 is True + + @staticmethod + def test_maybe_register_stub_no_match() -> None: + """Test that non-matching types return False.""" + + class PlainClass: + pass + + obj = PlainClass() + + # Should return False (no registration) + result = maybe_register_stub(obj) + assert result is False + + # Should not be in CUSTOM_STUBS + assert PlainClass not in CUSTOM_STUBS + + @staticmethod + @pytest.mark.skipif( + not pytest.importorskip("pydantic", reason="pydantic not installed"), + reason="pydantic required", + ) + def test_mro_traversal() -> None: + """Test that MRO traversal finds base class registration.""" + from pydantic import BaseModel + + from marimo._save.stubs import _REGISTERED_NAMES + + # Clear registrations + if BaseModel in CUSTOM_STUBS: + del CUSTOM_STUBS[BaseModel] + _REGISTERED_NAMES.discard("pydantic.main.BaseModel") + + class Parent(BaseModel): + x: int + + class Child(Parent): + y: int + + if Child in CUSTOM_STUBS: + del CUSTOM_STUBS[Child] + + child = Child(x=1, y=2) + + # Should register via MRO (finds BaseModel in parent chain) + result = maybe_register_stub(child) + assert result is True + + # Both BaseModel and Child should be registered + assert BaseModel in CUSTOM_STUBS + assert Child in CUSTOM_STUBS + + +class TestCustomStubBase: + """Tests for CustomStub base class.""" + + @staticmethod + def test_abstract_methods() -> None: + """Test that CustomStub has required abstract methods.""" + # Should not be able to instantiate directly + with pytest.raises(TypeError): + CustomStub() # type: ignore + + @staticmethod + def test_register_classmethod() -> None: + """Test that register is a classmethod.""" + assert hasattr(CustomStub, "register") + assert callable(CustomStub.register) + + @staticmethod + def test_get_type_staticmethod() -> None: + """Test that get_type is a static method.""" + assert hasattr(CustomStub, "get_type") + + @staticmethod + def test_slots() -> None: + """Test that CustomStub has __slots__ defined.""" + assert hasattr(CustomStub, "__slots__") + assert CustomStub.__slots__ == () + + +class TestRegisterStub: + """Tests for register_stub function.""" + + @staticmethod + def test_register_stub_basic() -> None: + """Test basic stub registration.""" + + class DummyType: + pass + + class DummyStub(CustomStub): + __slots__ = ("obj",) + + def __init__(self, obj: Any) -> None: + self.obj = obj + + def load(self, glbls: dict[str, Any]) -> Any: + del glbls # Unused + return self.obj + + @staticmethod + def get_type() -> type: + return DummyType + + # Register + register_stub(DummyType, DummyStub) + + # Should be in CUSTOM_STUBS + assert DummyType in CUSTOM_STUBS + assert CUSTOM_STUBS[DummyType] is DummyStub + + # Clean up + del CUSTOM_STUBS[DummyType] + + @staticmethod + def test_register_stub_none() -> None: + """Test registering with None type does nothing.""" + + class DummyStub(CustomStub): + __slots__ = () + + def __init__(self, obj: Any) -> None: + pass + + def load(self, glbls: dict[str, Any]) -> Any: + del glbls # Unused + return None + + @staticmethod + def get_type() -> type: + return object + + # Register with None + register_stub(None, DummyStub) + + # Should not add None to CUSTOM_STUBS + assert None not in CUSTOM_STUBS + + +class TestStubIntegration: + """Integration tests for stub system.""" + + @staticmethod + @pytest.mark.skipif( + not pytest.importorskip( + "pydantic_core", reason="pydantic not installed" + ), + reason="pydantic required", + ) + def test_cache_integration() -> None: + """Test stub integration with cache system.""" + from pydantic import BaseModel + + from marimo._save.cache import Cache + from marimo._save.stubs import _REGISTERED_NAMES + from marimo._save.stubs.pydantic_stub import PydanticStub + + class TestModel(BaseModel): + name: str + value: int + + # Clear any existing registration to ensure clean test + if BaseModel in CUSTOM_STUBS: + del CUSTOM_STUBS[BaseModel] + if TestModel in CUSTOM_STUBS: + del CUSTOM_STUBS[TestModel] + _REGISTERED_NAMES.discard("pydantic.main.BaseModel") + + model = TestModel(name="test", value=42) + + # Create cache + cache = Cache.empty( + key=type("HashKey", (), {"hash": "test", "cache_type": "Pure"})(), + defs={"x"}, + stateful_refs=set(), + ) + + # Convert to stub (should trigger registration and conversion) + converted = cache._convert_to_stub_if_needed(model, {}) + + # Should be a PydanticStub + assert isinstance(converted, PydanticStub) + + # Restore from stub + restored = cache._restore_from_stub_if_needed(converted, {}, {}) + + # Should match original + assert isinstance(restored, TestModel) + assert restored.name == model.name + assert restored.value == model.value + assert restored.model_fields_set == model.model_fields_set diff --git a/tests/_save/stubs/test_unhashable_stub.py b/tests/_save/stubs/test_unhashable_stub.py new file mode 100644 index 00000000000..9a28308e8a5 --- /dev/null +++ b/tests/_save/stubs/test_unhashable_stub.py @@ -0,0 +1,179 @@ +# Copyright 2026 Marimo. All rights reserved. +"""UnhashableStub semantics: data, tripwire, and Cache.restore handling. + +Split from the cached-lifecycle suite: these tests exercise only the +stub serialization toolkit (no runtime lifecycle involvement). +""" + +from __future__ import annotations + +import pickle + +import pytest + +from marimo._runtime.exceptions import ( + MarimoRescheduleError, + MarimoUnhashableCacheError, +) +from marimo._save.cache import Cache +from marimo._save.loaders.lazy import from_item +from marimo._save.stubs.lazy_stub import Item, UnhashableStub + +# --------------------------------------------------------------------------- +# UnhashableStub: data + tripwire semantics +# --------------------------------------------------------------------------- + + +class TestUnhashableStub: + def test_init_captures_type_info(self) -> None: + stub = UnhashableStub( + lambda x: x, var_name="f", error_msg="lambdas not pickleable" + ) + assert stub.var_name == "f" + assert "function" in stub.type_name.lower() + assert stub.error_msg == "lambdas not pickleable" + + def test_load_raises_unhashable_error(self) -> None: + stub = UnhashableStub(None, var_name="f", error_msg="cannot pickle") + with pytest.raises(MarimoUnhashableCacheError) as exc_info: + stub.load({}) + assert "f" in exc_info.value.variables + assert "cannot pickle" in exc_info.value.error_details + + def test_inherits_cancel_cell_parent(self) -> None: + """MarimoUnhashableCacheError funnels through the parent class + in the runner's classifier.""" + err = MarimoUnhashableCacheError( + cells_to_rerun=set(), variables=[], error_details="" + ) + assert isinstance(err, MarimoRescheduleError) + + def test_pickle_roundtrip(self) -> None: + original = UnhashableStub(None, var_name="f", error_msg="oops") + original.type_name = "builtins.function" + round_tripped = pickle.loads(pickle.dumps(original)) + assert round_tripped.var_name == "f" + assert round_tripped.type_name == "builtins.function" + assert round_tripped.error_msg == "oops" + + def test_type_name_param_overrides_derivation(self) -> None: + """An explicit `type_name` (used when rebuilding from a manifest + marker, with no original object) wins over `_obj` derivation.""" + stub = UnhashableStub(var_name="f", type_name="numpy.ndarray") + assert stub.type_name == "numpy.ndarray" + assert stub.var_name == "f" + + def test_from_item_reconstructs_marker(self) -> None: + """`from_item` rebuilds the tripwire in-memory from an Item carrying + `unserializable_type` — no blob is read off disk.""" + item = Item(unserializable_type="builtins.function") + stub = from_item(item, "f") + assert isinstance(stub, UnhashableStub) + assert stub.var_name == "f" + assert stub.type_name == "builtins.function" + + def test_isinstance_works(self) -> None: + """Tripwires must not interfere with isinstance — the restore + path uses isinstance to decide whether to install the marker.""" + stub = UnhashableStub(None, var_name="f") + assert isinstance(stub, UnhashableStub) + + def test_repr_does_not_trip(self) -> None: + """repr is safe to invoke for debugging / logging.""" + stub = UnhashableStub(None, var_name="f", error_msg="oops") + stub.type_name = "builtins.function" + text = repr(stub) + assert "UnhashableStub" in text + assert "f" in text + + +class TestUnhashableStubTripwire: + """`__call__` is the only tripwire (see UnhashableStub docstring). + + Other accesses deliberately fall through to Python defaults so + framework probes (`getattr(value, "_repr_mimebundle_", None)`, + `isinstance`, `hasattr`, storage-engine introspection, etc.) stay + inert and don't cancel innocent cells. + """ + + def _stub(self) -> UnhashableStub: + return UnhashableStub(None, var_name="x", error_msg="cannot pickle") + + def test_call_trips(self) -> None: + with pytest.raises(MarimoUnhashableCacheError) as ei: + self._stub()(42) + assert ei.value.variables == ["x"] + + def test_getattr_does_not_trip(self) -> None: + # Falls through to Python's default: a missing attribute raises + # AttributeError, not the cache tripwire. + with pytest.raises(AttributeError): + _ = self._stub().some_method + + def test_len_does_not_trip(self) -> None: + with pytest.raises(TypeError): + len(self._stub()) + + def test_iter_does_not_trip(self) -> None: + with pytest.raises(TypeError): + list(self._stub()) + + def test_internal_attrs_dont_trip(self) -> None: + """var_name / type_name / error_msg / load / to_bytes are + intentionally accessible — pickling and the runner's classifier + rely on them.""" + s = self._stub() + assert s.var_name == "x" + assert s.type_name == "" + assert s.error_msg == "cannot pickle" + # load() raises (by design); just confirm the method is reachable. + with pytest.raises(MarimoUnhashableCacheError): + s.load({}) + # to_bytes() pickles successfully. + assert s.to_bytes() == pickle.dumps(s) + + +# --------------------------------------------------------------------------- +# Cache.restore — total-restore semantics for UnhashableStub +# --------------------------------------------------------------------------- + + +class TestRestoreUnhashableMarker: + def test_top_level_marker_preserved(self) -> None: + """UnhashableStub at the top level of defs is placed in scope as-is, + not loaded (which would raise).""" + stub = UnhashableStub(None, var_name="f", error_msg="cannot pickle") + cache = Cache( + defs={"f": stub}, + hash="h", + cache_type="ExecutionPath", + stateful_refs=set(), + hit=True, + meta={}, + ) + scope: dict[str, object] = {} + cache.restore(scope) + assert isinstance(scope["f"], UnhashableStub) + + def test_nested_marker_in_container_preserved(self) -> None: + """UnhashableStub nested in tuple/list/dict survives the recursive + restore unchanged — the marker propagates instead of triggering + `.load()` (which raises).""" + stub = UnhashableStub(None, var_name="g", error_msg="cannot pickle") + cache = Cache( + defs={ + "in_tuple": (1, stub, 3), + "in_list": [stub, "x"], + "in_dict": {"k": stub}, + }, + hash="h", + cache_type="ExecutionPath", + stateful_refs=set(), + hit=True, + meta={}, + ) + scope: dict[str, object] = {} + cache.restore(scope) + assert scope["in_tuple"][1] is stub # type: ignore[index] + assert scope["in_list"][0] is stub # type: ignore[index] + assert scope["in_dict"]["k"] is stub # type: ignore[index] diff --git a/tests/_save/test_cache.py b/tests/_save/test_cache.py index fe90abc9c39..3958ba757de 100644 --- a/tests/_save/test_cache.py +++ b/tests/_save/test_cache.py @@ -1,15 +1,269 @@ -# Copyright 2024 Marimo. All rights reserved. +# Copyright 2026 Marimo. All rights reserved. from __future__ import annotations +import sys import textwrap +import warnings +from unittest.mock import patch import pytest +import marimo from marimo._ast.app import App -from marimo._runtime.requests import ExecutionRequest +from marimo._dependencies.dependencies import DependencyManager +from marimo._plugins.ui._impl.input import dropdown +from marimo._runtime.commands import ExecuteCellCommand from marimo._runtime.runtime import Kernel -from tests.conftest import ExecReqProvider +from marimo._save.cache import Cache +from marimo._save.stubs.module_stub import ModuleStub +from marimo._save.stubs.ui_element_stub import UIElementStub +from tests.conftest import ExecReqProvider, TestableModuleStub + + +class TestCache: + @staticmethod + def test_cache_basic_update() -> None: + cache = Cache( + defs={}, + hash="123", + cache_type="Pure", + stateful_refs=set(), + hit=True, + meta={}, + ) + scope = {} + ret = 1 + cache.update(scope, {"return": ret}) + assert cache.meta["return"] == ret + + @staticmethod + def test_cache_recursive_update() -> None: + cache = Cache( + defs={}, + hash="123", + cache_type="Pure", + stateful_refs=set(), + hit=True, + meta={}, + ) + scope = {} + ret = [] + ret.append(ret) + cache.update(scope, {"return": ret}) + + stored = cache.meta["return"] + assert isinstance(stored, list) + assert len(stored) == 1 + assert stored[0] is stored # Self-reference maintained + + @staticmethod + def test_cache_scope_recursive() -> None: + _list = [] + _list.append(_list) + d = {} + d["self"] = d + scope = { + "_list": _list, + "_dict": d, + } + cache = Cache( + defs=scope, + hash="123", + cache_type="Pure", + stateful_refs=set(), + hit=True, + meta={}, + ) + + # Force update to trigger stubbing. + cache.update(scope, {"return": None}) + assert "_list" in cache.defs + assert "_dict" in cache.defs + + @staticmethod + @patch("marimo._save._cache_module.ModuleStub", TestableModuleStub) + def test_cache_iterable() -> None: + scope = { + "_tuple": (1, 2, 3, marimo), + "_set": {1, 2, 3, marimo}, + } + cache = Cache( + defs=scope, + hash="123", + cache_type="Pure", + stateful_refs=set(), + hit=True, + meta={}, + ) + + # Force update to trigger stubbing. + cache.update(scope, {"return": None}) + assert "_tuple" in cache.defs + assert "_set" in cache.defs + assert isinstance(cache.defs["_tuple"][-1], ModuleStub) + assert TestableModuleStub(marimo) in cache.defs["_set"] + + cache.restore(scope) + assert marimo == cache.defs["_tuple"][-1] + assert marimo in cache.defs["_set"] + + @staticmethod + def test_cache_preserves_ref() -> None: + _set = {1, 2, 3, marimo} + _list = [1, 2, 3, _set] + _dict = {"_set": _set, "_list": _list} + scope = { + "_list": _list, + "_set": _set, + "_dict": _dict, + } + cache = Cache( + defs=scope, + hash="123", + cache_type="Pure", + stateful_refs=set(), + hit=True, + meta={}, + ) + + cache.update(scope, {"return": None}) + assert "_list" in cache.defs + assert "_dict" in cache.defs + assert "_set" in cache.defs + assert id(cache.defs["_set"]) == id(_set) + assert id(cache.defs["_list"]) == id(_list) + assert id(cache.defs["_dict"]) == id(_dict) + + @staticmethod + def test_cache_ui_element_update() -> None: + cache = Cache( + defs={}, + hash="123", + cache_type="Pure", + stateful_refs=set(), + hit=True, + meta={}, + ) + scope = {} + ret = dropdown(options=[1, 2, 3]) + cache.update(scope, {"return": ret}) + + stub = cache.meta["return"] + + assert isinstance(stub, UIElementStub) + + assert stub.load().options == ret.options + assert stub.load().value == ret.value + + @staticmethod + def test_cache_basic_restore() -> None: + cache = Cache( + defs={}, + hash="123", + cache_type="Pure", + stateful_refs=set(), + hit=True, + meta={"return": 42}, + ) + scope = {} + cache.restore(scope) + assert cache.meta["return"] == 42 + + @staticmethod + def test_cache_recursive_restore() -> None: + # Create a self-referential list + ret = [] + ret.append(ret) + + cache = Cache( + defs={}, + hash="123", + cache_type="Pure", + stateful_refs=set(), + hit=True, + meta={"return": ret}, + ) + scope = {} + cache.restore(scope) + + # After restoration, should maintain the self-reference + restored = cache.meta["return"] + assert isinstance(restored, list) + assert len(restored) == 1 + assert restored[0] is restored # Self-reference maintained + + @staticmethod + def test_cache_ui_element_restore() -> None: + # Create a UIElement and convert it to a stub + original_dropdown = dropdown(options=[1, 2, 3]) + stub = UIElementStub(original_dropdown) + + cache = Cache( + defs={}, + hash="123", + cache_type="Pure", + stateful_refs=set(), + hit=True, + meta={"return": stub}, + ) + scope = {} + cache.restore(scope) + + # After restoration, should have a new UIElement instance with same properties + restored = cache.meta["return"] + assert isinstance(restored, type(original_dropdown)) + assert restored is not original_dropdown # Different instance + assert restored.options == original_dropdown.options + assert restored.value == original_dropdown.value + + @staticmethod + def test_cache_nested_ui_element_restore() -> None: + # Create nested structure with UIElements + slider = dropdown(options=["a", "b", "c"]) + button = dropdown(options=[1, 2, 3]) + nested = { + "controls": [slider, button], + "primary": slider, + "secondary": button, + } + + # Convert to stubs + slider_stub = UIElementStub(slider) + button_stub = UIElementStub(button) + nested_with_stubs = { + "controls": [slider_stub, button_stub], + "primary": slider_stub, + "secondary": button_stub, + } + + cache = Cache( + defs={}, + hash="123", + cache_type="Pure", + stateful_refs=set(), + hit=True, + meta={"return": nested_with_stubs}, + ) + scope = {} + cache.restore(scope) + + # After restoration, should have new UIElement instances but preserve structure + restored = cache.meta["return"] + assert isinstance(restored, dict) + assert len(restored["controls"]) == 2 + + # Should be same instances within the structure (shared references preserved) + assert restored["primary"] is restored["controls"][0] + assert restored["secondary"] is restored["controls"][1] + + # But different from originals + assert restored["primary"] is not slider + assert restored["secondary"] is not button + + # Properties should match + assert restored["primary"].options == slider.options + assert restored["secondary"].options == button.options class TestScriptCache: @@ -22,28 +276,35 @@ def test_cache_miss() -> None: def one() -> tuple[int]: # Check top level import from marimo import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader with persistent_cache(name="one", _loader=MockLoader()) as cache: Y = 8 X = 7 assert X == 7 assert cache._cache.defs == {"X": 7, "Y": 8} - assert cache._loader._saved - assert not cache._loader._loaded + assert cache.loader._saved + assert not cache.loader._loaded return X, Y, persistent_cache - app.run() + # Coverage's trace override conflicts with cache introspection. Letting + # the first test fail seems to fix this issue. + # TODO: fix with_setup to properly manage both traces. + try: + app.run() + except Exception as e: + if "--cov=marimo" not in sys.argv: + raise + pytest.mark.xfail( + reason="Coverage conflict with cache introspection" + ) @staticmethod - def test_cache_hit() -> None: - app = App() - app._anonymous_file = True - + def test_cache_hit(app) -> None: @app.cell def one() -> tuple[int]: from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader with persistent_cache( name="one", _loader=MockLoader(data={"X": 7, "Y": 8}) @@ -52,34 +313,211 @@ def one() -> tuple[int]: X = 10 assert X == 7 assert cache._cache.defs == {"X": 7, "Y": 8} - assert not cache._loader._saved - assert cache._loader._loaded + assert not cache.loader._saved + assert cache.loader._loaded return X, Y, persistent_cache - app.run() - @staticmethod - def test_cache_hit_whitespace() -> None: - app = App() - app._anonymous_file = True + def test_cache_loader_api(app) -> None: + @app.cell + def one() -> tuple[int]: + from tests._save.loaders.mocks import MockLoader + + with MockLoader.cache("one", data={"X": 7, "Y": 8}) as cache: + Y = 9 + X = 10 + assert X == 7 + assert cache._cache.defs == {"X": 7, "Y": 8} + assert not cache.loader._saved + assert cache.loader._loaded + return X, Y + @staticmethod + def test_cache_hit_whitespace(app) -> None: @app.cell def one() -> tuple[int]: from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader # fmt: off - with persistent_cache(name="one", _loader=MockLoader(data={"X": 7, "Y": 8})) as cache: # noqa: E501 + with persistent_cache(name="one", + _loader=MockLoader( + data={"X": 7, "Y": 8}) + ) as cache: Y = 9 X = 10 # fmt: on assert X == 7 assert cache._cache.defs == {"X": 7, "Y": 8} - assert not cache._loader._saved - assert cache._loader._loaded + assert not cache.loader._saved + assert cache.loader._loaded return X, Y, persistent_cache - app.run() + @staticmethod + def test_cache_linebreak(app) -> None: + @app.cell + def one() -> tuple[int]: + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + _loader = MockLoader() + # fmt: off + # issues 3332, 2633 + with persistent_cache("one", _loader=_loader) as cache: + b = [ + 8 + ] + # fmt: on + assert b == [8] + assert cache._cache.defs == {"b": [8]} + + @staticmethod + def test_cache_if_block_and_break(app) -> None: + @app.cell + def one() -> tuple[int]: + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + _loader = MockLoader() + # fmt: off + b = [2] + if True: + with persistent_cache("if", _loader=_loader): # noqa: E111 + b = [ # noqa: E111 + 7 + ] + # fmt: on + assert b == [7] + + @staticmethod + def test_cache_if_block(app) -> None: + @app.cell + def one() -> tuple[int]: + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + _loader = MockLoader() + b = 2 + if True: + with persistent_cache("if", _loader=_loader): + b = 8 + assert b == 8 + + @staticmethod + def test_cache_else_block(app) -> None: + @app.cell + def one() -> tuple[int]: + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + _loader = MockLoader() + if False: + b = 2 + else: + with persistent_cache("else", _loader=_loader): + b = 8 + assert b == 8 + + @staticmethod + def test_cache_elif_block(app) -> None: + @app.cell + def one() -> tuple[int]: + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + _loader = MockLoader() + if False: + b = 2 + elif True: + with persistent_cache("else", _loader=_loader): + b = 8 + assert b == 8 + + @staticmethod + def test_cache_with_block(app) -> None: + @app.cell + def one() -> tuple[int]: + from contextlib import contextmanager + + @contextmanager + def called(v): + assert v + yield 1 + + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + _loader = MockLoader() + with called(True): + with persistent_cache("else", _loader=_loader): + b = 8 + assert b == 8 + + @staticmethod + def test_cache_with_block_inner(app) -> None: + @app.cell + def one() -> tuple[int]: + from contextlib import contextmanager + + @contextmanager + def called(v): + assert v + yield 1 + + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + _loader = MockLoader() + with persistent_cache("else", _loader=_loader): + with called(True): + b = 8 + assert b == 8 + + @staticmethod + def test_cache_same_line_fails() -> None: + from marimo._ast.transformers import BlockException + + app = App() + app._anonymous_file = True + + @app.cell + def one() -> tuple[int]: + def call(v): + assert v + + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + _loader = MockLoader() + # fmt: off + with persistent_cache("else", _loader=_loader): call(False) # noqa: E701 + # fmt: on + + with pytest.raises(BlockException): + app.run() + + @staticmethod + def test_cache_in_fn_fails() -> None: + from marimo._ast.transformers import BlockException + + app = App() + app._anonymous_file = True + + @app.cell + def one() -> tuple[int]: + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + _loader = MockLoader() + + def call(): + with persistent_cache("else", _loader=_loader): + return 1 + + call() + + with pytest.raises(BlockException): + app.run() class TestAppCache: @@ -87,12 +525,12 @@ async def test_cache_miss(self, any_kernel: Kernel) -> None: k = any_kernel await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="0", code=textwrap.dedent( """ from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader with persistent_cache( name="one", _loader=MockLoader() @@ -113,15 +551,16 @@ async def test_cache_hit(self, any_kernel: Kernel) -> None: k = any_kernel await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="0", code=textwrap.dedent( """ from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader with persistent_cache( - name="one", _loader=MockLoader(data={"X": 7, "Y": 8}) + name="one", + _loader=MockLoader(data={"X": 7, "Y": 8}, strict=True) ) as cache: Y = 9 X = 10 @@ -135,16 +574,179 @@ async def test_cache_hit(self, any_kernel: Kernel) -> None: assert k.globals["Y"] == 8 assert k.globals["Z"] == 3 + async def test_cache_module_hit(self, any_kernel: Kernel) -> None: + k = any_kernel + await k.run( + [ + ExecuteCellCommand( + cell_id="0", + code=textwrap.dedent( + """ + import marimo as mod + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + def my_func_2(): + return 2 + + with persistent_cache( + name="one", + _loader=MockLoader(data={"mo": mod}, strict=True), + ) as cache: + import numpy as mo + """ + ), + ), + ] + ) + assert not k.stderr.messages, k.stderr + assert not k.stdout.messages, k.stdout + assert k.globals["mo"].__version__ == marimo.__version__ + + async def test_cache_module_miss(self, any_kernel: Kernel) -> None: + k = any_kernel + await k.run( + [ + ExecuteCellCommand( + cell_id="0", + code=textwrap.dedent( + """ + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + with persistent_cache( + name="one", _loader=MockLoader() + ) as cache: + import marimo as mo + """ + ), + ), + ] + ) + # No warning messages. + assert k.errors == {} + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, k.stderr + assert k.globals["mo"].__version__ == marimo.__version__ + + async def test_cache_function_hit(self, any_kernel: Kernel) -> None: + k = any_kernel + await k.run( + [ + ExecuteCellCommand( + cell_id="0", + code=textwrap.dedent( + """ + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + def my_func_2(): + return 2 + + loader = MockLoader(data={"my_func": my_func_2}, strict=True) + with persistent_cache(name="one", _loader=loader) as cache: + def my_func(): + return 1 + """ + ), + ), + ] + ) + assert "cache" in k.globals + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, (k.stderr, k.stdout) + assert k.globals["my_func"]() == 2 + + async def test_cache_function_miss(self, any_kernel: Kernel) -> None: + k = any_kernel + await k.run( + [ + ExecuteCellCommand( + cell_id="0", + code=textwrap.dedent( + """ + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + with persistent_cache( + name="one", _loader=MockLoader(), + ) as cache: + def my_func(): + return 1 + """ + ), + ), + ] + ) + # No warning messages. + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, k.stderr + assert k.globals["my_func"]() == 1 + + async def test_cache_ui_hit(self, any_kernel: Kernel) -> None: + k = any_kernel + await k.run( + [ + ExecuteCellCommand( + cell_id="0", + code=textwrap.dedent( + """ + import marimo as mo + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + loaded_slider = mo.ui.slider(20, 30) + + with persistent_cache( + name="one", + _loader=MockLoader(data={"slider": loaded_slider}, strict=True), + ) as cache: + slider = mo.ui.slider(0, 1) + """ + ), + ), + ] + ) + # No warning messages. + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, k.stderr + assert k.globals["slider"].value == 20 + + async def test_cache_ui_miss(self, any_kernel: Kernel) -> None: + k = any_kernel + await k.run( + [ + ExecuteCellCommand( + cell_id="0", + code=textwrap.dedent( + """ + import marimo as mo + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + with persistent_cache( + name="one", _loader=MockLoader() + ) as cache: + slider = mo.ui.slider(0, 1) + """ + ), + ), + ] + ) + # No warning messages. + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, k.stderr + assert k.globals["slider"].value == 0 + async def test_cache_one_line(self, any_kernel: Kernel) -> None: k = any_kernel await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="0", code=textwrap.dedent( """ from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader with persistent_cache(name="one", _loader=MockLoader(data={"X": 1})): X = 1 @@ -155,7 +757,6 @@ async def test_cache_one_line(self, any_kernel: Kernel) -> None: ] ) assert k.errors == {} - assert k.errors == {} assert k.globals["X"] == 1 assert k.globals["Y"] == 2 @@ -163,12 +764,12 @@ async def test_cache_comment_line(self, any_kernel: Kernel) -> None: k = any_kernel await k.run( [ - ExecutionRequest( + ExecuteCellCommand( cell_id="0", code=textwrap.dedent( """ from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader with persistent_cache(name="one", _loader=MockLoader()): # Comment @@ -181,6 +782,7 @@ async def test_cache_comment_line(self, any_kernel: Kernel) -> None: ), ] ) + assert not k.stderr.messages, k.stderr assert k.errors == {} assert k.errors == {} assert k.globals["X"] == 1 @@ -208,7 +810,7 @@ def __eq__(self, other): exec_req.get( """ from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader with persistent_cache( name="cache", _loader=MockLoader() @@ -238,7 +840,7 @@ async def test_set_state_hits_cache( exec_req.get( """ from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader with persistent_cache( name="cache", _loader=MockLoader(), @@ -271,7 +873,7 @@ async def test_set_state_invalidates( exec_req.get( """ from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader with persistent_cache( name="cache", _loader=MockLoader() @@ -306,7 +908,7 @@ async def test_set_state_loads( exec_req.get( """ from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader with persistent_cache( name="cache", @@ -331,16 +933,16 @@ async def test_set_state_loads( class TestCacheDecorator: - async def test_basic_cache( + async def test_basic_cache_api( self, k: Kernel, exec_req: ExecReqProvider ) -> None: await k.run( [ exec_req.get( """ - from marimo._save.save import cache + from marimo._save.loaders import MemoryLoader - @cache + @MemoryLoader.cache def fib(n): if n <= 1: return n @@ -359,16 +961,16 @@ def fib(n): assert k.globals["a"] == 5 assert k.globals["b"] == 55 - async def test_lru_cache( + async def test_basic_cache_api_with_arg( self, k: Kernel, exec_req: ExecReqProvider ) -> None: await k.run( [ exec_req.get( """ - from marimo._save.save import lru_cache + from marimo._save.loaders import MemoryLoader - @lru_cache(maxsize=2) + @MemoryLoader.cache(max_size=2) def fib(n): if n <= 1: return n @@ -381,31 +983,31 @@ def fib(n): ] ) - assert not len(k.stderr.messages) - # More hits with a smaller cache, because it needs to check the cache - # more. + assert not k.stderr.messages assert k.globals["fib"].hits == 14 assert k.globals["a"] == 5 assert k.globals["b"] == 55 - async def test_cross_cell_cache( + async def test_basic_cache( self, k: Kernel, exec_req: ExecReqProvider ) -> None: await k.run( [ - exec_req.get("""from marimo._save.save import cache"""), exec_req.get( """ + from marimo._save.save import cache + @cache def fib(n): if n <= 1: return n return fib(n - 1) + fib(n - 2) + + a = fib(5) + b = fib(10) """ ), - exec_req.get("""a=fib(5)"""), - exec_req.get("""b=fib(10); a"""), ] ) @@ -415,134 +1017,186 @@ def fib(n): assert k.globals["a"] == 5 assert k.globals["b"] == 55 - async def test_cross_cell_cache_with_external( + async def test_lru_cache( self, k: Kernel, exec_req: ExecReqProvider ) -> None: await k.run( [ - exec_req.get("""from marimo._save.save import cache"""), - exec_req.get("""external = 0"""), exec_req.get( """ - @cache + from marimo._save.save import lru_cache + + @lru_cache(maxsize=2) def fib(n): if n <= 1: - return n + external + return n return fib(n - 1) + fib(n - 2) + + a = fib(5) + b = fib(10) """ ), - exec_req.get("""a = fib(5)"""), - exec_req.get("""b = fib(10); a"""), ] ) - assert not k.stderr.messages + assert not k.stderr.messages, k.stderr + # More hits with a smaller cache, because it needs to check the cache + # more. + assert k.globals["fib"].hits == 14 assert k.globals["a"] == 5 assert k.globals["b"] == 55 - assert k.globals["fib"].hits == 9 - async def test_cross_cell_cache_with_external_state( + async def test_lru_cache_default( self, k: Kernel, exec_req: ExecReqProvider ) -> None: await k.run( [ exec_req.get( """ - from marimo._save.save import cache - from marimo._runtime.state import state - """ - ), - exec_req.get("""external, setter = state(0)"""), - exec_req.get( - """ - @cache + from marimo._save.save import lru_cache + + @lru_cache def fib(n): if n <= 1: - return n + external() + return n return fib(n - 1) + fib(n - 2) - """ - ), - exec_req.get("""impure = []"""), - exec_req.get("""a = fib(5)"""), - exec_req.get("""b = fib(10); a"""), - exec_req.get( - """ - c = a + b - if len(impure) == 0: - setter(1) - elif len(impure) == 1: - setter(0) - impure.append(c) - """ + + a = fib(260) + b = fib(10) + """ ), ] ) - assert not k.stdout.messages assert not k.stderr.messages + # More hits with a smaller cache, because it needs to check the cache + # more. Has 256 entries by default, normal cache hits just 259 times. + assert k.globals["fib"].hits == 266 - assert k.globals["a"] == 5 + # A little ridiculous, but still low compute. + assert ( + k.globals["a"] + == 971183874599339129547649988289594072811608739584170445 + ) assert k.globals["b"] == 55 - assert k.globals["impure"] == [60, 157, 60] - # Cache hit value may be flaky depending on when state is evicted from - # the registry. The actual cache hit is less important than caching - # occurring in the first place. - # 2 * 9 + 2 - assert k.globals["fib"].hits in (9, 18, 20) - async def test_cross_cell_cache_with_external_ui( + async def test_lru_cache_with_maxsize_persists_across_cell_reruns( self, k: Kernel, exec_req: ExecReqProvider ) -> None: + cached_cell = exec_req.get( + """ + @lru_cache(maxsize=128) + def foo(): + print("ran") + + foo() + foo() + """ + ) + await k.run( [ exec_req.get( """ - from marimo._save.save import cache - from marimo._runtime.state import state - import marimo as mo - """ + from marimo._save.save import lru_cache + """ ), - exec_req.get("slider = mo.ui.slider(0, 1)"), - exec_req.get("""external, setter = state("a")"""), + cached_cell, + ] + ) + await k.run([cached_cell]) + + assert not k.stderr.messages, k.stderr + assert k.stdout.messages.count("ran") == 1 + assert k.globals["foo"].hits == 3 + + async def test_lru_cache_underscore_alias( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + # Regression: `import marimo as _private` (underscore alias) used to + # blow up cache hashing because mangled scope keys (`_cell___private`) + # don't match the unmangled-ref lookup used during the cache attempt. + await k.run( + [ exec_req.get( """ - external # To force rerun + import marimo as _private - @cache - def fib(n): - if n <= 1: - return n + slider.value - return fib(n - 1) + fib(n - 2) + @_private.lru_cache(maxsize=128) + def f(x): + return x * 2 + + a = f(1) + b = f(1) + c = f(2) """ ), - exec_req.get("""impure = []"""), - exec_req.get("""a = fib(5)"""), - exec_req.get("""b = fib(10); a"""), + ] + ) + + assert not k.stderr.messages, k.stderr + assert k.globals["a"] == 2 + assert k.globals["b"] == 2 + assert k.globals["c"] == 4 + # Second `f(1)` should hit the cache. + assert k.globals["f"].hits == 1 + + async def test_cache_underscore_alias( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + await k.run( + [ exec_req.get( """ - c = a + b - if len(impure) == 0: - setter("b") - slider._update(1) - elif len(impure) == 1: - setter("c") - slider._update(0) - impure.append(c) + import marimo as _private + + @_private.cache + def f(x): + return x * 3 + + a = f(1) + b = f(1) + """ + ), + ] + ) + + assert not k.stderr.messages, k.stderr + assert k.globals["a"] == 3 + assert k.globals["b"] == 3 + assert k.globals["f"].hits == 1 + + async def test_persistent_cache( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + await k.run( + [ + exec_req.get( """ + from marimo._save.save import persistent_cache + from marimo._save.loaders import MemoryLoader + + @persistent_cache(_loader=MemoryLoader) + def fib(n): + if n <= 1: + return n + return fib(n - 1) + fib(n - 2) + + a = fib(5) + b = fib(10) + """ ), ] ) assert not k.stderr.messages + assert k.globals["fib"].hits == 9 assert k.globals["a"] == 5 assert k.globals["b"] == 55 - assert k.globals["impure"] == [60, 157, 60] - # 2 * 9 + 2 - assert k.globals["fib"].hits in (9, 18, 20) - async def test_full_scope_utilized( + async def test_cache_decorator_with_kwargs( self, k: Kernel, exec_req: ExecReqProvider ) -> None: await k.run( @@ -550,191 +1204,1346 @@ async def test_full_scope_utilized( exec_req.get( """ from marimo._save.save import cache - d = 0 # Check shadowing + + @cache + def my_cached_func(*args, **kwargs): + return sum(args) + sum(kwargs.values()) + + # First call with specific kwargs + result1 = my_cached_func(1, 2, some_kw_arg=3) + hash1 = my_cached_func._last_hash """ ), - exec_req.get("""impure = []"""), exec_req.get( """ - _a = 0 - def _b(): - _c = 2 - @cache - def d(): - return _a + _c - return d - _e = _b() - impure.append([_e, _e()]) + # Second call with different kwargs - should be cache miss + result2 = my_cached_func(1, 2, some_kw_arg=4) + hash2 = my_cached_func._last_hash """ ), exec_req.get( - repeated := """ - _a = 0 - def _b(): - _c = 1 - @cache - def d(): - return _a + _c - return d - _e = _b() - impure.append([_e, _e()]) + """ + # Third call with same kwargs as first - should be cache hit + result3 = my_cached_func(1, 2, some_kw_arg=3) + hash3 = my_cached_func._last_hash """ ), - exec_req.get(repeated), ] ) - assert not k.stderr.messages, k.stderr - assert not k.stdout.messages, k.stdout - - assert len(k.globals["impure"]) == 3 - assert { - k.globals["impure"][0][1], - k.globals["impure"][1][1], - k.globals["impure"][2][1], - } == {2, 1} + # Verify results + assert k.globals["result1"] == 6 # 1 + 2 + 3 + assert k.globals["result2"] == 7 # 1 + 2 + 4 + assert k.globals["result3"] == 6 # 1 + 2 + 3 - # Same name, but should be under different entries - assert ( - k.globals["impure"][0][0]._loader() - is not k.globals["impure"][1][0]._loader() - ) + # Verify cache keys + hash1 = k.globals["hash1"] + hash2 = k.globals["hash2"] + hash3 = k.globals["hash3"] - assert ( - len( - { - *k.globals["impure"][0][0]._loader()._cache.keys(), - *k.globals["impure"][1][0]._loader()._cache.keys(), - *k.globals["impure"][2][0]._loader()._cache.keys(), - } - ) - == 2 + assert hash1 != hash2, "Cache key should change when kwargs change" + assert hash1 == hash3, ( + "Cache key should be same for identical args/kwargs" ) - # No cache hits - assert { - k.globals["impure"][0][0].hits, - k.globals["impure"][1][0].hits, - k.globals["impure"][2][0].hits, - } == {0} + # Verify cache hits + assert k.globals["my_cached_func"].hits == 1 - async def test_full_scope_utilized_lru_cache( + async def test_cache_decorator_kwargs_expansion( self, k: Kernel, exec_req: ExecReqProvider ) -> None: await k.run( [ exec_req.get( """ - from marimo._save.save import lru_cache - d = 0 # Check shadowing + from marimo._save.save import cache + + @cache + def my_cached_func(*args, **kwargs): + return sum(args) + sum(kwargs.values()) + + # Test with kwargs expansion + _kw = {"some_kw_arg": 1} + result1 = my_cached_func(1, 2, **_kw) + hash1 = my_cached_func._last_hash """ ), - exec_req.get("""impure = []"""), exec_req.get( """ - _a = 0 - def _b(): - _c = 2 - @lru_cache - def d(): - return _a + _c - return d - _e = _b() - impure.append([_e, _e()]) + # Test with different kwargs expansion + _kw = {"some_kw_arg": 2} + result2 = my_cached_func(1, 2, **_kw) + hash2 = my_cached_func._last_hash """ ), exec_req.get( - repeated := """ - _a = 0 - def _b(): - _c = 1 - @lru_cache - def d(): - return _a + _c - return d - _e = _b() - impure.append([_e, _e()]) + """ + # Test with same kwargs expansion - should hit cache + _kw = {"some_kw_arg": 1} + result3 = my_cached_func(1, 2, **_kw) + hash3 = my_cached_func._last_hash """ ), - exec_req.get(repeated), ] ) - assert not k.stderr.messages, k.stderr - assert not k.stdout.messages, k.stdout + # Verify results + assert k.globals["result1"] == 4 # 1 + 2 + 1 + assert k.globals["result2"] == 5 # 1 + 2 + 2 + assert k.globals["result3"] == 4 # 1 + 2 + 1 - assert len(k.globals["impure"]) == 3 - assert { - k.globals["impure"][0][1], - k.globals["impure"][1][1], - k.globals["impure"][2][1], - } == {2, 1} + # Verify cache keys + hash1 = k.globals["hash1"] + hash2 = k.globals["hash2"] + hash3 = k.globals["hash3"] - # Same name, but should be under different entries - assert ( - k.globals["impure"][0][0]._loader() - is not k.globals["impure"][1][0]._loader() + assert hash1 != hash2, ( + "Cache key should change when kwargs expansion changes" ) - - assert ( - len( - { - *k.globals["impure"][0][0]._loader()._cache.keys(), - *k.globals["impure"][1][0]._loader()._cache.keys(), - *k.globals["impure"][2][0]._loader()._cache.keys(), - } - ) - == 2 + assert hash1 == hash3, ( + "Cache key should be same for identical kwargs expansion" ) - # No cache hits - assert { - k.globals["impure"][0][0].hits, - k.globals["impure"][1][0].hits, - k.globals["impure"][2][0].hits, - } == {0} + # Verify cache hits + assert k.globals["my_cached_func"].hits == 1 - @staticmethod - def test_object_content_hash() -> None: - app = App() - app._anonymous_file = True + async def test_cache_decorator_varargs( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + await k.run( + [ + exec_req.get( + """ + from marimo._save.save import cache - @app.cell - def __(): + @cache + def my_cached_func(*args, **kwargs): + return sum(args) + sum(kwargs.values()) + + # Test with different varargs + result1 = my_cached_func(1, 2, 3) + hash1 = my_cached_func._last_hash + """ + ), + exec_req.get( + """ + # Test with different varargs - should be cache miss + result2 = my_cached_func(1, 2, 4) + hash2 = my_cached_func._last_hash + """ + ), + exec_req.get( + """ + # Test with same varargs - should hit cache + result3 = my_cached_func(1, 2, 3) + hash3 = my_cached_func._last_hash + """ + ), + ] + ) + + # Verify results + assert k.globals["result1"] == 6 # 1 + 2 + 3 + assert k.globals["result2"] == 7 # 1 + 2 + 4 + assert k.globals["result3"] == 6 # 1 + 2 + 3 + + # Verify cache keys + hash1 = k.globals["hash1"] + hash2 = k.globals["hash2"] + hash3 = k.globals["hash3"] + + assert hash1 != hash2, "Cache key should change when varargs change" + assert hash1 == hash3, "Cache key should be same for identical varargs" + + # Verify cache hits + assert k.globals["my_cached_func"].hits == 1 + + async def test_cache_decorator_varargs_expansion( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + await k.run( + [ + exec_req.get( + """ + from marimo._save.save import cache + + @cache + def my_cached_func(*args, **kwargs): + return sum(args) + sum(kwargs.values()) + + # Test with varargs expansion + _args = [1, 2, 3] + result1 = my_cached_func(*_args) + hash1 = my_cached_func._last_hash + """ + ), + exec_req.get( + """ + # Test with different varargs expansion + _args = [1, 2, 4] + result2 = my_cached_func(*_args) + hash2 = my_cached_func._last_hash + """ + ), + exec_req.get( + """ + # Test with same varargs expansion - should hit cache + _args = [1, 2, 3] + result3 = my_cached_func(*_args) + hash3 = my_cached_func._last_hash + """ + ), + ] + ) + + # Verify results + assert k.globals["result1"] == 6 # 1 + 2 + 3 + assert k.globals["result2"] == 7 # 1 + 2 + 4 + assert k.globals["result3"] == 6 # 1 + 2 + 3 + + # Verify cache keys + hash1 = k.globals["hash1"] + hash2 = k.globals["hash2"] + hash3 = k.globals["hash3"] + + assert hash1 != hash2, ( + "Cache key should change when varargs expansion changes" + ) + assert hash1 == hash3, ( + "Cache key should be same for identical varargs expansion" + ) + + # Verify cache hits + assert k.globals["my_cached_func"].hits == 1 + + async def test_cache_decorator_varargs_count( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + await k.run( + [ + exec_req.get( + """ + from marimo._save.save import cache + + @cache + def my_cached_func(*args, **kwargs): + return sum(args) + sum(kwargs.values()) + + _args1 = [1, 2, 3] + result1 = my_cached_func(*_args1) + hash1 = my_cached_func._last_hash + """ + ), + exec_req.get( + """ + _args2 = [1, 2] + result2 = my_cached_func(*_args2) + hash2 = my_cached_func._last_hash + """ + ), + exec_req.get( + """ + _args1 = [1, 2, 3, 4] + result3 = my_cached_func(*_args1) + hash3 = my_cached_func._last_hash + """ + ), + ] + ) + + # Verify results + assert k.globals["result1"] == 6 # 1 + 2 + 3 + assert k.globals["result2"] == 3 # 1 + 2 + assert k.globals["result3"] == 10 # 1 + 2 + 3 + 4 + + # Verify cache keys + hash1 = k.globals["hash1"] + hash2 = k.globals["hash2"] + hash3 = k.globals["hash3"] + + # The cache key should be the same for identical values regardless of variable name + assert len({hash1, hash2, hash3}) == 3, ( + "Cache key should be same for identical values with different variable names" + ) + + # Verify cache hits + assert k.globals["my_cached_func"].hits == 0 + + async def test_persistent_cache_decorator_mixed_signature( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + # Sanity check that the same code path is captured. + await k.run( + [ + exec_req.get( + """ + from marimo._save.save import persistent_cache + from marimo._save.loaders import MemoryLoader + + @persistent_cache(_loader=MemoryLoader) + def mixed_func(arg, *vargs, kw=None, **kwargs): + return arg + sum(vargs) + (kw or 0) + sum(kwargs.values()) + + # Test with mixed arguments + result1 = mixed_func(1, 2, 3, kw=4, extra=5) + hash1 = mixed_func._last_hash + """ + ), + exec_req.get( + """ + # Test with different mixed arguments - should be cache miss + result2 = mixed_func(1, 2, 3, kw=4, extra=7) + hash2 = mixed_func._last_hash + """ + ), + exec_req.get( + """ + # Test with same mixed arguments - should hit cache + result3 = mixed_func(1, 2, 3, kw=4, extra=5) + hash3 = mixed_func._last_hash + """ + ), + ] + ) + + # Verify results + assert k.globals["result1"] == 15 # 1 + 2 + 3 + 4 + 5 + assert k.globals["result2"] == 17 # 1 + 2 + 3 + 4 + 7 + assert k.globals["result3"] == 15 # 1 + 2 + 3 + 4 + 5 + + # Verify cache keys + hash1 = k.globals["hash1"] + hash2 = k.globals["hash2"] + hash3 = k.globals["hash3"] + + assert hash1 != hash2, "Cache key should change when kwargs change" + assert hash1 == hash3, ( + "Cache key should be same for identical mixed arguments" + ) + + # Verify cache hits + assert k.globals["mixed_func"].hits == 1 + + async def test_cache_decorator_mixed_signature( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + await k.run( + [ + exec_req.get( + """ + from marimo._save.save import cache + + @cache + def mixed_func(arg, *vargs, kw=None, **kwargs): + return arg + sum(vargs) + (kw or 0) + sum(kwargs.values()) + + # Test with mixed arguments + result1 = mixed_func(1, 2, 3, kw=4, extra=5) + hash1 = mixed_func._last_hash + """ + ), + exec_req.get( + """ + # Test with different mixed arguments - should be cache miss + result2 = mixed_func(1, 2, 3, kw=4, extra=7) + hash2 = mixed_func._last_hash + """ + ), + exec_req.get( + """ + # Test with same mixed arguments - should hit cache + result3 = mixed_func(1, 2, 3, kw=4, extra=5) + hash3 = mixed_func._last_hash + """ + ), + ] + ) + + # Verify results + assert k.globals["result1"] == 15 # 1 + 2 + 3 + 4 + 5 + assert k.globals["result2"] == 17 # 1 + 2 + 3 + 4 + 7 + assert k.globals["result3"] == 15 # 1 + 2 + 3 + 4 + 5 + + # Verify cache keys + hash1 = k.globals["hash1"] + hash2 = k.globals["hash2"] + hash3 = k.globals["hash3"] + + assert hash1 != hash2, "Cache key should change when kwargs change" + assert hash1 == hash3, ( + "Cache key should be same for identical mixed arguments" + ) + + # Verify cache hits + assert k.globals["mixed_func"].hits == 1 + + async def test_cache_decorator_positional_only( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + await k.run( + [ + exec_req.get( + """ + from marimo._save.save import cache + + @cache + def pos_only_func(pos1, pos2, /, kw1=None, *, kwonly): + return pos1 + pos2 + (kw1 or 0) + kwonly + + # Test with positional-only arguments + result1 = pos_only_func(1, 2, kw1=3, kwonly=4) + hash1 = pos_only_func._last_hash + """ + ), + exec_req.get( + """ + # Test with different positional arguments - should be cache miss + result2 = pos_only_func(1, 3, kw1=3, kwonly=4) + hash2 = pos_only_func._last_hash + """ + ), + exec_req.get( + """ + # Test with same arguments - should hit cache + result3 = pos_only_func(1, 2, kw1=3, kwonly=4) + hash3 = pos_only_func._last_hash + """ + ), + ] + ) + + # Verify results + assert k.globals["result1"] == 10 # 1 + 2 + 3 + 4 + assert k.globals["result2"] == 11 # 1 + 3 + 3 + 4 + assert k.globals["result3"] == 10 # 1 + 2 + 3 + 4 + + # Verify cache keys + hash1 = k.globals["hash1"] + hash2 = k.globals["hash2"] + hash3 = k.globals["hash3"] + + assert hash1 != hash2, ( + "Cache key should change when positional arguments change" + ) + assert hash1 == hash3, ( + "Cache key should be same for identical arguments" + ) + + # Verify cache hits + assert k.globals["pos_only_func"].hits == 1 + + async def test_cache_decorator_method_wrap( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + await k.run( + [ + exec_req.get( + """ + from marimo._save.save import cache + + class MyClass: + def __init__(self, x): + self.x = x + @cache + def method(self, y): + return self.x + y + + case_a = MyClass(0).method + case_b = MyClass(1).method + case_c = MyClass(1).method + result1 = case_a(2) + hash1 = case_a._last_hash + result2 = case_b(2) + hash2 = case_b._last_hash + result3 = case_c(2) + hash3 = case_c._last_hash + + base_hash = MyClass.method._last_hash + + obj = MyClass(0) + # __get__ is called in both places. + # Sanity check the functor returned. + get_method_0 = obj.method + get_method_1 = obj.method + """ + ), + ] + ) + + assert not k.stdout.messages, k.stdout.messages + assert not k.stderr.messages, k.stderr.messages + + # Verify results + assert k.globals["result1"] == 2 # 0 + 2 + assert k.globals["result2"] == 3 # 1 + 2 + assert k.globals["result3"] == 3 # 1 + 2 + assert k.globals["hash1"] != k.globals["hash2"] + assert k.globals["hash2"] == k.globals["hash3"] + # Since self.loader is shared, the lookup dict is shared. + assert k.globals["case_a"].hits == 1 + assert k.globals["case_b"].hits == 1 + assert k.globals["case_c"].hits == 1 + assert k.globals["base_hash"] is None + + # Not per say correct or incorrect; but known, documented behavior. + assert k.globals["get_method_0"] != k.globals["get_method_1"] + + async def test_cache_static_decorator_method_wrap( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + await k.run( + [ + exec_req.get( + """ + from marimo._save.save import cache + + class MyClass: + + @staticmethod + @cache + def static_method(x, y): + return x + y + + + case_a = MyClass().static_method + case_b = MyClass().static_method + case_c = MyClass.static_method + result1 = case_a(1, 2) + hash1 = case_a._last_hash + result2 = case_b(2, 1) + hash2 = case_b._last_hash + result3 = case_c(1, 2) + hash3 = case_c._last_hash + base_hash = MyClass.static_method._last_hash + """ + ), + ] + ) + assert not k.stdout.messages, k.stdout.messages + assert not k.stderr.messages, k.stderr.messages + + # Verify results + assert k.globals["result1"] == 3 + assert k.globals["result2"] == 3 + assert k.globals["result3"] == 3 + assert k.globals["hash1"] != k.globals["hash2"] + assert k.globals["hash1"] == k.globals["hash3"] + assert k.globals["base_hash"] is not None + + async def test_cache_class_decorator_method_wrap( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + await k.run( + [ + exec_req.get( + """ + from marimo._save.save import cache + + class MyClass: + @classmethod + @cache + def class_method(cls, x, y): + return x + y + case_a = MyClass().class_method + case_b = MyClass().class_method + case_c = MyClass.class_method + result1 = case_a(1, 2) + hash1 = case_a._last_hash + result2 = case_b(2, 1) + hash2 = case_b._last_hash + result3 = case_c(1, 2) + hash3 = case_c._last_hash + base_hash = MyClass.class_method._last_hash + """ + ), + ] + ) + assert not k.stdout.messages, k.stdout.messages + assert not k.stderr.messages, k.stderr.messages + + # Verify results + assert k.globals["result1"] == 3 + assert k.globals["result2"] == 3 + assert k.globals["result3"] == 3 + assert k.globals["hash1"] != k.globals["hash2"] + assert k.globals["hash1"] == k.globals["hash3"] + assert k.globals["case_c"]._last_hash is not None + + # NB. base_hash has different behavior than the others on python 3.13+ + # 3.13 has base_hash == hash1, while <3.13 has base_hash != None + if sys.version_info >= (3, 13): + assert k.globals["base_hash"] == k.globals["hash1"] + else: + assert k.globals["base_hash"] is None + + async def test_cross_cell_cache( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + await k.run( + [ + exec_req.get("""from marimo._save.save import cache"""), + exec_req.get( + """ + @cache + def fib(n): + if n <= 1: + return n + return fib(n - 1) + fib(n - 2) + """ + ), + exec_req.get("""a=fib(5)"""), + exec_req.get("""b=fib(10); a"""), + ] + ) + + assert not k.stderr.messages + assert k.globals["fib"].hits == 9 + + assert k.globals["a"] == 5 + assert k.globals["b"] == 55 + + async def test_cross_cell_cache_with_external( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + await k.run( + [ + exec_req.get("""from marimo._save.save import cache"""), + exec_req.get("""external = 0"""), + exec_req.get( + """ + @cache + def fib(n): + if n <= 1: + return n + external + return fib(n - 1) + fib(n - 2) + """ + ), + exec_req.get("""a = fib(5)"""), + exec_req.get("""b = fib(10); a"""), + ] + ) + + assert not k.stderr.messages + + assert k.globals["a"] == 5 + assert k.globals["b"] == 55 + assert k.globals["fib"].hits == 9 + + async def test_cross_cell_cache_with_external_state( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + await k.run( + [ + exec_req.get( + """ + from marimo._save.save import cache + from marimo._runtime.state import state + """ + ), + exec_req.get("""external, setter = state(0)"""), + exec_req.get( + """ + @cache + def fib(n): + if n <= 1: + return n + external() + return fib(n - 1) + fib(n - 2) + """ + ), + exec_req.get("""impure = []"""), + exec_req.get("""a = fib(5)"""), + exec_req.get("""b = fib(10); a"""), + exec_req.get( + """ + c = a + b + if len(impure) == 0: + setter(1) + elif len(impure) == 1: + setter(0) + impure.append(c) + """ + ), + ] + ) + + assert not k.stderr.messages + + assert k.globals["a"] == 5 + assert k.globals["b"] == 55 + assert k.globals["impure"] == [60, 157, 60] + # Cache hit value may be flaky depending on when state is evicted from + # the registry. The actual cache hit is less important than caching + # occurring in the first place. + # NB. 20 = 2 * 9 + 2 + if k.globals["fib"].hits in (9, 18): + warnings.warn( + "Known flaky edge case for cache with state dep.", stacklevel=1 + ) + else: + assert k.globals["fib"].hits == 20 + + async def test_cross_cell_cache_with_external_ui( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + await k.run( + [ + exec_req.get( + """ + from marimo._save.save import cache + from marimo._runtime.state import state + import marimo as mo + """ + ), + exec_req.get("slider = mo.ui.slider(0, 1)"), + exec_req.get("""external, setter = state("a")"""), + exec_req.get( + """ + external # To force rerun + + @cache + def fib(n): + if n <= 1: + return n + slider.value + return fib(n - 1) + fib(n - 2) + """ + ), + exec_req.get("""impure = []"""), + exec_req.get("""a = fib(5)"""), + exec_req.get("""b = fib(10); a"""), + exec_req.get( + """ + c = a + b + if len(impure) == 0: + setter("b") + slider._update(1) + elif len(impure) == 1: + setter("c") + slider._update(0) + impure.append(c) + """ + ), + ] + ) + + assert not k.stderr.messages + + assert k.globals["a"] == 5 + assert k.globals["b"] == 55 + assert k.globals["impure"] == [60, 157, 60] + + # 2 * 9 + 2 + if k.globals["fib"].hits in (9, 18): + warnings.warn("Known flaky edge case for cache.", stacklevel=1) + else: + assert k.globals["fib"].hits == 20 + + async def test_rerun_update( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + await k.run( + [ + exec_req.get("""from marimo._save.save import cache"""), + exec_req.get( + """ + from marimo._runtime.state import state + """ + ), + exec_req.get("""max_size, setter = state(-1)"""), + exec_req.get( + """from marimo._save.loaders import MemoryLoader""" + ), + exec_req.get("""impure = []"""), + exec_req.get("""external = 0;c={}"""), + exec_req.get_with_id( + cell_id="0", + code=""" + @cache(loader=MemoryLoader.partial(max_size=max_size())) + def fib(n): + if n <= 1: + return n + external + return fib(n - 1) + fib(n - 2) + fib(5) + """, + ), + exec_req.get("""a = fib(5)"""), + exec_req.get("""b = fib(10); a; impure.append(b)"""), + exec_req.get( + """ + if len(impure) == 1: + setter(256) + b + """ + ), + ] + ) + assert not k.stderr.messages, k.stderr + assert not k.stdout.messages, k.stdout + # Throw a warning for flake edge case where cache is evicted earlier + # than expected. + if k.globals["fib"].hits in (10,): + warnings.warn( + "Known flaky edge case for cache rerun.", stacklevel=1 + ) + else: + assert k.globals["fib"].hits == 10 + 3 + + async def test_full_scope_utilized( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + # This is not completely obvious, but @cache needs to know what frame it + # is on so it can get locals or globals. + await k.run( + [ + exec_req.get( + """ + from marimo._save.save import cache + d = 0 # Check shadowing + """ + ), + exec_req.get("""impure = []"""), + exec_req.get( + """ + _a = 0 + def _b(): + _c = 2 + @cache + def d(): + return _a + _c + return d + _e = _b() + impure.append([_e, _e()]) + """ + ), + exec_req.get( + repeated := """ + _a = 0 + def _b(): + _c = 1 + @cache + def d(): + return _a + _c + return d + _e = _b() + impure.append([_e, _e()]) + """ + ), + exec_req.get(repeated), + ] + ) + + assert not k.stderr.messages, k.stderr + + assert len(k.globals["impure"]) == 3 + assert { + k.globals["impure"][0][1], + k.globals["impure"][1][1], + k.globals["impure"][2][1], + } == {2, 1} + + # Same name, but should be under different entries + assert ( + k.globals["impure"][0][0].loader + is not k.globals["impure"][1][0].loader + ) + + assert ( + len( + { + *k.globals["impure"][0][0].loader._cache.keys(), + *k.globals["impure"][1][0].loader._cache.keys(), + *k.globals["impure"][2][0].loader._cache.keys(), + } + ) + == 2 + ) + + # No cache hits + assert { + k.globals["impure"][0][0].hits, + k.globals["impure"][1][0].hits, + k.globals["impure"][2][0].hits, + } == {0} + + async def test_full_scope_utilized_lru_cache( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + await k.run( + [ + exec_req.get( + """ + from marimo._save.save import lru_cache + d = 0 # Check shadowing + """ + ), + exec_req.get("""impure = []"""), + exec_req.get( + """ + _a = 0 + def _b(): + _c = 2 + @lru_cache + def d(): + return _a + _c + return d + _e = _b() + impure.append([_e, _e()]) + """ + ), + exec_req.get( + repeated := """ + _a = 0 + def _b(): + _c = 1 + @lru_cache + def d(): + return _a + _c + return d + _e = _b() + impure.append([_e, _e()]) + """ + ), + exec_req.get(repeated), + ] + ) + + assert not k.stderr.messages, k.stderr + + assert len(k.globals["impure"]) == 3 + assert { + k.globals["impure"][0][1], + k.globals["impure"][1][1], + k.globals["impure"][2][1], + } == {2, 1} + + # Same name, but should be under different entries + assert ( + k.globals["impure"][0][0].loader + is not k.globals["impure"][1][0].loader + ) + + assert ( + len( + { + *k.globals["impure"][0][0].loader._cache.keys(), + *k.globals["impure"][1][0].loader._cache.keys(), + *k.globals["impure"][2][0].loader._cache.keys(), + } + ) + == 2 + ) + + # No cache hits + assert { + k.globals["impure"][0][0].hits, + k.globals["impure"][1][0].hits, + k.globals["impure"][2][0].hits, + } == {0} + + @staticmethod + def test_object_execution_hash(app) -> None: + @app.cell + def __(): + import marimo as mo + + return (mo,) + + @app.cell + def __(): + class Namespace: ... + + ns = Namespace() + ns.x = 0 + return Namespace, ns + + @app.cell + def __(mo, ns): + @mo.cache + def f(): + return ns + + return (f,) + + @app.cell + def __(f): + f() + assert f.hits == 0 + assert f.base_block.execution_refs == {"ns"} + return + + @staticmethod + def test_execution_hash_same_block(app) -> None: + @app.cell + def __(): + import marimo as mo + + return (mo,) + + @app.cell + def __(mo): + class Namespace: ... + + ns = Namespace() + ns.x = 0 + + @mo.cache + def f(): + return ns.x + + return ( + ns, + f, + ) + + @app.cell + def __(f, ns): + assert f() == 0 + ns.x = 1 + assert f() == 1 + assert f.hits == 0 + assert f() == 1 + assert f.hits == 1 + assert f.base_block.context_refs == {"ns"}, ( + f.base_block.context_refs, + f.base_block.execution_refs, + f.base_block.content_refs, + ) + assert f.base_block.context_refs == {"ns"}, ( + f.base_block.context_refs + ) + return + + @staticmethod + def test_execution_hash_diff_block(app) -> None: + @app.cell + def __(): + import marimo as mo + + return (mo,) + + @app.cell + def __(): + import weakref + + class Namespace: ... + + ns = Namespace() + ns.x = weakref.ref(ns) + z = ns.x + + return (weakref, ns, z) + + @app.cell + def __(mo, ns, z): + @mo.cache + def f(): + return ns.x, z + + return (f,) + + @app.cell + def __(f): + f() + assert f.base_block.execution_refs == {"ns", "z"} + return + + @staticmethod + def test_content_hash_define_after(app) -> None: + @app.cell + def __(): + import marimo as mo + + return (mo,) + + @app.cell + def __(mo): + @mo.cache + def f(): + return ns.x + + class Namespace: ... + + ns = Namespace() + ns.x = 0 + + return ( + ns, + f, + ) + + @app.cell + def __(f, ns): + assert f() == 0 + ns.x = 1 + assert f() == 1 + assert f.hits == 0 + assert f() == 1 + assert f.hits == 1 + assert f.base_block.execution_refs == set(), ( + f.base_block.execution_refs + ) + assert f.base_block.missing == {"ns"}, f.base_block.missing + return + + @staticmethod + def test_execution_hash_same_block_fails() -> None: + app = App() + app._anonymous_file = True + + @app.cell + def _(): + import marimo as mo + + return (mo,) + + @app.cell + def _(): + import weakref + + return (weakref,) + + @app.cell + def _(mo, weakref): + class Namespace: ... + + ns = Namespace() + ns.x = weakref.ref(ns) + z = ns.x + + @mo.cache + def f(): + return ns.x, z + + return ( + ns, + f, + ) + + @app.cell + def _(f): + f() + return + + # Cannot hash the cell of the unhashable content, so it should fail + with pytest.raises(TypeError): + app.run() + + @staticmethod + def test_unused_args(app) -> None: + @app.cell + def __(): + import random + + import marimo as mo + + return (mo, random) + + @app.cell + def __(mo, random): + @mo.cache + def g(_x): + return random.randint(0, 1000) + + return (g,) + + @app.cell + def __(g, random): + random.seed(0) + a = g("hello") + assert g("hello") == g("hello") + random.seed(1) + assert a == g("hello") + assert a != g("world") + return + + @staticmethod + def test_shadowed_kwargs(app) -> None: + @app.cell + def __(): + import marimo as mo + + return (mo,) + + @app.cell + def __(mo): + @mo.cache + def g(value="hello"): + return value + + return g + + @app.cell + def __(g): + assert g() == "hello" + assert g(value="world") == "world" + assert g(123) == 123 + assert g.hits == 0 + assert g(value="hello") == "hello" + # g(value="hello") matches g() since both resolve to value="hello" + # This is the expected behavior after issue #7977 fix + assert g.hits == 1 + assert g() == "hello" + assert g.hits == 2 + return + + @staticmethod + def test_shadowed_state(app) -> None: + @app.cell + def __(): + import marimo as mo + + return (mo,) + + @app.cell + def __(mo): + state, set_state = mo.state(None) + + @mo.cache + def g(state): + return len(state) + + v = g("123") + return (g, v) + + @app.cell + def __(v): + assert v == 3 + return + + @staticmethod + def test_shadowed_state_redefined(app) -> None: + @app.cell + def __(): + import marimo as mo + + return (mo,) + + @app.cell + def __(mo): + state, set_state = mo.state(None) + + @mo.cache + def g(): + return len(state) + + state = "123" + + v = g() + return (g, v) + + @app.cell + def __(v): + assert v == 3 + return + + @staticmethod + def test_internal_shadowed(app) -> None: + @app.cell + def __(): import marimo as mo return (mo,) + @app.cell + def __(mo): + state0, set_state0 = mo.state(1) + state1, set_state1 = mo.state(1) + state2, set_state2 = mo.state(10) + + state, set_state = mo.state(100) + + @mo.cache + def h(state): + x = state() + + def g(): + global state + + def f(state): + return x + state() + + return state() + f(state2) + + return g() + + assert h(state0) == 111 + assert h.hits == 0 + assert h(state1) == 111 + assert h.hits == 1 + + @staticmethod + def test_transitive_shadowed_state_passes(app) -> None: @app.cell def __(): - class Namespace: ... + import marimo as mo - ns = Namespace() - ns.x = 0 - return Namespace, ns + return (mo,) @app.cell - def __(mo, ns): - @mo.cache + def __(mo): + state0, set_state0 = mo.state(1) + state1, set_state1 = mo.state(1) + state2, set_state2 = mo.state(10) + + state, set_state = mo.state(100) + + # Example of a case where things start to get very tricky. There + # comes a point where you might also have to capture frame levels + # as well if you mix scope. + # + # This is solved by rewriting potential name collisions + def h(state): + return state() + def f(): - return ns + return state() + h(state2) - return (f,) + @mo.cache + def g(state): + return state() + f() + assert g(state0) == 111 + assert g.hits == 0 + assert g(state1) == 111 + assert g.hits == 1 + + @staticmethod + def test_shadowed_state_mismatch(app) -> None: @app.cell - def __(f): - f() - assert f.hits == 0 - assert f.base_block.execution_refs == {"ns"} - return + def __(): + import marimo as mo - app.run() + return (mo,) - @staticmethod - def test_execution_hash_same_block() -> None: - app = App() - app._anonymous_file = True + @app.cell + def __(mo): + state1, set_state1 = mo.state(1) + state2, set_state2 = mo.state(2) + + # Here as a var for shadowing + state, set_state = mo.state(3) + + @mo.cache + def g(state): + return state() + + a = g(state1) + b = g(state2) + assert g.hits == 0 + A = g(state1) + B = g(state2) + assert g.hits == 2 + return (a, b, A, B) + + @app.cell + def __(a, b, A, B, state, state1, state2): + assert state1() != state2() + assert state1() == a == A + assert state2() == b == B + assert state() == 3 + return + @staticmethod + def test_shadowed_ui(app) -> None: @app.cell def __(): import marimo as mo @@ -743,362 +2552,1117 @@ def __(): @app.cell def __(mo): - class Namespace: ... + slider = mo.ui.slider(0, 1) - ns = Namespace() - ns.x = 0 + @mo.cache + def g(slider): + return len(slider) + + v = g("123") + return (g, v, slider) + + @app.cell + def __(v): + assert v == 3 + return + + @staticmethod + def test_cache_with_mutation_after_def(app) -> None: + @app.cell + def __(): + import marimo as mo + + return (mo,) + + @app.cell + def _(mo): + arr = [1, 2, 3] @mo.cache - def f(): - return ns.x + def g(): + return len(arr) + + assert g() == 3 + arr.append(4) # Mutation after definition + assert g() == 4 + arr = [1, 2] # Mutation after definition + assert g() == 2 + return (g, arr) + + @staticmethod + def test_shadowed_ui_variable_threadpool(app) -> None: + """Test shadow error with UI-derived variable and ThreadPoolExecutor. + + Bug requires: + 1. UI element providing a cell-scoped variable (e.g. `extension`) + 2. Helper function with same-named parameter using nested scope (list comp) + 3. Cached function called via ThreadPoolExecutor.submit() + + Causes KeyError at hash.py because scope has '*extension' (ARG_PREFIX) + but lookup uses 'extension'. + """ + with app.setup: + from concurrent.futures import ThreadPoolExecutor + + import marimo as mo + + @app.cell + def _(): + ui_input = mo.ui.text(value="hello") + return (ui_input,) + + @app.cell + def _(): + def helper(extension: list[str] | None) -> int: + # Nested scope using extension triggers the bug + # for e in extension: ... works fine. + return len([e for e in extension or []]) + + @mo.cache + def inner(extension: list[str] | None) -> int: + assert len([e for e in extension or []]) == 5 + return helper(extension) + + return (inner,) + + @app.cell + def _(inner, ui_input): + extension = ui_input.value + + results = [] + # has to be in a thread submission + # the following works fine + assert inner(extension) == 5 + + with ThreadPoolExecutor(max_workers=2) as executor: + future = executor.submit(inner, extension) + assert future.result() == 5 + return + + @pytest.mark.skipif( + not DependencyManager.pandas.has(), + reason="pandas not installed", + ) + async def test_cache_dataframe_object_column( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Regression test for marimo-team/marimo#9068. + + @cache with a DataFrame containing an object-dtype column previously + raised: ValueError: The truth value of a DataFrame is ambiguous. + Caused by an unsafe truthiness check in get_type() that called + bool() on the scope value instead of checking key presence. + """ + await k.run( + [ + exec_req.get( + """ + import pandas as pd + from marimo._save.save import cache + + @cache + def get_length(df): + return len(df) + + result = get_length( + pd.DataFrame( + {"a": list(range(100)), "b": list(map(str, range(100)))} + ) + ) + """ + ), + ] + ) + + assert not k.stderr.messages + assert k.globals["result"] == 100 + + +class TestPersistentCache: + async def test_pickle_context( + self, k: Kernel, exec_req: ExecReqProvider, tmp_path + ) -> None: + await k.run( + [ + exec_req.get(""" + import marimo as mo + import os + from pathlib import Path + pc = mo.persistent_cache + """), + exec_req.get( + f'tmp_path_fixture = Path("{tmp_path.as_posix()}")' + ), + exec_req.get(""" + assert not os.path.exists(tmp_path_fixture / "basic") + with pc("basic", save_path=tmp_path_fixture) as cache: + _b = 1 + assert _b == 1 + assert not cache._cache.hit + assert cache._cache.meta["version"] == mo._save.MARIMO_CACHE_VERSION + #assert os.path.exists(tmp_path_fixture / "basic" / f"P_{cache._cache.hash}.pickle") + """), + exec_req.get(""" + with pc("basic", save_path=tmp_path_fixture) as cache_2: + _b = 1 + assert _b == 1 + assert cache_2._cache.hit + assert cache._cache.hash == cache_2._cache.hash + #assert os.path.exists(tmp_path_fixture / "basic" / f"P_{cache._cache.hash}.pickle") + """), + ] + ) + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, k.stderr + + async def test_json_context( + self, k: Kernel, exec_req: ExecReqProvider, tmp_path + ) -> None: + await k.run( + [ + exec_req.get(""" + import marimo as mo + import os + from pathlib import Path + pc = mo.persistent_cache + """), + exec_req.get( + f'tmp_path_fixture = Path("{tmp_path.as_posix()}")' + ), + exec_req.get(""" + assert not os.path.exists(tmp_path_fixture / "json") + with pc("json", save_path=tmp_path_fixture, method="json") as json_cache: + _b = 1 + assert _b == 1 + assert not json_cache._cache.hit + #assert os.path.exists(tmp_path_fixture / "json" / f"P_{json_cache._cache.hash}.json") + """), + exec_req.get(""" + with pc("json", save_path=tmp_path_fixture, method="json") as json_cache_2: + _b = 1 + assert _b == 1 + assert json_cache_2._cache.hit + assert json_cache._cache.hash == json_cache_2._cache.hash + #assert os.path.exists(tmp_path_fixture / "json" / f"P_{json_cache._cache.hash}.json") + """), + ] + ) + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, k.stderr + + +class TestCacheStatistics: + """Tests for cache statistics API (cache_info(), cache_clear())""" + + async def test_cache_info_and_clear( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Verify cache_info() and cache_clear() work correctly.""" + await k.run( + [ + exec_req.get( + """ + from marimo._save.save import cache, lru_cache + + @cache + def func(x): + return x * 2 + + @lru_cache(maxsize=2) + def lru_func(x): + return x * 3 + + # Test basic cache_info + info0 = func.cache_info() + func(1) + func(1) # hit + func(2) # miss + info1 = func.cache_info() + + # Test lru_cache maxsize + lru_info = lru_func.cache_info() + + # Test cache_clear + func.cache_clear() + info2 = func.cache_info() + """ + ), + ] + ) + + assert not k.stderr.messages, k.stderr + + # Initial state + info0 = k.globals["info0"] + assert info0.hits == 0 + assert info0.misses == 0 + assert info0.maxsize is None + assert info0.currsize == 0 + assert info0.time_saved == 0.0 + + # After calls + info1 = k.globals["info1"] + assert info1.hits == 1 + assert info1.misses == 2 + assert info1.currsize == 2 + + # LRU maxsize + lru_info = k.globals["lru_info"] + assert lru_info.maxsize == 2 + + # After clear + info2 = k.globals["info2"] + assert info2.currsize == 0 + + async def test_persistent_cache_clear( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Verify cache_clear() works with persistent cache decorator.""" + await k.run( + [ + exec_req.get(""" + from marimo._save.loaders.memory import MemoryLoader + from marimo._save.save import persistent_cache + + @persistent_cache(_loader=MemoryLoader) + def calc(x): + return x * 2 + + # First call - miss + r1 = calc(5) + info1 = calc.cache_info() + + # Second call - hit + r2 = calc(5) + info2 = calc.cache_info() + + # Clear + calc.cache_clear() + info3 = calc.cache_info() + + # Call again - should be miss + r3 = calc(5) + info4 = calc.cache_info() + """), + ] + ) + + assert not k.stderr.messages, k.stderr + + # Should have 1 hit before clear + info2 = k.globals["info2"] + assert info2.hits == 1 + + # After clear: should be empty + info3 = k.globals["info3"] + assert info3.currsize == 0 + + # After calling again: should be a miss + info4 = k.globals["info4"] + assert info4.misses >= 1 + + async def test_cache_time_tracking( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Verify time_saved is tracked and included in cache_info().""" + await k.run( + [ + exec_req.get(""" + import time + from marimo._save.save import cache + + @cache + def slow_func(x): + time.sleep(0.01) # Simulate slow operation + return x * 2 + + # Initial state + info0 = slow_func.cache_info() + + # First call - miss (should record runtime) + r1 = slow_func(5) + info1 = slow_func.cache_info() + + # Second call - hit (should add to time_saved) + r2 = slow_func(5) + info2 = slow_func.cache_info() + + # Third call - another hit (should accumulate time_saved) + r3 = slow_func(5) + info3 = slow_func.cache_info() + """), + ] + ) + + assert not k.stderr.messages, k.stderr + + # Initial state: no time saved yet + info0 = k.globals["info0"] + assert info0.time_saved == 0.0 + + # After first call (miss): still no time saved + info1 = k.globals["info1"] + assert info1.time_saved == 0.0 + + # After first hit: should have some time saved + info2 = k.globals["info2"] + assert info2.time_saved > 0.0 + first_saving = info2.time_saved + + # After second hit: time_saved should accumulate + info3 = k.globals["info3"] + assert info3.time_saved > first_saving + assert info3.hits == 2 + + +class TestAsyncCacheDecorator: + """Tests for async function caching support.""" + + async def test_basic_async_cache( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Test basic async function caching with @cache decorator.""" + await k.run( + [ + exec_req.get( + """ + from marimo._save.save import cache + + @cache + async def async_fib(n): + if n <= 1: + return n + a = await async_fib(n - 1) + b = await async_fib(n - 2) + return a + b - return ( - ns, - f, - ) + a = await async_fib(5) + b = await async_fib(10) + """ + ), + ] + ) - @app.cell - def __(f, ns): - assert f() == 0 - ns.x = 1 - assert f() == 1 - assert f.hits == 0 - assert f() == 1 - assert f.hits == 1 - assert f.base_block.context_refs == {"ns"}, ( - f.base_block.context_refs, - f.base_block.execution_refs, - f.base_block.content_refs, - ) - assert f.base_block.context_refs == { - "ns" - }, f.base_block.context_refs - return + assert not k.stderr.messages, k.stderr + assert k.globals["a"] == 5 + assert k.globals["b"] == 55 + # Should have cache hits like the sync version + assert k.globals["async_fib"].hits == 9 - app.run() + async def test_async_lru_cache( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Test async function caching with @lru_cache decorator.""" + await k.run( + [ + exec_req.get( + """ + from marimo._save.save import lru_cache - @staticmethod - def test_execution_hash_diff_block() -> None: - app = App() - app._anonymous_file = True + @lru_cache(maxsize=2) + async def async_fib(n): + if n <= 1: + return n + a = await async_fib(n - 1) + b = await async_fib(n - 2) + return a + b - @app.cell - def __(): - import marimo as mo + a = await async_fib(5) + b = await async_fib(10) + """ + ), + ] + ) - return (mo,) + assert not k.stderr.messages, k.stderr + assert k.globals["a"] == 5 + assert k.globals["b"] == 55 + # Should have more hits with smaller cache + assert k.globals["async_fib"].hits == 14 - @app.cell - def __(): - import weakref + async def test_async_persistent_cache( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Test async function with @persistent_cache decorator.""" + await k.run( + [ + exec_req.get( + """ + import asyncio + from marimo._save.save import persistent_cache + from marimo._save.loaders import MemoryLoader - class Namespace: ... + @persistent_cache(_loader=MemoryLoader) + async def async_compute(x): + await asyncio.sleep(0.001) # Simulate async work + return x * 2 - ns = Namespace() - ns.x = weakref.ref(ns) - z = ns.x + result1 = await async_compute(5) + result2 = await async_compute(5) # Should hit cache + result3 = await async_compute(10) # Should miss + """ + ), + ] + ) - return (weakref, ns, z) + assert not k.stderr.messages, k.stderr + assert k.globals["result1"] == 10 + assert k.globals["result2"] == 10 + assert k.globals["result3"] == 20 + assert k.globals["async_compute"].hits == 1 - @app.cell - def __(mo, ns, z): - @mo.cache - def f(): - return ns.x, z + async def test_async_cache_with_external_deps( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Test async cached function with external dependencies.""" + await k.run( + [ + exec_req.get( + """ + import asyncio + from marimo._save.save import cache - return (f,) + external_value = 10 - @app.cell - def __(f): - f() - assert f.base_block.execution_refs == {"ns", "z"} - return + @cache + async def async_add(x): + await asyncio.sleep(0.001) + return x + external_value - app.run() + result1 = await async_add(5) + result2 = await async_add(5) # Should hit cache + """ + ), + ] + ) - @staticmethod - def test_content_hash_define_after() -> None: - app = App() - app._anonymous_file = True + assert not k.stderr.messages, k.stderr + assert k.globals["result1"] == 15 + assert k.globals["result2"] == 15 + assert k.globals["async_add"].hits == 1 - @app.cell - def __(): - import marimo as mo + async def test_async_cache_method( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Test async method caching.""" + await k.run( + [ + exec_req.get( + """ + import asyncio + from marimo._save.save import cache - return (mo,) + class AsyncCalculator: + def __init__(self, base): + self.base = base - @app.cell - def __(mo): - @mo.cache - def f(): - return ns.x + @cache + async def calculate(self, x): + await asyncio.sleep(0.001) + return self.base + x + + calc = AsyncCalculator(10) + result1 = await calc.calculate(5) + result2 = await calc.calculate(5) # Should hit cache + result3 = await calc.calculate(7) # Should miss + """ + ), + ] + ) - class Namespace: ... + assert not k.stderr.messages, k.stderr + assert k.globals["result1"] == 15 + assert k.globals["result2"] == 15 + assert k.globals["result3"] == 17 - ns = Namespace() - ns.x = 0 + async def test_async_cache_static_method( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Test async static method caching.""" + await k.run( + [ + exec_req.get( + """ + import asyncio + from marimo._save.save import cache - return ( - ns, - f, - ) + class AsyncMath: + @staticmethod + @cache + async def multiply(x, y): + await asyncio.sleep(0.001) + return x * y - @app.cell - def __(f, ns): - assert f() == 0 - ns.x = 1 - assert f() == 1 - assert f.hits == 0 - assert f() == 1 - assert f.hits == 1 - assert ( - f.base_block.execution_refs == set() - ), f.base_block.execution_refs - assert f.base_block.missing == {"ns"}, f.base_block.missing - return + result1 = await AsyncMath.multiply(3, 4) + result2 = await AsyncMath.multiply(3, 4) # Should hit cache + result3 = await AsyncMath.multiply(5, 6) # Should miss + """ + ), + ] + ) - app.run() + assert not k.stderr.messages, k.stderr + assert k.globals["result1"] == 12 + assert k.globals["result2"] == 12 + assert k.globals["result3"] == 30 - @staticmethod - def test_execution_hash_same_block_fails() -> None: - app = App() - app._anonymous_file = True + async def test_async_cache_with_await_in_notebook( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Test async function that can be awaited directly in notebook context.""" + await k.run( + [ + exec_req.get( + """ + import asyncio + from marimo._save.save import cache - @app.cell - def __(): - import marimo as mo + @cache + async def fetch_data(n): + await asyncio.sleep(0.001) + return n * 100 - return (mo,) + # Use direct await since marimo supports top-level await + result = await fetch_data(5) + """ + ), + ] + ) - @app.cell - def __(): - import weakref + assert not k.stderr.messages, k.stderr + assert k.globals["result"] == 500 - return (weakref,) + async def test_async_cache_info_and_clear( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Verify cache_info() and cache_clear() work correctly with async functions.""" + await k.run( + [ + exec_req.get( + """ + from marimo._save.save import cache, lru_cache - @app.cell - def __(mo, weakref): - class Namespace: ... + @cache + async def async_func(x): + return x * 2 - ns = Namespace() - ns.x = weakref.ref(ns) - z = ns.x + @lru_cache(maxsize=2) + async def async_lru_func(x): + return x * 3 + + # Test basic cache_info + info0 = async_func.cache_info() + await async_func(1) + await async_func(1) # hit + await async_func(2) # miss + info1 = async_func.cache_info() + + # Test lru_cache maxsize + lru_info = async_lru_func.cache_info() + + # Test cache_clear + async_func.cache_clear() + info2 = async_func.cache_info() + """ + ), + ] + ) - @mo.cache - def f(): - return ns.x, z + assert not k.stderr.messages, k.stderr - return ( - ns, - f, - ) + # Initial state + info0 = k.globals["info0"] + assert info0.hits == 0 + assert info0.misses == 0 + assert info0.maxsize is None + assert info0.currsize == 0 + assert info0.time_saved == 0.0 + + # After calls + info1 = k.globals["info1"] + assert info1.hits == 1 + assert info1.misses == 2 + assert info1.currsize == 2 + + # LRU maxsize + lru_info = k.globals["lru_info"] + assert lru_info.maxsize == 2 + + # After clear + info2 = k.globals["info2"] + assert info2.currsize == 0 + + async def test_async_cache_time_tracking( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Verify time_saved is tracked correctly for async functions.""" + await k.run( + [ + exec_req.get( + """ + import asyncio + from marimo._save.save import cache - @app.cell - def __(f): - f() - return + @cache + async def async_slow_func(x): + await asyncio.sleep(0.01) # Simulate slow async operation + return x * 2 - # Cannot hash the cell of the unhashable content, so it should fail - with pytest.raises(TypeError): - app.run() + # Initial state + info0 = async_slow_func.cache_info() - @staticmethod - def test_unused_args() -> None: - app = App() - app._anonymous_file = True + # First call - miss (should record runtime) + r1 = await async_slow_func(5) + info1 = async_slow_func.cache_info() - @app.cell - def __(): - import random + # Second call - hit (should add to time_saved) + r2 = await async_slow_func(5) + info2 = async_slow_func.cache_info() - import marimo as mo + # Third call - another hit (should accumulate time_saved) + r3 = await async_slow_func(5) + info3 = async_slow_func.cache_info() + """ + ), + ] + ) - return (mo, random) + assert not k.stderr.messages, k.stderr - @app.cell - def __(mo, random): - @mo.cache - def g(_x): - return random.randint(0, 1000) + # Initial state: no time saved yet + info0 = k.globals["info0"] + assert info0.time_saved == 0.0 - return (g,) + # After first call (miss): still no time saved + info1 = k.globals["info1"] + assert info1.time_saved == 0.0 - @app.cell - def __(g, random): - random.seed(0) - a = g("hello") - assert g("hello") == g("hello") - random.seed(1) - assert a == g("hello") - assert a != g("world") - return + # After first hit: should have some time saved + info2 = k.globals["info2"] + assert info2.time_saved > 0.0 + first_saving = info2.time_saved - app.run() + # After second hit: time_saved should accumulate + info3 = k.globals["info3"] + assert info3.time_saved > first_saving + assert info3.hits == 2 - @staticmethod - def test_shadowed_state() -> None: - app = App() - app._anonymous_file = True + async def test_async_cache_class_method( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Test async class method caching.""" + await k.run( + [ + exec_req.get( + """ + import asyncio + from marimo._save.save import cache - @app.cell - def __(): - import marimo as mo + class AsyncMath: + @classmethod + @cache + async def compute(cls, x, y): + await asyncio.sleep(0.001) + return x + y + + case_a = AsyncMath().compute + case_b = AsyncMath().compute + case_c = AsyncMath.compute + result1 = await case_a(1, 2) + hash1 = case_a._last_hash + result2 = await case_b(2, 1) + hash2 = case_b._last_hash + result3 = await case_c(1, 2) + hash3 = case_c._last_hash + base_hash = AsyncMath.compute._last_hash + """ + ), + ] + ) - return (mo,) + assert not k.stdout.messages, k.stdout.messages + assert not k.stderr.messages, k.stderr.messages - @app.cell - def __(mo): - state, set_state = mo.state(None) + # Verify results + assert k.globals["result1"] == 3 + assert k.globals["result2"] == 3 + assert k.globals["result3"] == 3 + assert k.globals["hash1"] != k.globals["hash2"] + assert k.globals["hash1"] == k.globals["hash3"] + assert k.globals["case_c"]._last_hash is not None - @mo.cache - def g(state): - return len(state) + # NB. base_hash has different behavior than the others on python 3.13+ + # 3.13 has base_hash == hash1, while <3.13 has base_hash != None + import sys - v = g("123") - return (g, v) + if sys.version_info >= (3, 13): + assert k.globals["base_hash"] == k.globals["hash1"] + else: + assert k.globals["base_hash"] is None - @app.cell - def __(v): - assert v == 3 - return + async def test_async_lru_cache_default( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Test async lru_cache with default maxsize (256).""" + await k.run( + [ + exec_req.get( + """ + from marimo._save.save import lru_cache - app.run() + @lru_cache + async def async_fib(n): + if n <= 1: + return n + a = await async_fib(n - 1) + b = await async_fib(n - 2) + return a + b - @staticmethod - def test_shadowed_state_redefined() -> None: - app = App() - app._anonymous_file = True + a = await async_fib(260) + b = await async_fib(10) + """ + ), + ] + ) - @app.cell - def __(): - import marimo as mo + assert not k.stderr.messages + # More hits with a smaller cache, because it needs to check the cache + # more. Has 256 entries by default, normal cache hits just 259 times. + assert k.globals["async_fib"].hits == 266 - return (mo,) + # A little ridiculous, but still low compute. + assert ( + k.globals["a"] + == 971183874599339129547649988289594072811608739584170445 + ) + assert k.globals["b"] == 55 - @app.cell - def __(mo): - state, set_state = mo.state(None) + async def test_async_cross_cell_cache( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Test async function caching across multiple notebook cells.""" + await k.run( + [ + exec_req.get("""from marimo._save.save import cache"""), + exec_req.get( + """ + @cache + async def async_fib(n): + if n <= 1: + return n + a = await async_fib(n - 1) + b = await async_fib(n - 2) + return a + b + """ + ), + exec_req.get("""a = await async_fib(5)"""), + exec_req.get("""b = await async_fib(10); a"""), + ] + ) - @mo.cache - def g(): - return len(state) + assert not k.stderr.messages + assert k.globals["async_fib"].hits == 9 - state = "123" + assert k.globals["a"] == 5 + assert k.globals["b"] == 55 - v = g() - return (g, v) + async def test_async_cache_with_external_state( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Test async cached function with mo.state() dependency.""" + await k.run( + [ + exec_req.get( + """ + from marimo._save.save import cache + from marimo._runtime.state import state + """ + ), + exec_req.get("""external, setter = state(0)"""), + exec_req.get( + """ + @cache + async def async_fib(n): + if n <= 1: + return n + external() + a = await async_fib(n - 1) + b = await async_fib(n - 2) + return a + b + """ + ), + exec_req.get("""impure = []"""), + exec_req.get("""a = await async_fib(5)"""), + exec_req.get("""b = await async_fib(10); a"""), + exec_req.get( + """ + c = a + b + if len(impure) == 0: + setter(1) + elif len(impure) == 1: + setter(0) + impure.append(c) + """ + ), + ] + ) - @app.cell - def __(v): - assert v == 3 - return + assert not k.stderr.messages - app.run() + assert k.globals["a"] == 5 + assert k.globals["b"] == 55 + assert k.globals["impure"] == [60, 157, 60] + # Cache hit value may be flaky depending on when state is evicted from + # the registry. The actual cache hit is less important than caching + # occurring in the first place. + # NB. 20 = 2 * 9 + 2 + if k.globals["async_fib"].hits in (9, 18): + import warnings - @staticmethod - def test_transitive_shadowed_state_fails() -> None: - app = App() - app._anonymous_file = True + warnings.warn( + "Known flaky edge case for async cache with state dep.", + stacklevel=1, + ) + else: + assert k.globals["async_fib"].hits == 20 - # Add a unit test to denote a known failure case + async def test_async_cache_decorator_with_kwargs( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Test that kwargs hashing works identically for async functions.""" + await k.run( + [ + exec_req.get( + """ + from marimo._save.save import cache - @app.cell - def __(): - import marimo as mo + @cache + async def async_cached_func(*args, **kwargs): + return sum(args) + sum(kwargs.values()) - return (mo,) + # First call with specific kwargs + result1 = await async_cached_func(1, 2, some_kw_arg=3) + hash1 = async_cached_func._last_hash + """ + ), + exec_req.get( + """ + # Second call with different kwargs - should be cache miss + result2 = await async_cached_func(1, 2, some_kw_arg=4) + hash2 = async_cached_func._last_hash + """ + ), + exec_req.get( + """ + # Third call with same kwargs as first - should be cache hit + result3 = await async_cached_func(1, 2, some_kw_arg=3) + hash3 = async_cached_func._last_hash + """ + ), + ] + ) - @app.cell - def __(mo): - state1, set_state1 = mo.state(1) - state2, set_state2 = mo.state(2) + # Verify results + assert k.globals["result1"] == 6 # 1 + 2 + 3 + assert k.globals["result2"] == 7 # 1 + 2 + 4 + assert k.globals["result3"] == 6 # 1 + 2 + 3 - state, set_state = mo.state(3) + # Verify cache keys + hash1 = k.globals["hash1"] + hash2 = k.globals["hash2"] + hash3 = k.globals["hash3"] - # Example of a case where things start to get very tricky. There - # comes a point where you might also have to capture frame levels - # as well if you mix scope. - # - # This is solved by throwing an exception when state - # shadowing occurs. - def f(): - return state() + assert hash1 != hash2, "Cache key should change when kwargs change" + assert hash1 == hash3, ( + "Cache key should be same for identical args/kwargs" + ) - @mo.cache - def g(state): - return state() + f() + # Verify cache hits + assert k.globals["async_cached_func"].hits == 1 - # Cannot resolved shadowed ref. - with pytest.raises(RuntimeError) as e: - app.run() + async def test_async_cache_concurrent_deduplication( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Test that concurrent calls to the same async cached function are deduplicated. - assert "rename the argument" in str(e) + When multiple async calls are made concurrently with the same arguments, + only one execution should occur - the rest should await the same task. + This prevents race conditions and duplicate work. + """ + await k.run( + [ + exec_req.get( + """ + import asyncio + from marimo._save.save import cache - @staticmethod - def test_shadowed_state_mismatch() -> None: - app = App() - app._anonymous_file = True + call_count = 0 - @app.cell - def __(): - import marimo as mo + @cache + async def expensive_async_compute(x): + global call_count + call_count += 1 + await asyncio.sleep(0.1) # Simulate expensive async work + return x * 2 + + # Launch 5 concurrent calls with the same argument + # Only one should actually execute, the rest should await that task + results = await asyncio.gather( + expensive_async_compute(42), + expensive_async_compute(42), + expensive_async_compute(42), + expensive_async_compute(42), + expensive_async_compute(42), + ) + """ + ), + ] + ) - return (mo,) + assert not k.stderr.messages, k.stderr - @app.cell - def __(mo): - state1, set_state1 = mo.state(1) - state2, set_state2 = mo.state(2) + # All results should be the same + results = k.globals["results"] + assert all(r == 84 for r in results), "All results should be 84" - state, set_state = mo.state(3) + # The function should only have been called once (deduplication worked) + assert k.globals["call_count"] == 1, ( + f"Expected 1 execution due to deduplication, got {k.globals['call_count']}" + ) - @mo.cache - def g(state): - return state() + # Cache hit should be 0 (first execution is a miss) + # Note: The first call misses, subsequent concurrent calls await the same task + assert k.globals["expensive_async_compute"].hits == 0, ( + "First execution should be a miss, deduplication doesn't count as hits" + ) - a = g(state1) - b = g(state2) + @pytest.mark.skipif( + sys.version_info < (3, 12), + reason="PEP 695 `type` alias syntax requires Python 3.12+", + ) + async def test_cache_dataclass_with_type_alias( + self, any_kernel: Kernel + ) -> None: + """@mo.cache + dataclass field using PEP 695 type alias (#10192). - A = g(state1) - B = g(state2) - assert g.hits == 2 - return (a, b, A, B) + Keep the `type` statement inside executed cell source so the test + module itself parses on Python 3.10/3.11 (ruff target-version). + """ + k = any_kernel + await k.run( + [ + ExecuteCellCommand( + cell_id="setup", + code=textwrap.dedent( + """ + import marimo as mo + from dataclasses import dataclass + from typing import Literal - @app.cell - def __(a, b, A, B, state, state1, state2): - assert state1() != state2() - assert state1() == a == A - assert state2() == b == B - assert state() == 3 - return + type Mode = Literal["auto", "manual"] + """ + ), + ), + ExecuteCellCommand( + cell_id="0", + code=textwrap.dedent( + """ + @dataclass + class Config: + mode: Mode = "auto" + """ + ), + ), + ExecuteCellCommand( + cell_id="1", + code=textwrap.dedent( + """ + @mo.cache + def build(mode: Mode = "auto") -> Config: + return Config(mode=mode) - app.run() + cfg = build() + cfg2 = build() + """ + ), + ), + ] + ) + assert k.errors == {} + assert not k.stderr.messages, k.stderr + assert k.globals["cfg"].mode == "auto" + assert k.globals["build"].hits == 1 - @staticmethod - def test_shadowed_ui() -> None: - app = App() - app._anonymous_file = True + async def test_async_cache_stale_task_different_loop( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Regression test for GH-8866. - @app.cell - def __(): - import marimo as mo + In marimo run, each session runs in its own thread with a separate event + loop. When a pending task from session A's loop is found by session B + (different loop), it must not be awaited — doing so raises RuntimeError. + """ + import asyncio - return (mo,) + from marimo._save.save import _cache_call_async - @app.cell - def __(mo): - slider = mo.ui.slider(0, 1) + await k.run( + [ + exec_req.get( + """ + import asyncio + from marimo._save.save import cache - @mo.cache - def g(slider): - return len(slider) + @cache + async def compute(x): + await asyncio.sleep(0) + return x * 2 - v = g("123") - return (g, v, slider) + result = await compute(42) + """ + ) + ] + ) - @app.cell - def __(v): - assert v == 3 - return + assert not k.stderr.messages + assert k.globals["result"] == 84 + + fn = k.globals["compute"] + cache_key = fn._last_hash + + # Inject a stale future from a different event loop into the per-loop + # pending dict, simulating session A's in-progress task. Session B + # (the current kernel loop) must use its own loop's pending dict and + # never see or touch session A's future. + other_loop = asyncio.new_event_loop() + try: + stale_future = ( + other_loop.create_future() + ) # Future, no coroutine → no warning + + with _cache_call_async._pending_lock: + import weakref + + if fn not in _cache_call_async._pending_executions: + _cache_call_async._pending_executions[fn] = ( + weakref.WeakKeyDictionary() + ) + _cache_call_async._pending_executions[fn][other_loop] = { + cache_key: stale_future + } - app.run() + # Session B looks up its own loop's pending dict → not found → + # fresh execution → cache hit → result = 84, no RuntimeError. + await k.run([exec_req.get("result2 = await compute(42)")]) + + assert not k.stderr.messages, k.stderr + assert k.globals["result2"] == 84 + # The stale future must be untouched: if __call__ had tried to + # await it the future would have raised an error and been resolved. + assert not stale_future.done(), ( + "stale future was awaited/cancelled — __call__ must not touch " + "pending tasks from a different event loop" + ) + finally: + with _cache_call_async._pending_lock: + _cache_call_async._pending_executions.pop(fn, None) + other_loop.close() diff --git a/tests/_save/test_cache_invalidation.py b/tests/_save/test_cache_invalidation.py new file mode 100644 index 00000000000..da0ab0935e4 --- /dev/null +++ b/tests/_save/test_cache_invalidation.py @@ -0,0 +1,165 @@ +# Copyright 2026 Marimo. All rights reserved. +"""Tests for cache invalidation when function body changes.""" + +from __future__ import annotations + +import textwrap + +from marimo._runtime.runtime import Kernel +from tests.conftest import ExecReqProvider + + +class TestCacheInvalidation: + async def test_numeric_return_invalidation( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """@mo.cache with numeric return types must invalidate on body change.""" + # First run: return 11 + 19 = 30 + await k.run( + [ + exec_req.get_with_id( + cell_id="0", + code=textwrap.dedent(""" + import marimo as mo + + @mo.cache + def query(): + return 11 + 19 + + result = query() + """), + ), + ] + ) + + assert k.globals["result"] == 30 + first_hash = k.globals["query"]._last_hash + + # Second run: return 5 + 3 = 8 (same cell, different code) + await k.run( + [ + exec_req.get_with_id( + cell_id="0", + code=textwrap.dedent(""" + import marimo as mo + + @mo.cache + def query(): + return 5 + 3 + + result = query() + """), + ), + ] + ) + + second_hash = k.globals["query"]._last_hash + + # Hashes should be different + assert first_hash != second_hash, ( + "Hash should change when function body changes" + ) + # Should get 8, not stale 30 + assert k.globals["result"] == 8, ( + f"Expected 8, got {k.globals['result']} (stale cache)" + ) + + async def test_string_return_invalidation( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """@mo.cache with string return types must invalidate on body change.""" + # First run + await k.run( + [ + exec_req.get_with_id( + cell_id="0", + code=textwrap.dedent(""" + import marimo as mo + + @mo.cache + def query(): + return "hello" + + result = query() + """), + ), + ] + ) + + assert k.globals["result"] == "hello" + first_hash = k.globals["query"]._last_hash + + # Second run: different function body + await k.run( + [ + exec_req.get_with_id( + cell_id="0", + code=textwrap.dedent(""" + import marimo as mo + + @mo.cache + def query(): + return "world" + + result = query() + """), + ), + ] + ) + + second_hash = k.globals["query"]._last_hash + + # Hashes should be different + assert first_hash != second_hash, ( + "Hash should change when function body changes" + ) + assert k.globals["result"] == "world", ( + f"Expected 'world', got {k.globals['result']} (stale cache)" + ) + + async def test_float_return_invalidation( + self, k: Kernel, exec_req: ExecReqProvider + ) -> None: + """@mo.cache with float return types must invalidate on body change.""" + # First run: return 1.5 + 2.5 = 4.0 + await k.run( + [ + exec_req.get_with_id( + cell_id="0", + code=textwrap.dedent(""" + import marimo as mo + + @mo.cache + def query(): + return 1.5 + 2.5 + + result = query() + """), + ), + ] + ) + + assert k.globals["result"] == 4.0 + + # Second run: return 0.1 + 0.2 ≈ 0.3 + await k.run( + [ + exec_req.get_with_id( + cell_id="0", + code=textwrap.dedent(""" + import marimo as mo + + @mo.cache + def query(): + return 0.1 + 0.2 + + result = query() + """), + ), + ] + ) + + # Should get approximately 0.3, not stale 4.0 + assert abs(k.globals["result"] - 0.3) < 0.0001, ( + f"Expected ~0.3, got {k.globals['result']} (stale cache)" + ) diff --git a/tests/_save/test_cache_versions.py b/tests/_save/test_cache_versions.py new file mode 100644 index 00000000000..24db9a425de --- /dev/null +++ b/tests/_save/test_cache_versions.py @@ -0,0 +1,52 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import pytest + + +class TestVersionCache: + @staticmethod + @pytest.mark.skipif( + "sys.version_info < (3, 12) or sys.version_info >= (3, 13)" + ) + def test_load_v4_pickle(app) -> None: + @app.cell + def _(): + unhashable = [object()] + + @app.cell + def v1(unhashable) -> tuple[int]: + # Check top level import + from marimo import persistent_cache + + ref = 1 + with persistent_cache( + name="pickle-dump-v4", save_path="tests/_save/cache-dumps" + ) as cache: + value = 1 + len(unhashable) + ref + assert cache.hit + assert value == 3 + + @staticmethod + @pytest.mark.skipif( + "sys.version_info < (3, 12) or sys.version_info >= (3, 13)" + ) + def test_load_v4_json(app) -> None: + @app.cell + def _(): + unhashable = [object()] + + @app.cell + def v1(unhashable) -> tuple[int]: + # Check top level import + from marimo import persistent_cache + + ref = 1 + with persistent_cache( + name="json-dump-v4", + save_path="tests/_save/cache-dumps", + method="json", + ) as cache: + value = 1 + len(unhashable) + ref + assert cache.hit + assert value == 3 diff --git a/tests/_save/test_class_dependency_restore.py b/tests/_save/test_class_dependency_restore.py new file mode 100644 index 00000000000..4ea090377c6 --- /dev/null +++ b/tests/_save/test_class_dependency_restore.py @@ -0,0 +1,139 @@ +# Copyright 2026 Marimo. All rights reserved. +"""Dependency-ordered restore for cell-defined classes/functions. + +A cached block may define classes and functions that reference each other +(and instances of each other). On a cache hit the block is skipped, so the +loader must reconstruct everything — and it must do so in dependency order, +or exec/unpickle hits a NameError / missing-class error. +""" + +from __future__ import annotations + +import linecache +import textwrap +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + +from marimo._runtime.patches import ( + create_main_module, + patch_main_module_context, +) +from marimo._save.cache import Cache +from marimo._save.hash import HashKey +from marimo._save.loaders import LazyLoader +from marimo._save.stores.file import FileStore + +CELL_FILENAME = "__marimo__cell_dep_.py" + + +def _seed_linecache(src: str) -> None: + linecache.cache[CELL_FILENAME] = ( + len(src), + None, + [line + "\n" for line in src.splitlines()], + CELL_FILENAME, + ) + + +def _save_then_restart_restore( + src: str, + names: list[str], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> dict[str, Any]: + """Run `src` in a fresh kernel main module, cache its `names`, then + simulate a kernel restart and restore into a brand-new main module. + Returns the restored namespace. + """ + monkeypatch.setattr( + Cache, "_cell_filename", staticmethod(lambda: CELL_FILENAME) + ) + _seed_linecache(src) + + loader = LazyLoader("t", store=FileStore(save_path=str(tmp_path))) + key = HashKey(hash="dephash", cache_type="Pure") + + # Original run + save — all under a patched __main__ so instances of + # cell-defined classes pickle against the right module. + original = create_main_module(None, None) + with patch_main_module_context(original): + exec(compile(src, CELL_FILENAME, "exec"), original.__dict__) + defs = {name: original.__dict__[name] for name in names} + cache = Cache( + defs=dict(defs), + hash=key.hash, + cache_type=key.cache_type, + stateful_refs=set(), + hit=False, + meta={}, + ) + cache.update(dict(defs)) + assert loader.save_cache(cache) + loader.flush() + + # Simulated restart: a fresh main module with none of the cell defs. + restarted = create_main_module(None, None) + with patch_main_module_context(restarted): + loaded = loader.load_cache(key) + assert loaded is not None, "cache failed to load after restart" + loaded.restore(restarted.__dict__) + return dict(restarted.__dict__) + + +def test_function_class_ordering( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`Foo`'s body calls `foo()` at class-definition time, so `foo` must + be reconstructed before `Foo`.""" + src = textwrap.dedent( + """ + def foo(): + return 21 + + class Foo: + doubled = foo() * 2 + """ + ).strip() + scope = _save_then_restart_restore( + src, ["foo", "Foo"], tmp_path, monkeypatch + ) + assert scope["foo"]() == 21 + assert scope["Foo"].doubled == 42 + + +def test_instance_of_cell_class_restores( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The caveat case: `_baz` is an instance of the cell-defined class + `Bar`, so `Bar` must be materialized before `_baz` unpickles; `Foo` + depends on both `Bar` (annotation) and `foo` (called in its body).""" + src = textwrap.dedent( + """ + class Bar: + def __init__(self, x: int): + self.x = x + + _baz = Bar(7) + + def foo(): + return _baz + + class Foo: + bar: Bar = foo() + """ + ).strip() + scope = _save_then_restart_restore( + src, ["Bar", "_baz", "foo", "Foo"], tmp_path, monkeypatch + ) + + assert scope["Bar"](7).x == 7 + assert scope["_baz"].x == 7 + assert isinstance(scope["_baz"], scope["Bar"]) + # foo returns the restored module-level _baz + assert scope["foo"]() is scope["_baz"] + # Foo.bar was computed as foo() at class-def time + assert scope["Foo"].bar is scope["_baz"] diff --git a/tests/_save/test_encode.py b/tests/_save/test_encode.py new file mode 100644 index 00000000000..362bf4d062c --- /dev/null +++ b/tests/_save/test_encode.py @@ -0,0 +1,139 @@ +# Copyright 2026 Marimo. All rights reserved. + +from __future__ import annotations + +import array +import pickle +from typing import Any + +import pytest + +from marimo._dependencies.dependencies import DependencyManager +from marimo._save.encode import common_container_to_bytes, deterministic_dumps + +HAS_PANDAS = DependencyManager.pandas.has() +HAS_NUMPY = DependencyManager.numpy.has() + + +class _ArrayWithSet: + """Array-like whose pickle is non-deterministic across processes. + + The `_tags` set has hash-randomized iteration order across Python + processes (PYTHONHASHSEED), so `pickle.dumps` produces different bytes + on each run. But since this class has `__array__`, `_ContentHashPickler` + replaces it with a deterministic content hash of the array data, discarding + the non-deterministic set entirely. + """ + + def __init__(self, data: list[float], tags: set) -> None: + self._data = array.array("f", data) + self._tags = tags + + def __array__(self, dtype: Any = None) -> Any: + import numpy as np + + return np.frombuffer(self._data, dtype=np.float32) + + +def test_reducer_fires_replaces_with_content_hash() -> None: + """reducer_override must replace data primitives, not pass through to pickle.""" + obj = _ArrayWithSet([1.0, 2.0, 3.0], tags={"a", "b", "c"}) + + raw = pickle.dumps(obj) + det = deterministic_dumps(obj, "sha256") + + assert raw != det + + +def test_deterministic_for_same_data_different_set() -> None: + """Two objects with same array data but different set attrs must hash the same. + + This mirrors the cross-process case: PYTHONHASHSEED changes set iteration + order, so `pickle.dumps` differs between processes. `deterministic_dumps` + must not — it is content-addressed on the array data only. + """ + data = list(range(10)) + + obj1 = _ArrayWithSet(data, tags={"x", "y", "z"}) + obj2 = _ArrayWithSet(data, tags={"p", "q", "r"}) + + assert pickle.dumps(obj1) != pickle.dumps(obj2) + assert deterministic_dumps(obj1, "sha256") == deterministic_dumps( + obj2, "sha256" + ) + + +def test_different_array_data_produces_different_hash() -> None: + """Different array data must produce different deterministic hashes.""" + obj1 = _ArrayWithSet([0.0] * 10, tags={"a"}) + obj2 = _ArrayWithSet([1.0] * 10, tags={"a"}) + + assert deterministic_dumps(obj1, "sha256") != deterministic_dumps( + obj2, "sha256" + ) + + +def test_plain_objects_pass_through() -> None: + """Objects without data primitives should pickle normally.""" + obj = {"key": "value", "n": 42, "nested": [1, 2, 3]} + assert deterministic_dumps(obj, "sha256") == pickle.dumps(obj) + + +def test_0d_array() -> None: + """Scalar (0-d) arrays are a special code path in _contiguous_tensor_bytes.""" + obj = _ArrayWithSet([3.14], tags={"x"}) + det1 = deterministic_dumps(obj, "sha256") + det2 = deterministic_dumps(obj, "sha256") + assert det1 == det2 + + +@pytest.mark.skipif( + not HAS_PANDAS or not HAS_NUMPY, + reason="pandas and numpy are required", +) +def test_dataframe_with_integer_columns() -> None: + """Regression: pd.DataFrame(np.random.randn(3, 3)) has integer column names. + + narwhals interprets df[0] as row selection, not column selection, causing + AttributeError: 'DataFrame' object has no attribute 'dtype'. + """ + import numpy as np + import pandas as pd + + df = pd.DataFrame(np.random.randn(3, 3)) + # Must not raise + result = deterministic_dumps(df, hash_type="sha256") + assert isinstance(result, bytes) + + +@pytest.mark.skipif( + not HAS_PANDAS or not HAS_NUMPY, + reason="pandas and numpy are required", +) +def test_dataframe_same_data_same_hash() -> None: + """Two DataFrames with identical data must produce the same hash.""" + import numpy as np + import pandas as pd + + data = np.arange(9, dtype=float).reshape(3, 3) + df1 = pd.DataFrame(data) + df2 = pd.DataFrame(data.copy()) + + assert deterministic_dumps(df1, hash_type="sha256") == deterministic_dumps( + df2, hash_type="sha256" + ) + + +def test_bytearray_encodes_to_deterministic_bytes() -> None: + """A bytearray encodes to deterministic bytes that vary by content.""" + a = common_container_to_bytes(bytearray(b"abc")) + assert type(a) is bytes + assert a == common_container_to_bytes(bytearray(b"abc")) + assert a != common_container_to_bytes(bytearray(b"xyz")) + + +def test_bytearray_does_not_collide_with_equal_bytes() -> None: + """bytearray and bytes of equal content sign under distinct labels.""" + assert common_container_to_bytes(bytearray(b"abc")) != ( + common_container_to_bytes(b"abc") + ) diff --git a/tests/_save/test_external_decorators.py b/tests/_save/test_external_decorators.py new file mode 100644 index 00000000000..82eaee1043c --- /dev/null +++ b/tests/_save/test_external_decorators.py @@ -0,0 +1,405 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import sys +import textwrap + +from marimo._runtime.commands import ExecuteCellCommand +from marimo._runtime.runtime import Kernel +from tests.conftest import ExecReqProvider + + +class TestDecoratorImports: + @staticmethod + def test_import_alias_hash_path(app) -> None: + """Test that imported cached functions with module aliases have correct hash paths.""" + for module in list(sys.modules.keys()): + if module.startswith("tests._save.external_decorators"): + del sys.modules[module] + + with app.setup: + import marimo as mo + import tests._save.external_decorators.module_1 as my_module + from tests._save.external_decorators.transitive_imports import ( + doesnt_have_namespace as external_func, + ) + + @app.function + @mo.cache + def doesnt_have_namespace() -> None: + # Just replicating the function in external_func + return my_module.__version__ + + @app.cell + def check_hash_paths() -> None: + local_func = doesnt_have_namespace + # Both functions access the same module with the same alias + external_result = external_func() + local_result = local_func() + + # Results should be the same (both return "1.0.0") + assert external_result == local_result == "1.0.0" + + # Hashes should be equal (same code, same module accessed) + external_name = external_func._loader().name + local_name = local_func._loader().name + + assert external_name == local_name, ( + f"Hashes should be equal for same code and module, " + f"got {external_name} != {local_name}" + ) + + @staticmethod + def test_has_shared_import(app) -> None: + with app.setup: + import marimo as mo + from tests._save.external_decorators.transitive_imports import ( + has_import, + ) + + @app.cell + def has_dep_works() -> tuple[int]: + # matches test + use mo for lint + assert has_import() == len([mo]) + + @staticmethod + def test_doesnt_have_shared_import(app) -> None: + with app.setup: + from tests._save.external_decorators.transitive_imports import ( + doesnt_have_import, + ) + + @app.cell + def doesnt_have_dep_works() -> tuple[int]: + # Counts modules on call. + assert doesnt_have_import() == 2 + + @staticmethod + def test_has_dep_with_differing_name_works(app) -> None: + for module in list(sys.modules.keys()): + if module.startswith("tests._save.external_decorators"): + del sys.modules[module] + + with app.setup: + import marimo as mo + import tests._save.external_decorators.module_0 as my_module + from tests._save.external_decorators.transitive_imports import ( + doesnt_have_namespace as other, + doesnt_have_namespace_pinned as other_pinned, + ) + + @app.function + @mo.cache(pin_modules=True) + def doesnt_have_namespace_pinned() -> None: + return my_module.__version__ + + @app.function + @mo.cache + def doesnt_have_namespace() -> None: + return my_module.__version__ + + @app.cell + def has_dep_with_differing_name_works() -> tuple[int]: + assert other() != my_module.__version__ + other_hash = other._last_hash + assert doesnt_have_namespace() == my_module.__version__ + # By virtue of backwards compatibility, this is true. + # TODO: Negate and fix. + assert other_hash == doesnt_have_namespace._last_hash + + @app.cell + def has_dep_with_differing_name_works_pinned() -> tuple[int]: + assert other_pinned() != my_module.__version__ + other_hash_pinned = other_pinned._last_hash + assert doesnt_have_namespace_pinned() == my_module.__version__ + assert other_hash_pinned != doesnt_have_namespace_pinned._last_hash + + @staticmethod + async def test_decorator_in_kernel( + lazy_kernel: Kernel, exec_req: ExecReqProvider + ) -> None: + # The `@mo.cache` functions in `transitive_imports` bind their + # caching context the first time the module is imported. If that + # first import happens inside a kernel cell (as it would below), + # they bind to the kernel's globals instead of their own module + # scope, corrupting their pinned hash. Import the module here — + # outside any cell execution — so the external binding is correct + # regardless of test execution order (e.g. under pytest-xdist, + # where this test may run before its siblings that would + # otherwise prime this state). + for module in list(sys.modules.keys()): + if module.startswith("tests._save.external_decorators"): + del sys.modules[module] + import tests._save.external_decorators.transitive_imports # noqa: F401 + + k = lazy_kernel + await k.run( + [ + ExecuteCellCommand( + cell_id="setup", + code=textwrap.dedent( + """ + import marimo as mo + import tests._save.external_decorators.module_0 as my_module + from tests._save.external_decorators.transitive_imports import ( + doesnt_have_namespace as other, + doesnt_have_namespace_pinned as other_pinned, + ) + from tests._save.external_decorators.transitive_imports import ( + doesnt_have_import, + ) + from tests._save.external_decorators.transitive_imports import has_import + """ + ), + ), + exec_req.get( + """ + @mo.cache(pin_modules=True) + def doesnt_have_namespace_pinned() -> None: + return my_module.__version__ + """ + ), + exec_req.get( + """ + @mo.cache + def doesnt_have_namespace() -> None: + return my_module.__version__ + """ + ), + exec_req.get( + """ + assert has_import() == 1 + assert doesnt_have_import() == 2 + assert other() != my_module.__version__ + other_hash = other._last_hash + assert doesnt_have_namespace() == my_module.__version__ + # By virtue of backwards compatibility, this is true. + # TODO: Negate and fix. + assert other_hash == doesnt_have_namespace._last_hash + + assert other_pinned() != my_module.__version__ + other_hash_pinned = other_pinned._last_hash + assert doesnt_have_namespace_pinned() == my_module.__version__ + assert other_hash_pinned != doesnt_have_namespace_pinned._last_hash + resolved = True + """ + ), + ] + ) + assert k.globals.get("resolved", False), k.stderr + + +class TestDecoratorTransitiveFns: + @staticmethod + async def test_impure_decorator_with_pure_dependencies(app) -> None: + with app.setup: + from tests._save.external_decorators.transitive_wrappers_1 import ( + pure_wrapped_impure, + ) + from tests._save.external_decorators.transitive_wrappers_2 import ( + pure_wrapped_impure as pure_wrapped_impure_2, + ) + + @app.cell + def _(): + result1 = pure_wrapped_impure() + hash1 = pure_wrapped_impure._last_hash + cache_type1 = pure_wrapped_impure.base_block.cache_type + return result1, hash1, cache_type1 + + @app.cell + def _(): + result2 = pure_wrapped_impure_2() + hash2 = pure_wrapped_impure_2._last_hash + cache_type2 = pure_wrapped_impure_2.base_block.cache_type + return result2, hash2, cache_type2 + + @app.cell + def check_results( + result1, result2, hash1, hash2, cache_type1, cache_type2 + ): + assert result1 == 1 + assert result2 == 2 + + # The decorator itself is pure, but the function has impure dependencies + # This should use ExecutionPath hashing, not ContentAddressed + assert cache_type1 == "ExecutionPath", ( + f"Expected ExecutionPath, got {cache_type1}" + ) + assert cache_type2 == "ExecutionPath", ( + f"Expected ExecutionPath, got {cache_type2}" + ) + + # Hashes should be different because the execution path changed + # (due to different impure_dependency) + assert hash1 != hash2, ( + f"Expected different hashes for different impure dependencies, " + f"got {hash1} == {hash2}" + ) + + @staticmethod + async def test_pure_decorator_with_impure_dependencies(app) -> None: + with app.setup: + from tests._save.external_decorators.transitive_wrappers_1 import ( + impure_wrapped_pure, + ) + from tests._save.external_decorators.transitive_wrappers_2 import ( + impure_wrapped_pure as impure_wrapped_pure_2, + ) + + @app.cell + def _(): + result1 = impure_wrapped_pure() + hash1 = impure_wrapped_pure._last_hash + cache_type1 = impure_wrapped_pure.base_block.cache_type + + @app.cell + def _(): + result2 = impure_wrapped_pure_2() + hash2 = impure_wrapped_pure_2._last_hash + cache_type2 = impure_wrapped_pure_2.base_block.cache_type + + @app.cell + def check_results( + result1, result2, hash1, hash2, cache_type1, cache_type2 + ) -> None: + assert result1 == 42 + assert result2 == 42 + + # The decorator itself is pure, but the function has impure dependencies + # This should use ExecutionPath hashing, not ContentAddressed + assert cache_type1 == "ExecutionPath", ( + f"Expected ExecutionPath, got {cache_type1}" + ) + assert cache_type2 == "ExecutionPath", ( + f"Expected ExecutionPath, got {cache_type2}" + ) + + # Hashes should be different because the execution path changed + # (due to different impure_dependency) + assert hash1 != hash2, ( + f"Expected different hashes for different impure dependencies, " + f"got {hash1} == {hash2}" + ) + + +class TestAsExternalApp: + @staticmethod + async def test_as_external_app(app) -> None: + with app.setup: + from tests._save.external_decorators.app import ( + app as ex_app, + ) + + @app.cell + def _(): + _, defs = ex_app.run() + assert defs["bar"] == 2 + assert defs["cache"](1) == 2 + assert len(defs["x"]) == 0 + defs["x"].append(1) + _, defs = ex_app.run() + assert len(defs["x"]) == 1 + return + + @staticmethod + async def test_as_external_app_in_kernel( + lazy_kernel: Kernel, exec_req: ExecReqProvider + ) -> None: + k = lazy_kernel + await k.run( + [ + ExecuteCellCommand( + cell_id="setup", + code=textwrap.dedent( + """ + from tests._save.external_decorators.app import ( + app as ex_app, + ) + """ + ), + ), + exec_req.get( + """ + _, defs = ex_app.run() + assert defs["bar"] == 2 + assert defs["cache"](1) == 2 + resolved = True + """ + ), + ] + ) + assert k.globals.get("resolved", False), k.stderr + + @staticmethod + async def test_as_external_app_embedded(app) -> None: + with app.setup: + from tests._save.external_decorators.app import ( + app as ex_app, + ) + + @app.cell + async def _(): + r1 = await ex_app.embed() + assert r1.defs["bar"] == 2 + assert r1.defs["cache"](1) == 2 + return + + @staticmethod + async def test_as_external_app_embedded_cloned(app) -> None: + with app.setup: + from tests._save.external_decorators.app import ( + app as ex_app, + ) + + @app.cell + async def _(): + r2 = await ex_app.clone().embed() + assert r2.defs["bar"] == 2 + assert r2.defs["cache"](1) == 2 + return + + @staticmethod + async def test_as_external_app_embedded_in_kernel( + lazy_kernel: Kernel, exec_req: ExecReqProvider + ) -> None: + k = lazy_kernel + await k.run( + [ + ExecuteCellCommand( + cell_id="setup", + code=textwrap.dedent( + """ + from tests._save.external_decorators.app import ( + app as ex_app, + ) + """ + ), + ), + exec_req.get( + """ + r1 = await ex_app.embed() + assert r1.defs["bar"] == 2 + assert r1.defs["cache"](1) == 2 + """ + ), + exec_req.get( + """ + r2 = await ex_app.clone().embed() + assert r2.defs["bar"] == 2 + assert r2.defs["cache"](1) == 2 + """ + ), + exec_req.get( + """ + r1, r2 + resolved = True + """ + ), + ] + ) + assert k.globals.get("resolved", False), k.stderr + assert k.globals["r1"].defs["bar"] == 2 + assert k.globals["r1"].defs["cache"](1) == 2 + assert k.globals["r2"].defs["bar"] == 2 + assert k.globals["r2"].defs["cache"](1) == 2 diff --git a/tests/_save/test_hash.py b/tests/_save/test_hash.py index 2e35bcd8e53..88567b80f5e 100644 --- a/tests/_save/test_hash.py +++ b/tests/_save/test_hash.py @@ -1,25 +1,32 @@ -# Copyright 2024 Marimo. All rights reserved. +# Copyright 2026 Marimo. All rights reserved. from __future__ import annotations +import asyncio +import dataclasses +import os +import subprocess +import sys +import textwrap from typing import Any import pytest from marimo._ast.app import App from marimo._dependencies.dependencies import DependencyManager +from marimo._runtime.commands import ExecuteStaleCellsCommand +from marimo._runtime.runtime import Kernel +from marimo._save.hash import BlockHasher +from tests.conftest import ExecReqProvider class TestHash: @staticmethod - def test_pure_hash() -> None: - app = App() - app._anonymous_file = True - + def test_pure_hash(app) -> None: @app.cell def one() -> tuple[int]: from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader with persistent_cache(name="one", _loader=MockLoader()) as cache: Y = [1, 2, 3] @@ -27,17 +34,12 @@ def one() -> tuple[int]: assert cache._cache.cache_type == "Pure" return Y, Z - app.run() - @staticmethod - def test_content_hash() -> None: - app = App() - app._anonymous_file = True - + def test_content_hash(app) -> None: @app.cell def one() -> tuple[int]: from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader a = 1 with persistent_cache(name="one", _loader=MockLoader()) as cache: @@ -45,23 +47,18 @@ def one() -> tuple[int]: assert cache._cache.cache_type == "ContentAddressed" return (Y,) - app.run() - # Note: Hash may change based on byte code, so pin to particular version @staticmethod @pytest.mark.skipif( "sys.version_info < (3, 12) or sys.version_info >= (3, 13)" ) - def test_content_reproducibility() -> None: - app = App() - app._anonymous_file = True - + def test_content_reproducibility(app) -> None: @app.cell def load() -> tuple[Any]: from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader - expected_hash = "haIqC9yzlTaNo-ClmY11Kvtiv08oQPz3-SlnOLfhJYM" + expected_hash = "RSccsMCC0dBqvdcnBN1mdxlvKUr4zzR_qupKBW_P_qE" return expected_hash, persistent_cache, MockLoader @@ -94,17 +91,12 @@ def two(expected_hash, persistent_cache, MockLoader) -> tuple[int]: Z = 11 return (Z,) - app.run() - @staticmethod - def test_execution_hash() -> None: - app = App() - app._anonymous_file = True - + def test_execution_hash(app) -> None: @app.cell def one() -> tuple[int]: from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader non_primitive = [object()] @@ -113,23 +105,73 @@ def one() -> tuple[int]: assert cache._cache.cache_type == "ContextExecutionPath" return (Y,) - app.run() + @staticmethod + def test_single_expression_execution_hash() -> None: + # The final expression is treated a tiny bit differently than marimo, + # which has led to bugs. + app = App() + app._anonymous_file = True + app._pytest_rewrite = True + + @app.cell + def _(): + non_primitive = [object()] + return non_primitive + + @app.cell + def _(non_primitive) -> tuple[int]: + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + with persistent_cache(name="test", _loader=MockLoader()) as cache: + Y = 8 + len(non_primitive) + assert cache._cache.cache_type == "ExecutionPath" + return (Y,) + + _, defs = app.run() + + app2 = App() + app2._anonymous_file = True + app2._pytest_rewrite = True + + @app2.cell + def _(): + non_primitive = [object(), object()] + return non_primitive + + @app2.cell + def _(non_primitive) -> tuple[int]: + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + with persistent_cache(name="test", _loader=MockLoader()) as cache: + Y = 8 + len(non_primitive) + assert cache._cache.cache_type == "ExecutionPath" + return (Y,) + + _, defs2 = app2.run() + + assert defs["cache"]._cache.hash != defs2["cache"]._cache.hash @staticmethod @pytest.mark.skipif( "sys.version_info < (3, 12) or sys.version_info >= (3, 13)" ) - def test_execution_reproducibility() -> None: - app = App() - app._anonymous_file = True + def test_execution_reproducibility(app) -> None: + # Rewrite changes the AST, breaking the hash + app._pytest_rewrite = False @app.cell - def load() -> tuple[int]: + def imports() -> tuple[int]: from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader + return persistent_cache, MockLoader + + @app.cell + def load() -> tuple[int]: shared = [None, object()] - return persistent_cache, MockLoader, shared + return shared @app.cell def one(persistent_cache, MockLoader, shared) -> tuple[int]: @@ -142,7 +184,7 @@ def one(persistent_cache, MockLoader, shared) -> tuple[int]: # Cannot be reused/ shared, because it will change the hash. assert ( _cache._cache.hash - == "V_BAVE7PI97W7iec44GYXD69pebyztj7R3jgGFAnnEM" + == "r2_DqjuluzDmVs1wo1HZCNWz9wApoSSJlnXeYihOaNI" ), _cache._cache.hash assert _cache._cache.cache_type == "ContextExecutionPath" return @@ -163,48 +205,162 @@ def two(persistent_cache, MockLoader, shared) -> tuple[int]: assert _X == 7 assert ( _cache._cache.hash - == "V_BAVE7PI97W7iec44GYXD69pebyztj7R3jgGFAnnEM" + == "r2_DqjuluzDmVs1wo1HZCNWz9wApoSSJlnXeYihOaNI" ), _cache._cache.hash assert _cache._cache.cache_type == "ContextExecutionPath" # and a post block difference Z = 11 return (Z,) - app.run() - @staticmethod - def test_transitive_content_hash() -> None: - app = App() - app._anonymous_file = True + @pytest.mark.skipif( + "sys.version_info < (3, 12) or sys.version_info >= (3, 13)" + ) + def test_execution_reproducibility_different_cell_order(app) -> None: + # NB. load is last for cell order difference. + # Rewrite changes the AST, breaking the hash + app._pytest_rewrite = False @app.cell - def load() -> tuple[int]: - from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + def one(persistent_cache, MockLoader, shared) -> tuple[int]: + _a = [1, object()] + with persistent_cache( + name="one", _loader=MockLoader(data={"_X": 7}) + ) as _cache: + _X = 10 + _a[0] - len(shared) # Comment + assert _X == 7 + # Cannot be reused/ shared, because it will change the hash. + assert ( + _cache._cache.hash + == "r2_DqjuluzDmVs1wo1HZCNWz9wApoSSJlnXeYihOaNI" + ), _cache._cache.hash + assert _cache._cache.cache_type == "ContextExecutionPath" + return - shared = [None, object()] - return persistent_cache, MockLoader, shared + @app.cell + def two(persistent_cache, MockLoader, shared) -> tuple[int]: + # The same as cell one, but with this comment + _a = [ + 1, # Comment + object(), + ] + # Some white space + with persistent_cache( + name="one", _loader=MockLoader(data={"_X": 7}) + ) as _cache: + # More Comments + _X = 10 + _a[0] - len(shared) + assert _X == 7 + assert ( + _cache._cache.hash + == "r2_DqjuluzDmVs1wo1HZCNWz9wApoSSJlnXeYihOaNI" + ), _cache._cache.hash + assert _cache._cache.cache_type == "ContextExecutionPath" + # and a post block difference + Z = 11 + return (Z,) @app.cell - def one(persistent_cache, MockLoader, shared) -> tuple[int]: - _a = len(shared) - with persistent_cache(name="one", _loader=MockLoader()) as cache: - Y = 8 + _a - assert cache._cache.cache_type == "ContentAddressed" - return (Y,) + def imports() -> tuple[int]: + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader - app.run() + return persistent_cache, MockLoader + + @app.cell + def load() -> tuple[int]: + shared = [None, object()] + return shared @staticmethod - def test_function_ui_content_hash() -> None: - app = App() - app._anonymous_file = True + def test_transitive_content_hash() -> None: + app1 = App() + app1._anonymous_file = True + app1._pytest_rewrite = True + + @app1.cell + def _(): + import marimo as mo + + return mo + + @app1.cell + def _(): + value = False + + @app1.cell + def cache_1(args, mo): + with mo.persistent_cache("cache_bug") as cache: + output = args.value + assert cache.cache_type == "ExecutionPath" + from marimo._runtime.context.types import get_context + + ctx = get_context() + return ctx, output + + @app1.cell + def _(value): + class Unhashable: + def __eq__(self, other): + return isinstance(other, Unhashable) + + __hash__ = None # Makes instances unhashable + + args = Unhashable() + args.value = value + return (args,) + + app2 = App() + app2._anonymous_file = True + app2._pytest_rewrite = True + + @app2.cell + def _(): + import marimo as mo + + return mo + + @app2.cell + def _(): + value = True + + @app2.cell + def cache_2(args, mo): + with mo.persistent_cache("cache_bug") as cache: + output = args.value + assert cache.cache_type == "ExecutionPath" + from marimo._runtime.context.types import get_context + + ctx = get_context() + return ctx, output + + @app2.cell + def _(value): + class Unhashable: + def __eq__(self, other): + return isinstance(other, Unhashable) + + __hash__ = None # Makes instances unhashable + + args = Unhashable() + args.value = value + return (args,) + _, defs1 = app1.run() + _, defs2 = app2.run() + + assert len(defs1["ctx"].cell_lifecycle_registry.registry) == 2 + assert len(defs2["ctx"].cell_lifecycle_registry.registry) == 2 + assert defs1["cache"]._cache.hash != defs2["cache"]._cache.hash + assert defs1["output"] != defs2["output"] + + @staticmethod + def test_function_ui_content_hash(app) -> None: @app.cell def load() -> tuple[Any]: import marimo as mo from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader slider = mo.ui.slider(1, 10) @@ -229,18 +385,13 @@ def two(persistent_cache, MockLoader, shared, cache) -> tuple[Any]: assert cache2._cache.hash != cache._cache.hash return (cache2,) - app.run() - @staticmethod - def test_function_state_content_hash() -> None: - app = App() - app._anonymous_file = True - + def test_function_state_content_hash(app) -> None: @app.cell def load() -> tuple[Any]: import marimo as mo from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader state, set_state = mo.state(0) @@ -269,13 +420,8 @@ def two( assert cache2._cache.hash != cache._cache.hash return (cache2,) - app.run() - @staticmethod - def test_function_state_content_hash_distinct() -> None: - app = App() - app._anonymous_file = True - + def test_function_state_content_hash_distinct(app) -> None: @app.cell def load() -> tuple[Any]: import marimo as mo @@ -294,18 +440,13 @@ def check_type(v) -> str: assert "State" in b, b return a, b - app.run() - @staticmethod - def test_transitive_execution_path_when_state_dependent() -> None: - app = App() - app._anonymous_file = True - + def test_transitive_execution_path_when_state_dependent(app) -> None: @app.cell - def load() -> tuple[Any]: + def load() -> tuple[Any, ...]: import marimo as mo from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader state, set_state = mo.state(0) @@ -336,18 +477,13 @@ def two( assert cache2._cache.hash != cache._cache.hash return (cache2,) - app.run() - @staticmethod - def test_version_pinning() -> None: - app = App() - app._anonymous_file = True - + def test_version_pinning(app) -> None: @app.cell def load() -> tuple[Any]: import marimo as mo from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader mo.__version__ = "0.0.0" @@ -374,7 +510,271 @@ def two(mo, persistent_cache, MockLoader, cache) -> tuple[Any]: assert cache2._cache.hash != cache._cache.hash return (cache2,) - app.run() + @staticmethod + def test_builtins(app) -> None: + @app.cell + def _(): + import time + from time import sleep + + import marimo as mo + + return mo, sleep, time + + @app.cell + def _(mo, sleep): + @mo.cache + def direct(): + _ = sleep + return 42 + + return + + @app.cell + def _(mo, time): + @mo.cache + def module(): + _ = time.sleep + return 42 + + return + + @app.cell + def _(direct, module): + assert direct() == module(), "direct() != module()" + + +class TestHashMemo: + @staticmethod + @pytest.mark.skipif( + not DependencyManager.numpy.has(), + reason="optional dependencies not installed", + ) + def test_data_primitive_memoized_across_cells(app) -> None: + """Data primitives should be memoized — same array used in two cells + should only be serialized once.""" + + @app.cell + def load() -> tuple[Any]: + import numpy as np + + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + arr = np.ones((64, 64)) + return MockLoader, persistent_cache, arr, np + + @app.cell + def one(MockLoader, persistent_cache, arr, np) -> tuple[Any]: + from marimo._runtime.context.types import get_context + + ctx = get_context() + with persistent_cache(name="one", _loader=MockLoader()) as c1: + _v = np.sum(arr) + # After hashing, arr should be memoized + assert len(ctx.cache.hash_memo) > 0 + return c1, ctx + + @app.cell + def two(MockLoader, persistent_cache, arr, np, c1, ctx) -> None: + memo_before = dict(ctx.cache.hash_memo) + with persistent_cache(name="two", _loader=MockLoader()) as c2: + _v = np.sum(arr) + # Same arr, so memo should have been used (same entry) + assert c1._cache.hash == c2._cache.hash + assert memo_before == dict(ctx.cache.hash_memo) + + @staticmethod + @pytest.mark.skipif( + not DependencyManager.numpy.has(), + reason="optional dependencies not installed", + ) + def test_same_cell_def_not_memoized(app) -> None: + """Variables defined by the current cell should not use memo + (values can mutate within a cell).""" + + @app.cell + def one() -> tuple[Any]: + import numpy as np + + from marimo._runtime.context.types import get_context + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + arr = np.ones((4, 4)) + with persistent_cache(name="one", _loader=MockLoader()) as c: + _v = np.sum(arr) + ctx = get_context() + # arr is defined in this cell, so should NOT be memoized + assert len(ctx.cache.hash_memo) == 0 + return c, ctx + + @staticmethod + def test_list_mutation_not_stale(app) -> None: + """Lists are not memoized, so in-place mutation produces different + hashes.""" + + @app.cell + def one() -> tuple[Any]: + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + data = [1, 2, 3] + with persistent_cache(name="one", _loader=MockLoader()) as c1: + _v = sum(data) + data.append(4) + with persistent_cache(name="two", _loader=MockLoader()) as c2: + _v = sum(data) + assert c1._cache.hash != c2._cache.hash + + @staticmethod + def test_dict_mutation_not_stale(app) -> None: + """Dicts are not memoized, so mutation produces different hashes.""" + + @app.cell + def one() -> tuple[Any]: + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + data = {"a": 1} + with persistent_cache(name="one", _loader=MockLoader()) as c1: + _v = sum(data.values()) + data["b"] = 2 + with persistent_cache(name="two", _loader=MockLoader()) as c2: + _v = sum(data.values()) + assert c1._cache.hash != c2._cache.hash + + @staticmethod + @pytest.mark.skipif( + not DependencyManager.numpy.has(), + reason="optional dependencies not installed", + ) + def test_memo_with_cached_function(app) -> None: + """Memoization works with @mo.cache deferred hashing.""" + + @app.cell + def load() -> tuple[Any]: + import numpy as np + + import marimo as mo + + arr = np.ones((32, 32)) + return mo, np, arr + + @app.cell + def one(mo, np, arr) -> tuple[Any]: + @mo.cache + def compute(x): + return np.sum(x) + + r1 = compute(arr) + r2 = compute(arr) + assert r1 == r2 + assert compute.hits == 1 + return (compute,) + + @staticmethod + @pytest.mark.skipif( + not DependencyManager.numpy.has(), + reason="optional dependencies not installed", + ) + def test_cached_fn_distinct_ndarray_args(app) -> None: + """Distinct ndarray args vs kwargs must not collide.""" + + @app.cell + def load() -> tuple[Any]: + import numpy as np + + import marimo as mo + + return mo, np + + @app.cell + def one(mo, np) -> tuple[Any]: + @mo.cache + def f(a): + return float(a.sum()) + + assert f(np.array([1.0, 2.0, 3.0])) == 6.0 + # Check alternative arg path to ensure no collision between args and kwargs. + assert f(np.array([4.0, 5.0, 6.0])) == 15.0 + # Keyword form exercises the same arg path. + assert f(a=np.array([7.0, 8.0, 9.0])) == 24.0 + assert f.hits == 0 + assert f.misses == 3 + return (f,) + + @staticmethod + @pytest.mark.skipif( + not DependencyManager.numpy.has(), + reason="optional dependencies not installed", + ) + def test_lifecycle_cleanup(app) -> None: + """HashMemoCleanup clears memo when defining cell is disposed.""" + + @app.cell + def load() -> tuple[Any]: + import numpy as np + + from marimo._runtime.context.types import get_context + from marimo._save.cache import HashMemoCleanup + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + arr = np.ones((4, 4)) + return ( + MockLoader, + persistent_cache, + arr, + np, + get_context, + HashMemoCleanup, + ) + + @app.cell + def one( + MockLoader, persistent_cache, arr, np, get_context, HashMemoCleanup + ) -> tuple[Any]: + with persistent_cache(name="one", _loader=MockLoader()) as c: + _v = np.sum(arr) + ctx = get_context() + assert len(ctx.cache.hash_memo) > 0 + # Simulate lifecycle disposal — should clear memo + cleanup = HashMemoCleanup() + cleanup.dispose(ctx, deletion=False) + assert len(ctx.cache.hash_memo) == 0 + + @staticmethod + @pytest.mark.skipif( + not DependencyManager.numpy.has(), + reason="optional dependencies not installed", + ) + def test_globals_mutation_stale_memo(app) -> None: + """Known limitation: mutating a data primitive via globals() bypasses + lifecycle cleanup, producing a stale memo hit. This is expected (but + highly discouraged) since globals() sidesteps marimo's reactivity — but + we capture the behavior so it doesn't silently change.""" + + @app.cell + def load() -> tuple[Any]: + import numpy as np + + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + arr = np.ones((4, 4)) + return MockLoader, persistent_cache, arr, np + + @app.cell + def one(MockLoader, persistent_cache, arr, np) -> tuple[Any]: + with persistent_cache(name="one", _loader=MockLoader()) as c1: + _v = np.sum(arr) + # Mutate via globals — no lifecycle disposal triggered + globals()["arr"] = np.zeros((4, 4)) + with persistent_cache(name="two", _loader=MockLoader()) as c2: + _v = np.sum(arr) + # Stale memo: hashes match even though arr changed + assert c1._cache.hash == c2._cache.hash class TestDataHash: @@ -388,18 +788,15 @@ class TestDataHash: @pytest.mark.skipif( "sys.version_info < (3, 12) or sys.version_info >= (3, 13)" ) - def test_numpy_hash() -> None: - app = App() - app._anonymous_file = True - + def test_numpy_hash(app) -> None: @app.cell def load() -> tuple[Any]: import numpy as np from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader - expected_hash = "w_Bjhpz2xMVQC6Y61GgqB8O80u_UyoJ-1xQmJU3j0Gg" + expected_hash = "zLpFb6ANG99kP-4yWoH4zdV_FfrUodnEom1tILpF55c" return MockLoader, persistent_cache, expected_hash, np @app.cell @@ -410,9 +807,9 @@ def one(MockLoader, persistent_cache, expected_hash, np) -> tuple[int]: one = _A assert _cache._cache.cache_type == "ContentAddressed" - assert ( - _cache._cache.hash == expected_hash - ), f"expected_hash != {_cache._cache.hash}" + assert _cache._cache.hash == expected_hash, ( + f"expected_hash != {_cache._cache.hash}" + ) return (one,) @app.cell @@ -424,9 +821,9 @@ def two(MockLoader, persistent_cache, expected_hash, np) -> tuple[int]: two = _A assert _cache._cache.cache_type == "ContentAddressed" - assert ( - _cache._cache.hash == expected_hash - ), f"expected_hash != {_cache._cache.hash}" + assert _cache._cache.hash == expected_hash, ( + f"expected_hash != {_cache._cache.hash}" + ) return (two,) @app.cell @@ -434,8 +831,6 @@ def three(one, two) -> None: assert one == two assert one == 512 - app.run() - @staticmethod @pytest.mark.skipif( not DependencyManager.has("jax"), @@ -446,18 +841,15 @@ def three(one, two) -> None: @pytest.mark.skipif( "sys.version_info < (3, 12) or sys.version_info >= (3, 13)" ) - def test_jax_hash() -> None: - app = App() - app._anonymous_file = True - + def test_jax_hash(app) -> None: @app.cell def load() -> tuple[Any]: from jax import numpy as np from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader - expected_hash = "aAL9QNoQIQ1zOJgm_xDbHG63Bc4Atnpn58pGW9x9A_A" + expected_hash = "IaLyzmZZ4nwXMveSQjMVAa682QAyd2O90iSeHJvlb44" return MockLoader, persistent_cache, expected_hash, np @app.cell @@ -468,9 +860,9 @@ def one(MockLoader, persistent_cache, expected_hash, np) -> tuple[int]: one = _A assert _cache._cache.cache_type == "ContentAddressed" - assert ( - _cache._cache.hash == expected_hash - ), f"expected_hash != {_cache._cache.hash}" + assert _cache._cache.hash == expected_hash, ( + f"expected_hash != {_cache._cache.hash}" + ) return (one,) @app.cell @@ -482,9 +874,9 @@ def two(MockLoader, persistent_cache, expected_hash, np) -> tuple[int]: two = _A assert _cache._cache.cache_type == "ContentAddressed" - assert ( - _cache._cache.hash == expected_hash - ), f"expected_hash != {_cache._cache.hash}" + assert _cache._cache.hash == expected_hash, ( + f"expected_hash != {_cache._cache.hash}" + ) return (two,) @app.cell @@ -492,8 +884,6 @@ def three(one, two) -> None: assert one == two assert one == 512 - app.run() - @staticmethod @pytest.mark.skipif( not DependencyManager.has("torch"), @@ -504,18 +894,15 @@ def three(one, two) -> None: @pytest.mark.skipif( "sys.version_info < (3, 12) or sys.version_info >= (3, 13)" ) - def test_torch_hash() -> None: - app = App() - app._anonymous_file = True - + def test_torch_hash(app) -> None: @app.cell def load() -> tuple[Any]: import torch from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader - expected_hash = "stIOtiKIn4yscvKd-uK6mbmZpWzzfGm8Ccz7mvnRrnI" + expected_hash = "6EJfKOu_iB6jpSTtCnUV1kjYy2u96m_w_3VzOUOn5Hg" return MockLoader, persistent_cache, expected_hash, torch @app.cell @@ -528,9 +915,9 @@ def one( one = _A assert _cache._cache.cache_type == "ContentAddressed" - assert ( - _cache._cache.hash == expected_hash - ), f"expected_hash != {_cache._cache.hash}" + assert _cache._cache.hash == expected_hash, ( + f"expected_hash != {_cache._cache.hash}" + ) return (one,) @app.cell @@ -544,9 +931,9 @@ def two( two = _A assert _cache._cache.cache_type == "ContentAddressed" - assert ( - _cache._cache.hash == expected_hash - ), f"expected_hash != {_cache._cache.hash}" + assert _cache._cache.hash == expected_hash, ( + f"expected_hash != {_cache._cache.hash}" + ) return (two,) @app.cell @@ -554,8 +941,6 @@ def three(one, two) -> None: assert one == two assert one == 512 - app.run() - @staticmethod @pytest.mark.skipif( not DependencyManager.has("torch"), @@ -566,20 +951,18 @@ def three(one, two) -> None: @pytest.mark.skipif( "sys.version_info < (3, 12) or sys.version_info >= (3, 13)" ) - def test_torch_device_hash() -> None: + def test_torch_device_hash(app) -> None: # Utilizing the "meta" device should give similar cross device behavior # as gpu. - app = App() - app._anonymous_file = True @app.cell def load() -> tuple[Any]: import torch from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader - expected_hash = "rTAh8yNbBbq9qkF1nGNUw4DXhZSxRqGe4ptbDh2AwBI" + expected_hash = "QIjIEzceYIH7WIvdIasBLceU3Ad40kYqTCSBnUmJZV4" return MockLoader, persistent_cache, expected_hash, torch @app.cell @@ -591,16 +974,14 @@ def one( _A = torch.sum(_a) one = _A - assert ( - _cache._cache.cache_type == "ContextExecutionPath" - ), _cache._cache.cache_type - assert ( - _cache._cache.hash == expected_hash - ), f"expected_hash != {_cache._cache.hash}" + assert _cache._cache.cache_type == "ContextExecutionPath", ( + _cache._cache.cache_type + ) + assert _cache._cache.hash == expected_hash, ( + f"expected_hash != {_cache._cache.hash}" + ) return (one,) - app.run() - @staticmethod @pytest.mark.skipif( not DependencyManager.has("skbio"), @@ -611,10 +992,7 @@ def one( @pytest.mark.skipif( "sys.version_info < (3, 12) or sys.version_info >= (3, 13)" ) - def test_skibio_hash() -> None: - app = App() - app._anonymous_file = True - + def test_skibio_hash(app) -> None: @app.cell def load() -> tuple[Any]: from copy import copy @@ -622,9 +1000,9 @@ def load() -> tuple[Any]: from skbio import DNA from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader - expected_hash = "ggxwHLzWcyDQltN_Zq0_zYVP_w86a9AAQLQwleAMuH8" + expected_hash = "1eEgTTthH-FyKbziqYse1ITogQUMat0JW1meFZtMWCI" return MockLoader, persistent_cache, expected_hash, DNA, copy @app.cell @@ -638,13 +1016,11 @@ def one( assert strand == sibling assert _cache._cache.cache_type == "ContentAddressed" - assert ( - _cache._cache.hash == expected_hash - ), f"expected_hash != {_cache._cache.hash}" + assert _cache._cache.hash == expected_hash, ( + f"expected_hash != {_cache._cache.hash}" + ) return (one,) - app.run() - @staticmethod @pytest.mark.skipif( not DependencyManager.has("pandas"), @@ -655,19 +1031,16 @@ def one( @pytest.mark.skipif( "sys.version_info < (3, 12) or sys.version_info >= (3, 13)" ) - def test_process_dataframe() -> None: - app = App() - app._anonymous_file = True - + def test_dataframe(app) -> None: @app.cell def load() -> tuple[Any]: import numpy as np import pandas as pd from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader - expected_hash = "wtrS6NoH2AOH3DnWm7wooK4Bgw8TmMotgMbiY0bu5as" + expected_hash = "ycCqtVaQAODpfHyimtlbxj1TIQB3WtLnhDIGq59yiqw" return MockLoader, persistent_cache, expected_hash, np, pd @app.cell @@ -685,9 +1058,9 @@ def one( one = _A assert _cache._cache.cache_type == "ContentAddressed" - assert ( - _cache._cache.hash == expected_hash - ), f"expected_hash != {_cache._cache.hash}" + assert _cache._cache.hash == expected_hash, ( + f"expected_hash != {_cache._cache.hash}" + ) return (one,) @app.cell @@ -702,9 +1075,9 @@ def two( two = _A assert _cache._cache.cache_type == "ContentAddressed" - assert ( - _cache._cache.hash == expected_hash - ), f"expected_hash != {_cache._cache.hash}" + assert _cache._cache.hash == expected_hash, ( + f"expected_hash != {_cache._cache.hash}" + ) return (two,) @app.cell @@ -712,8 +1085,6 @@ def three(one, two) -> None: assert one == two assert one == 14 - app.run() - @staticmethod @pytest.mark.skipif( not DependencyManager.has("pandas"), @@ -724,19 +1095,16 @@ def three(one, two) -> None: @pytest.mark.skipif( "sys.version_info < (3, 12) or sys.version_info >= (3, 13)" ) - def test_process_dataframe_object() -> None: - app = App() - app._anonymous_file = True - + def test_dataframe_object(app) -> None: @app.cell def load() -> tuple[Any]: import numpy as np import pandas as pd from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader - expected_hash = "n4KGJ3wrRHd6pDCyekTWZXShmtT_ZkDY4Wo3C6BXzh4" + expected_hash = "C9MbH1ov4US2mrm_T_clm4VpI9WT97tg5BGEpOAbF1g" return MockLoader, persistent_cache, expected_hash, np, pd @app.cell @@ -754,13 +1122,11 @@ def one( one = _A assert _cache._cache.cache_type == "ContextExecutionPath" - assert ( - _cache._cache.hash == expected_hash - ), f"expected_hash != {_cache._cache.hash}" + assert _cache._cache.hash == expected_hash, ( + f"expected_hash != {_cache._cache.hash}" + ) return (one,) - app.run() - @staticmethod @pytest.mark.skipif( not DependencyManager.has("polars"), @@ -771,18 +1137,15 @@ def one( @pytest.mark.skipif( "sys.version_info < (3, 12) or sys.version_info >= (3, 13)" ) - def test_process_polars_dataframe() -> None: - app = App() - app._anonymous_file = True - + def test_polars_dataframe(app) -> None: @app.cell def load() -> tuple[Any]: import polars as pl from marimo._save.save import persistent_cache - from tests._save.mocks import MockLoader + from tests._save.loaders.mocks import MockLoader - expected_hash = "rC6YiNsuaZKQ1JqMgSRa0iyEDwi7ZTl6InABjuM0RDY" + expected_hash = "DtWHQ972QmRo2kBlzgoGQDi-bRnRknWZsSFk_rT4lRA" return MockLoader, persistent_cache, expected_hash, pl @app.cell @@ -798,9 +1161,9 @@ def one(MockLoader, persistent_cache, expected_hash, pl) -> tuple[int]: one = _A assert _cache._cache.cache_type == "ContentAddressed" - assert ( - _cache._cache.hash == expected_hash - ), f"expected_hash != {_cache._cache.hash}" + assert _cache._cache.hash == expected_hash, ( + f"expected_hash != {_cache._cache.hash}" + ) return (one,) @app.cell @@ -813,9 +1176,9 @@ def two(MockLoader, persistent_cache, expected_hash, pl) -> tuple[int]: two = _A assert _cache._cache.cache_type == "ContentAddressed" - assert ( - _cache._cache.hash == expected_hash - ), f"expected_hash != {_cache._cache.hash}" + assert _cache._cache.hash == expected_hash, ( + f"expected_hash != {_cache._cache.hash}" + ) return (two,) @app.cell @@ -823,4 +1186,1709 @@ def three(one, two) -> None: assert one == two assert one == 14 - app.run() + @staticmethod + @pytest.mark.skipif( + not DependencyManager.has("polars"), + reason="optional dependencies not installed", + ) + @pytest.mark.skipif( + "sys.version_info < (3, 12) or sys.version_info >= (3, 13)" + ) + def test_polars_object(app) -> None: + @app.cell + def load() -> tuple[Any]: + import polars as pl + + from marimo._save.save import persistent_cache + from tests._save.loaders.mocks import MockLoader + + expected_hash = "1HlOXWU-oQ5MNB5B9YRNLnHcn3I8sngJvLuHV_ICLGY" + return MockLoader, persistent_cache, expected_hash, pl + + @app.cell + def two(MockLoader, persistent_cache, expected_hash, pl) -> tuple[int]: + _a = { + "A": [2, 8, 18], + "B": ["a", "a", "a"], + "C": [14, 16, 18], + } + _a = pl.DataFrame(_a) + + with persistent_cache(name="two", _loader=MockLoader()) as _cache: + _A = _a.select(pl.col("A").sum()).item() + + assert _cache._cache.cache_type == "ContextExecutionPath" + assert _cache._cache.hash == expected_hash, ( + f"expected_hash != {_cache._cache.hash}" + ) + assert _A == 28 + return (two,) + + +class TestCustomHash: + @staticmethod + @pytest.mark.skipif( + not DependencyManager.has("pydantic"), + reason="optional dependencies not installed", + ) + @pytest.mark.skipif( + "sys.version_info < (3, 12) or sys.version_info >= (3, 13)" + ) + async def test_pydantic_model_hash(app: App) -> None: + with app.setup: + import pydantic + + import marimo as mo + + @app.class_definition + class Model(pydantic.BaseModel): + a: int + b: str + + @app.function + @mo.cache + def use_model(model: Model) -> tuple[int, str]: + return model.a, model.b + + @app.cell + def _check_deterministic() -> None: + assert use_model.hits == 0 + model = Model(a=1, b="test") + a, b = use_model(model) + initial_hash = use_model._last_hash + a, b = use_model(model) # Cache hit + assert use_model.hits == 1 + model_copy = Model(a=1, b="test") + A, B = use_model(model_copy) # Cache hit, different instance + assert use_model.hits == 2 + assert (a, b) == (A, B) == (1, "test") + assert use_model._last_hash == initial_hash + + @app.cell + def _check_different(a: int, b: str, initial_hash: str) -> None: + diff_model = Model(a=2, b="test") + c, d = use_model(diff_model) + assert use_model.hits == 2 + assert (c, d) != (a, b) + assert initial_hash != use_model._last_hash + + +class TestDynamicHash: + @staticmethod + async def test_transitive_state_hash( + k: Kernel, exec_req: ExecReqProvider, tmp_path + ) -> None: + await k.run( + [ + exec_req.get("import marimo as mo; from pathlib import Path"), + exec_req.get("value, set_value = mo.state(False)"), + exec_req.get(""" + class Unhashable: + def __eq__(self, other): + return isinstance(other, Unhashable) + + __hash__ = None # Makes instances unhashable + + args = Unhashable() + args.value = value + """), + exec_req.get(f""" + with mo.persistent_cache("cache", save_path=Path("{tmp_path.as_posix()}")) as cache: + output = args.value + """), + ] + ) + assert not k.errors + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, k.stderr + assert not k.globals["cache"]._cache.hit + + hash_1 = k.globals["cache"]._cache.hash + output_1 = k.globals["output"]._value + + await k.run([exec_req.get("set_value(True)")]) + assert not k.errors + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, k.stderr + assert not k.globals["cache"]._cache.hit + + hash_2 = k.globals["cache"]._cache.hash + output_2 = k.globals["output"]._value + + assert hash_1 != hash_2 + assert output_1 != output_2 + + +class TestSideEffects: + @staticmethod + async def test_side_effect_cache_different( + k: Kernel, exec_req: ExecReqProvider, tmp_path + ) -> None: + await k.run( + [ + exec_req.get( + f'tmp_path_fixture = Path("{tmp_path.as_posix()}")' + ), + exec_req.get(""" + from tests._save.loaders.mocks import MockLoader + import marimo as mo + from pathlib import Path + + state, set_state = mo.state(0) + hashes = [] + """), + exec_req.get(""" + with mo.cache("prim") as prim_cache: + non_primitive = [object(), len(hashes)] + state + """), + exec_req.get(""" + with mo.persistent_cache("get_v", save_path=tmp_path_fixture) as v_cache: + v = non_primitive[1] + """), + ] + ) + await k.run( + [ + exec_req.get(""" + if len(hashes) < 1: + assert non_primitive[1] == 0 + set_state(1) + hashes.append((v_cache.cache_type, v_cache._cache.hash)) + hashes.append((prim_cache.cache_type, prim_cache._cache.hash)) + """), + ] + ) + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, k.stderr + v = k.globals["v"] + hashes = k.globals["hashes"] + non_primitive = k.globals["non_primitive"] + assert len(hashes) == 4 + assert hashes[1] != hashes[3] + assert hashes[0] != hashes[2] + assert non_primitive[1] == 2 == v + + @staticmethod + async def test_side_effect_cache_context( + k: Kernel, exec_req: ExecReqProvider, tmp_path + ) -> None: + await k.run( + [ + exec_req.get( + f'tmp_path_fixture = Path("{tmp_path.as_posix()}")' + ), + exec_req.get(""" + from tests._save.loaders.mocks import MockLoader + import marimo as mo + from pathlib import Path + + state, set_state = mo.state(0) + hashes = [] + """), + exec_req.get(""" + with mo.cache("prim") as prim_cache: + non_primitive = [object(), len(hashes)] + + with mo.persistent_cache("get_v", save_path=tmp_path_fixture) as v_cache: + v = non_primitive[1] + state + """), + ] + ) + await k.run( + [ + exec_req.get(""" + if len(hashes) < 1: + assert non_primitive[1] == 0 + set_state(1) + hashes.append((v_cache.cache_type, v_cache._cache.hash)) + hashes.append((prim_cache.cache_type, prim_cache._cache.hash)) + """), + ] + ) + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, k.stderr + v = k.globals["v"] + hashes = k.globals["hashes"] + non_primitive = k.globals["non_primitive"] + assert len(hashes) == 4 + assert hashes[1] != hashes[3] + assert hashes[0] != hashes[2] + assert non_primitive[1] == 2 == v + + @staticmethod + async def test_side_effect_exception( + k: Kernel, exec_req: ExecReqProvider, tmp_path + ) -> None: + await k.run( + [ + exec_req.get( + f'tmp_path_fixture = Path("{tmp_path.as_posix()}")' + ), + exec_req.get(""" + from tests._save.loaders.mocks import MockLoader + import marimo as mo + from pathlib import Path + + state, set_state = mo.state(1) + hashes = [] + """), + exec_req.get(""" + non_primitive = [object(), 0] + ref = 1 + 1 / state() # Throw an exception when 0 + non_primitive = [object(), 1] + """), + ] + ) + assert not k.stderr.messages, k.stderr + await k.run( + [ + req := exec_req.get(""" + with mo.persistent_cache("get_v", save_path=tmp_path_fixture) as v_cache: + v = non_primitive[1] + ref + """), + exec_req.get(""" + if len(hashes) < 1: + set_state(0) + hashes.append((v_cache.cache_type, v_cache._cache.hash, v)) + """), + ] + ) + await k.run([req]) + + assert not k.stdout.messages, k.stdout + assert "ZeroDivisionError" in str(k.stderr), k.stderr + + hashes = k.globals["hashes"] + non_primitive = k.globals["non_primitive"] + + v = k.globals["v"] + + assert len(hashes) == 2 + assert non_primitive[1] == 0 + assert v == 1 + assert hashes[0] != hashes[1] + + @staticmethod + async def test_side_effect_decorator_different( + k: Kernel, exec_req: ExecReqProvider + ) -> None: + await k.run( + [ + exec_req.get(""" + from tests._save.loaders.mocks import MockLoader + import marimo as mo + import weakref + + state, set_state = mo.state(0) + hashes = [] + """), + exec_req.get(""" + @mo.cache + def prim_cache(): + return [weakref.ref(object), len(hashes)] + non_primitive = prim_cache() + state + """), + exec_req.get(""" + @mo.cache + def v_cache(): + return non_primitive[1] + v = v_cache() + """), + ] + ) + await k.run( + [ + exec_req.get(""" + if len(hashes) < 1: + assert non_primitive[1] == 0 + set_state(1) + hashes.append(v_cache._last_hash) + hashes.append(prim_cache._last_hash) + """), + ] + ) + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, k.stderr + v = k.globals["v"] + hashes = k.globals["hashes"] + non_primitive = k.globals["non_primitive"] + assert len(hashes) == 4 + assert hashes[1] != hashes[3] + assert hashes[0] != hashes[2] + assert non_primitive[1] == 2 == v + + @staticmethod + async def test_side_effect_decorator_context( + k: Kernel, exec_req: ExecReqProvider + ) -> None: + # Actually doesn't test side effects, because there's + # no "context" level hash for functions. Placed here in case the + # functionality does change in the future. + await k.run( + [ + exec_req.get(""" + from tests._save.loaders.mocks import MockLoader + import marimo as mo + import weakref + + state, set_state = mo.state(0) + hashes = [] + """), + exec_req.get(""" + @mo.cache + def prim_cache(): + return [weakref.ref(object), len(hashes)] + non_primitive = prim_cache() + + @mo.cache + def v_cache(): + return non_primitive[1] + v = v_cache() + state + """), + ] + ) + await k.run( + [ + exec_req.get(""" + if len(hashes) < 1: + assert non_primitive[1] == 0 + set_state(1) + hashes.append(v_cache._last_hash) + hashes.append(prim_cache._last_hash) + """), + ] + ) + assert not k.stdout.messages, k.stdout + assert k.stderr.messages, k.stderr + assert "Content addressed hash could not be utilized" in str( + k.stderr + ), k.stderr + + @staticmethod + async def test_side_effect_decorator_exception( + k: Kernel, exec_req: ExecReqProvider, tmp_path + ) -> None: + await k.run( + [ + exec_req.get( + f'tmp_path_fixture = Path("{tmp_path.as_posix()}")' + ), + exec_req.get(""" + from tests._save.loaders.mocks import MockLoader + import marimo as mo + from pathlib import Path + + state, set_state = mo.state(1) + hashes = [] + """), + exec_req.get(""" + non_primitive = [object(), 0] + ref = 1 + 1 / state() # Throw an exception when 0 + non_primitive = [object(), 1] + """), + ] + ) + assert not k.stderr.messages, k.stderr + await k.run( + [ + req := exec_req.get(""" + @mo.cache + def v_cache(): + return non_primitive[1] + ref + v = v_cache() + """), + exec_req.get(""" + if len(hashes) < 1: + set_state(0) + hashes.append((v_cache._last_hash, v)) + """), + ] + ) + await k.run([req]) + + assert not k.stdout.messages, k.stdout + assert "ZeroDivisionError" in str(k.stderr), k.stderr + + hashes = k.globals["hashes"] + non_primitive = k.globals["non_primitive"] + + v = k.globals["v"] + + assert len(hashes) == 2 + assert non_primitive[1] == 0 + assert v == 1 + assert hashes[0] != hashes[1] + + @staticmethod + async def test_side_effect_file( + k: Kernel, exec_req: ExecReqProvider, tmp_path + ) -> None: + control_requests = [] + k.enqueue_control_request = lambda req: control_requests.append(req) # type: ignore + await k.run( + [ + exec_req.get( + f'tmp_path_fixture = Path("{tmp_path.as_posix()}")' + ), + exec_req.get(""" + from tests._save.loaders.mocks import MockLoader + import marimo as mo + mo.watch._file._TEST_SLEEP_INTERVAL = 0.01 + from pathlib import Path + + hashes = [] + """), + r := exec_req.get(""" + f = mo.watch.file(tmp_path_fixture / "test.txt") + """), + ] + ) + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, k.stderr + await k.run( + [ + exec_req.get(""" + f + non_primitive = [object(), len(hashes), f.exists()] + """), + exec_req.get(""" + with mo.persistent_cache("get_v", save_path=tmp_path_fixture) as v_cache: + v = non_primitive[1] + """), + ] + ) + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, k.stderr + await k.run( + [ + r := exec_req.get(""" + if len(hashes) < 1: + assert non_primitive[1] == 0 + hashes.append((v_cache.cache_type, v_cache._cache.hash)) + """), + ] + ) + (tmp_path / "test.txt").touch() + await asyncio.sleep(0.25) + assert len(control_requests) == 1 + assert isinstance(control_requests[0], ExecuteStaleCellsCommand) + assert k.graph.cells[r.cell_id].stale + await k.run_stale_cells() + + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, k.stderr + v = k.globals["v"] + hashes = k.globals["hashes"] + non_primitive = k.globals["non_primitive"] + assert len(hashes) == 2 + assert hashes[0] != hashes[1] + assert non_primitive[1] == 1 == v + + @staticmethod + async def test_side_effect_directory( + k: Kernel, exec_req: ExecReqProvider, tmp_path + ) -> None: + control_requests = [] + k.enqueue_control_request = lambda req: control_requests.append(req) # type: ignore + await k.run( + [ + exec_req.get( + f'tmp_path_fixture = Path("{tmp_path.as_posix()}")' + ), + exec_req.get(""" + from tests._save.loaders.mocks import MockLoader + import marimo as mo + mo.watch._directory._TEST_SLEEP_INTERVAL = 0.01 + from pathlib import Path + + hashes = [] + """), + exec_req.get(""" + (tmp_path_fixture / "test_dir").mkdir(parents=True, exist_ok=True) + d = mo.watch.directory(tmp_path_fixture / "test_dir") + """), + ] + ) + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, k.stderr + await k.run( + [ + exec_req.get(""" + non_primitive = [object(), len(hashes), d.glob("*")] + """), + exec_req.get(""" + with mo.persistent_cache("get_v", save_path=tmp_path_fixture) as v_cache: + v = non_primitive[1] + """), + ] + ) + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, k.stderr + await k.run( + [ + r := exec_req.get(""" + if len(hashes) < 1: + assert non_primitive[1] == 0 + hashes.append((v_cache.cache_type, v_cache._cache.hash)) + """), + ] + ) + (tmp_path / "test_dir" / "test.txt").write_text("test") + await asyncio.sleep(0.25) + assert len(control_requests) == 1 + assert isinstance(control_requests[0], ExecuteStaleCellsCommand) + assert k.graph.cells[r.cell_id].stale + await k.run_stale_cells() + + await k.run([]) + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, k.stderr + v = k.globals["v"] + hashes = k.globals["hashes"] + non_primitive = k.globals["non_primitive"] + assert len(hashes) == 2 + assert hashes[0] != hashes[1] + assert non_primitive[1] == 1 == v + + @staticmethod + async def test_side_effect_file_ref( + k: Kernel, exec_req: ExecReqProvider, tmp_path + ) -> None: + await k.run( + [ + exec_req.get( + f'tmp_path_fixture = Path("{tmp_path.as_posix()}")' + ), + exec_req.get(""" + from tests._save.loaders.mocks import MockLoader + import marimo as mo + from pathlib import Path + + hashes = [] + """), + exec_req.get( + 'u = mo.watch.file(tmp_path_fixture / "test.txt")' + ), + exec_req.get(""" + non_primitive = [object(), len(hashes)] + with mo.cache("prim") as prim_cache: + # unused, but should trigger side effect + u + """), + exec_req.get(""" + with mo.persistent_cache("get_v", save_path=tmp_path_fixture) as v_cache: + v = non_primitive[1] + """), + ] + ) + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, k.stderr + await k.run( + [ + exec_req.get(""" + if len(hashes) < 1: + assert non_primitive[1] == 0 + u.write_text("test") + hashes.append((v_cache.cache_type, v_cache._cache.hash)) + hashes.append((prim_cache.cache_type, prim_cache._cache.hash)) + """), + ] + ) + assert not k.stdout.messages, k.stdout + assert not k.stderr.messages, k.stderr + v = k.globals["v"] + hashes = k.globals["hashes"] + non_primitive = k.globals["non_primitive"] + # Docs warn not to use write directly since RC can occur, causing double + # event. + assert len(hashes) == 4 + assert hashes[1] != hashes[3] + assert hashes[0] != hashes[2] + assert non_primitive[1] == 2 == v + + +class TestWrappedFunctionCache: + """Test cache behavior with wrapped functions (decorators) across kernel calls.""" + + @staticmethod + async def test_decorator_hash_same_name_different_kernels( + k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Test that decorators with same function names in different kernel calls have different hashes.""" + + # First kernel execution + cell_id = "test_cell" + await k.run( + [ + exec_req.get_with_id( + cell_id, + """ + import functools + import marimo as mo + + def my_decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + wrapper._call_count = getattr(wrapper, '_call_count', 0) + 1 + return func(*args, **kwargs) + wrapper._kernel_version = 1 + return wrapper + + @my_decorator + def my_function(): + return "kernel_1" + + @mo.cache + def cached_decorated_function(): + return my_function() + + result1 = cached_decorated_function() + hash1 = cached_decorated_function._last_hash + cache_type1 = cached_decorated_function.base_block.cache_type + """, + ) + ] + ) + + assert not k.stderr.messages, k.stderr + assert k.globals["result1"] == "kernel_1" + first_hash = k.globals["hash1"] + first_function = k.globals["my_function"] + first_cache_type = k.globals["cache_type1"] + + # Second kernel execution - update the same cell with different definitions + await k.run( + [ + exec_req.get_with_id( + cell_id, + """ + import functools + import marimo as mo + + def my_decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + wrapper._call_count = getattr(wrapper, '_call_count', 0) + 1 + return func(*args, **kwargs) + wrapper._kernel_version = 2 # Different version + return wrapper + + @my_decorator + def my_function(): + return "kernel_2" # Different return value + + @mo.cache + def cached_decorated_function(): + return my_function() + + result2 = cached_decorated_function() + hash2 = cached_decorated_function._last_hash + cache_type2 = cached_decorated_function.base_block.cache_type + """, + ) + ] + ) + + assert not k.stderr.messages, k.stderr + assert k.globals["result2"] == "kernel_2" + second_hash = k.globals["hash2"] + second_function = k.globals["my_function"] + second_cache_type = k.globals["cache_type2"] + + # Functions should be different objects despite same name + assert first_function is not second_function + assert ( + first_function._kernel_version != second_function._kernel_version + ) + + # Should use ContentAddressed cache type since decorator is pure + assert first_cache_type == "ContentAddressed", ( + f"Expected ContentAddressed, got {first_cache_type}" + ) + assert second_cache_type == "ContentAddressed", ( + f"Expected ContentAddressed, got {second_cache_type}" + ) + + # Cache hashes should be different + assert first_hash != second_hash, ( + f"Expected different hashes, got {first_hash} == {second_hash}" + ) + + @staticmethod + async def test_impure_decorator_with_pure_function( + k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Test impure decorator applied to pure function - decorator should dominate purity.""" + + # First execution with impure decorator + cell_id = "test_cell" + await k.run( + [ + exec_req.get_with_id( + cell_id, + """ + import functools + import marimo as mo + + # This will be an impure decorator (contains non-primitive objects) + impure_state = [object()] # Non-primitive, makes decorator impure + + def my_impure_decorator(func): + '''An impure decorator that depends on impure_state''' + @functools.wraps(func) + def wrapper(*args, **kwargs): + # Decorator depends on impure_state + wrapper._call_count = len(impure_state) + return func(*args, **kwargs) + return wrapper + + @my_impure_decorator + def pure_function(): + # This function itself is pure (no external dependencies) + return 42 + + @mo.cache + def cached_function(): + return pure_function() + """, + ), + exec_req.get_with_id( + "call_cell", + """ + + result1 = cached_function() + hash1 = cached_function._last_hash + cache_type1 = cached_function.base_block.cache_type + """, + ), + ] + ) + + assert not k.stderr.messages, k.stderr + assert k.globals["result1"] == 42 + first_hash = k.globals["hash1"] + first_cache_type = k.globals["cache_type1"] + + # Second execution - change the impure decorator state + await k.run( + [ + exec_req.get_with_id( + cell_id, + """ + import functools + import marimo as mo + + # Different impure state (different length) + impure_state = [object(), object()] # Different length affects decorator + + def my_impure_decorator(func): + '''Same impure decorator with different state''' + @functools.wraps(func) + def wrapper(*args, **kwargs): + # Decorator depends on impure_state (now different) + wrapper._call_count = len(impure_state) + return func(*args, **kwargs) + return wrapper + + @my_impure_decorator + def pure_function(): + # Same pure function + return 42 + + @mo.cache + def cached_function(): + return pure_function() + """, + ), + exec_req.get_with_id( + "call_cell", + """ + + result2 = cached_function() + hash2 = cached_function._last_hash + cache_type2 = cached_function.base_block.cache_type + """, + ), + ] + ) + + assert not k.stderr.messages, k.stderr + assert ( + k.globals["result2"] == 42 + ) # Same result since pure_function unchanged + second_hash = k.globals["hash2"] + second_cache_type = k.globals["cache_type2"] + + # The decorated function should be treated as impure due to impure decorator + # ContextExecutionPath, but since decorated ExecutionPath + assert first_cache_type == "ExecutionPath", ( + f"Expected ExecutionPath, got {first_cache_type}" + ) + assert second_cache_type == "ExecutionPath", ( + f"Expected ExecutionPath, got {second_cache_type}" + ) + + # Hashes should be different because the decorator's dependencies changed + # (even though the underlying pure function is the same) + assert first_hash != second_hash, ( + f"Expected different hashes for different decorator dependencies, " + f"got {first_hash} == {second_hash}" + ) + + @staticmethod + async def test_pure_decorator_with_impure_dependencies_different_cells( + k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Test impure dependencies in different cells (should be execution refs).""" + + # First execution - setup dependencies in separate cells + await k.run( + [ + exec_req.get_with_id( + "dep_cell", + """ + import functools + + # This will be an impure dependency (contains non-primitive objects) + impure_dependency = [object()] # Non-primitive, makes it impure + + def my_pure_decorator(func): + '''A pure decorator from external module perspective''' + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + return wrapper + """, + ), + exec_req.get_with_id( + "func_cell", + """ + @my_pure_decorator + def decorated_function(): + # This function depends on impure_dependency from another cell + return len(impure_dependency) + """, + ), + exec_req.get_with_id( + "cache_cell", + """ + import marimo as mo + + @mo.cache + def cached_function(): + return decorated_function() + + result1 = cached_function() + hash1 = cached_function._last_hash + cache_type1 = cached_function.base_block.cache_type + """, + ), + ] + ) + + assert not k.stderr.messages, k.stderr + assert k.globals["result1"] == 1 + first_hash = k.globals["hash1"] + first_cache_type = k.globals["cache_type1"] + + # Second execution - change the impure dependency in separate cell + await k.run( + [ + exec_req.get_with_id( + "dep_cell", + """ + import functools + + # Different impure dependency (different object, different length) + impure_dependency = [object(), object()] # Different length + + def my_pure_decorator(func): + '''Same pure decorator''' + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + return wrapper + """, + ), + exec_req.get_with_id( + "func_cell", + """ + @my_pure_decorator + def decorated_function(): + # Same function, but now depends on different impure_dependency + return len(impure_dependency) + """, + ), + exec_req.get_with_id( + "cache_cell", + """ + import marimo as mo + + @mo.cache + def cached_function(): + return decorated_function() + + result2 = cached_function() + hash2 = cached_function._last_hash + cache_type2 = cached_function.base_block.cache_type + """, + ), + ] + ) + + assert not k.stderr.messages, k.stderr + assert ( + k.globals["result2"] == 2 + ) # Different result due to different dependency + second_hash = k.globals["hash2"] + second_cache_type = k.globals["cache_type2"] + + # Should use ExecutionPath hashing since dependencies are in different cells + assert first_cache_type == "ExecutionPath", ( + f"Expected ExecutionPath, got {first_cache_type}" + ) + assert second_cache_type == "ExecutionPath", ( + f"Expected ExecutionPath, got {second_cache_type}" + ) + + # Hashes should be different because the execution path changed + # (due to different cells being hashed) + assert first_hash != second_hash, ( + f"Expected different hashes for different cell dependencies, " + f"got {first_hash} == {second_hash}" + ) + + @staticmethod + async def test_pure_decorator_with_impure_dependencies( + k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Test the impure edge case: decorator is pure but dependent functions are not.""" + + # First execution with impure dependency + cell_id = "test_cell" + await k.run( + [ + exec_req.get_with_id( + cell_id, + """ + import functools + import marimo as mo + + # This will be an impure dependency (contains non-primitive objects) + impure_dependency = [object()] # Non-primitive, makes it impure + + def my_pure_decorator(func): + '''A pure decorator from external module perspective''' + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + return wrapper + + @my_pure_decorator + def decorated_function(): + # This function depends on impure_dependency + return len(impure_dependency) + + @mo.cache + def cached_function(): + return decorated_function() + + result1 = cached_function() + hash1 = cached_function._last_hash + cache_type1 = cached_function.base_block.cache_type + """, + ) + ] + ) + + assert not k.stderr.messages, k.stderr + assert not k.stdout.messages, k.stdout + + assert k.globals["result1"] == 1 + first_hash = k.globals["hash1"] + first_cache_type = k.globals["cache_type1"] + + # Second execution - change the impure dependency + await k.run( + [ + exec_req.get_with_id( + cell_id, + """ + import functools + import marimo as mo + + # Different impure dependency (different object, different length) + impure_dependency = [object(), object()] # Different length + + def my_pure_decorator(func): + '''Same pure decorator''' + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + return wrapper + + @my_pure_decorator + def decorated_function(): + # Same function, but now depends on different impure_dependency + return len(impure_dependency) + + @mo.cache + def cached_function(): + return decorated_function() + + result2 = cached_function() + hash2 = cached_function._last_hash + cache_type2 = cached_function.base_block.cache_type + """, + ) + ] + ) + + assert not k.stderr.messages, k.stderr + assert not k.stdout.messages, k.stdout + + assert ( + k.globals["result2"] == 2 + ) # Different result due to different dependency + second_hash = k.globals["hash2"] + second_cache_type = k.globals["cache_type2"] + + # The decorator itself is pure, but the function has impure dependencies + # This should use ExecutionPath hashing, not ContentAddressed + assert first_cache_type == "ExecutionPath", ( + f"Expected ExecutionPath, got {first_cache_type}" + ) + assert second_cache_type == "ExecutionPath", ( + f"Expected ExecutionPath, got {second_cache_type}" + ) + + # Hashes should be different because the execution path changed + # (due to different impure_dependency) + assert first_hash != second_hash, ( + f"Expected different hashes for different impure dependencies, " + f"got {first_hash} == {second_hash}" + ) + + @staticmethod + async def test_default_argument_cache_invalidation( + k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Test that changing default argument values invalidates the cache (issue #7977).""" + + cell_id = "test_cell" + + # First execution with config_version=1 + await k.run( + [ + exec_req.get_with_id( + cell_id, + """ + import marimo as mo + + @mo.cache + def get_data_with_config(query: str, config_version=1): + return query, config_version + + result1 = get_data_with_config("test") + hash1 = get_data_with_config._last_hash + """, + ) + ] + ) + + assert not k.stderr.messages, k.stderr + assert k.globals["result1"] == ("test", 1) + first_hash = k.globals["hash1"] + + # Second execution with config_version=2 (same cell, different default) + await k.run( + [ + exec_req.get_with_id( + cell_id, + """ + import marimo as mo + + @mo.cache + def get_data_with_config(query: str, config_version=2): + return query, config_version + + result2 = get_data_with_config("test") + hash2 = get_data_with_config._last_hash + """, + ) + ] + ) + + assert not k.stderr.messages, k.stderr + assert k.globals["result2"] == ("test", 2) + second_hash = k.globals["hash2"] + + # The hashes should be different because the default argument changed + assert first_hash != second_hash, ( + f"Expected different hashes when default argument changes, " + f"got {first_hash} == {second_hash}" + ) + + @staticmethod + async def test_explicit_arg_matches_previous_default( + k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Test that explicit args matching defaults produce the same hash. + + Calling fn() with default a=1 should produce the same hash as + calling fn(1) with explicit arg, because the resolved value is the same. + """ + + # Test within a single cell to verify hash computation is correct + await k.run( + [ + exec_req.get( + """ + import marimo as mo + + @mo.cache + def fn(a=1): + return a + 1 + + # Call with default + result1 = fn() + hash_default = fn._last_hash + + # Call with explicit arg matching the default + result2 = fn(1) + hash_explicit = fn._last_hash + + # Call with different value + result3 = fn(2) + hash_different = fn._last_hash + """, + ) + ] + ) + + assert not k.stderr.messages, k.stderr + assert k.globals["result1"] == 2 # 1 + 1 + assert k.globals["result2"] == 2 # 1 + 1 + assert k.globals["result3"] == 3 # 2 + 1 + + # fn() and fn(1) should have the same hash (same resolved value) + assert k.globals["hash_default"] == k.globals["hash_explicit"], ( + f"Expected same hash for fn() and fn(1), " + f"got {k.globals['hash_default']} != {k.globals['hash_explicit']}" + ) + # fn(2) should have a different hash + assert k.globals["hash_default"] != k.globals["hash_different"], ( + f"Expected different hash for fn() and fn(2), " + f"got same hash {k.globals['hash_default']}" + ) + + @staticmethod + async def test_various_function_signature_forms( + k: Kernel, exec_req: ExecReqProvider + ) -> None: + """Test cache works with various function signature forms. + + Tests: positional-only (/), keyword-only (*), defaults, *args, **kwargs + """ + + await k.run( + [ + exec_req.get( + """ +import marimo as mo + +# Test 1: Keyword-only parameters with defaults +@mo.cache +def kwonly(a, *, value=1): + return a + value + +kw_r1 = kwonly(10) # Call with default +kw_h1 = kwonly._last_hash +kw_r2 = kwonly(10, value=1) # Call with explicit same value +kw_h2 = kwonly._last_hash +kw_r3 = kwonly(10, value=2) # Call with different value +kw_h3 = kwonly._last_hash +kw_hits = kwonly.hits + +# Test 2: Positional-only parameters with defaults +@mo.cache +def posonly(a, b=5, /): + return a + b + +po_r1 = posonly(10) # Call with default +po_h1 = posonly._last_hash +po_r2 = posonly(10, 5) # Call with explicit same value +po_h2 = posonly._last_hash +po_r3 = posonly(10, 7) # Call with different value +po_h3 = posonly._last_hash +po_hits = posonly.hits + +# Test 3: Mixed positional-only, regular, and keyword-only +@mo.cache +def mixed(a, /, b, c=3, *, d=4): + return a + b + c + d + +mx_r1 = mixed(1, 2) # Call with defaults for c and d +mx_h1 = mixed._last_hash +mx_r2 = mixed(1, 2, 3, d=4) # Call with explicit same values +mx_h2 = mixed._last_hash +mx_r3 = mixed(1, 2, d=5) # Call with different d +mx_h3 = mixed._last_hash +mx_hits = mixed.hits + +# Test 4: *args and **kwargs +@mo.cache +def varargs(a, *args, b=10, **kwargs): + return a + sum(args) + b + sum(kwargs.values()) + +va_r1 = varargs(1, 2, 3) # Call with default b +va_h1 = varargs._last_hash +va_r2 = varargs(1, 2, 3, b=10) # Call with explicit same b +va_h2 = varargs._last_hash +va_r3 = varargs(1, 2, 3, b=20) # Call with different b +va_h3 = varargs._last_hash +va_r4 = varargs(1, 2, 3, x=100) # Call with kwargs +va_h4 = varargs._last_hash +va_hits = varargs.hits + """, + ) + ] + ) + assert not k.stderr.messages, k.stderr + + # Test 1: Keyword-only assertions + assert k.globals["kw_r1"] == 11 + assert k.globals["kw_r2"] == 11 + assert k.globals["kw_r3"] == 12 + assert k.globals["kw_h1"] == k.globals["kw_h2"], ( + "kwonly: explicit same value should hit cache" + ) + assert k.globals["kw_h1"] != k.globals["kw_h3"], ( + "kwonly: different value should miss cache" + ) + assert k.globals["kw_hits"] == 1, ( + f"Expected 1 hit, got {k.globals['kw_hits']}" + ) + + # Test 2: Positional-only assertions + assert k.globals["po_r1"] == 15 + assert k.globals["po_r2"] == 15 + assert k.globals["po_r3"] == 17 + assert k.globals["po_h1"] == k.globals["po_h2"], ( + "posonly: explicit same value should hit cache" + ) + assert k.globals["po_h1"] != k.globals["po_h3"], ( + "posonly: different value should miss cache" + ) + assert k.globals["po_hits"] == 1, ( + f"Expected 1 hit, got {k.globals['po_hits']}" + ) + + # Test 3: Mixed assertions + assert k.globals["mx_r1"] == 10 # 1+2+3+4 + assert k.globals["mx_r2"] == 10 + assert k.globals["mx_r3"] == 11 # 1+2+3+5 + assert k.globals["mx_h1"] == k.globals["mx_h2"], ( + "mixed: explicit same values should hit cache" + ) + assert k.globals["mx_h1"] != k.globals["mx_h3"], ( + "mixed: different d should miss cache" + ) + assert k.globals["mx_hits"] == 1, ( + f"Expected 1 hit, got {k.globals['mx_hits']}" + ) + + # Test 4: Varargs assertions + assert k.globals["va_r1"] == 16 # 1+2+3+10 + assert k.globals["va_r2"] == 16 + assert k.globals["va_r3"] == 26 # 1+2+3+20 + assert k.globals["va_r4"] == 116 # 1+2+3+10+100 + assert k.globals["va_h1"] == k.globals["va_h2"], ( + "varargs: explicit same b should hit cache" + ) + assert k.globals["va_h1"] != k.globals["va_h3"], ( + "varargs: different b should miss cache" + ) + assert k.globals["va_h1"] != k.globals["va_h4"], ( + "varargs: with kwargs should miss cache" + ) + assert k.globals["va_hits"] == 1, ( + f"Expected 1 hit, got {k.globals['va_hits']}" + ) + + +def _hash_fn(fn: object) -> bytes: + """Run the full get_hashable_ast -> compile -> hash pipeline.""" + from marimo._ast.transformers import get_hashable_ast + from marimo._save.hash import hash_raw_module + + assert callable(fn) + return hash_raw_module(get_hashable_ast(fn)) + + +class TestRemoveReturnsBytecode: + """Bytecode-level tests for RemoveReturns (regression #8364).""" + + def test_different_name_exprs_different_hash(self) -> None: + """Changing the returned expression must change the hash.""" + + def fn_add(x: int, y: int) -> int: + return x + y + + def fn_sub(x: int, y: int) -> int: + return x - y + + assert _hash_fn(fn_add) != _hash_fn(fn_sub) + + def test_different_constants_different_hash(self) -> None: + """Different numeric returns must hash differently.""" + + def fn_a() -> int: + return 11 + 19 + + def fn_b() -> int: + return 11 + 29 + + assert _hash_fn(fn_a) != _hash_fn(fn_b) + + def test_different_single_constants_different_hash(self) -> None: + def fn_a() -> int: + return 42 + + def fn_b() -> int: + return 99 + + assert _hash_fn(fn_a) != _hash_fn(fn_b) + + def test_different_pure_lambdas_different_hash(self) -> None: + """Pure lambdas with different bodies must hash differently.""" + + def fn_a() -> object: + return lambda: 1 + + def fn_b() -> object: + return lambda: 2 + + assert _hash_fn(fn_a) != _hash_fn(fn_b) + + +class TestSetLiteralDeterminism: + """Set literals are constant-folded into frozensets in co_consts, whose + str()/iteration order is PYTHONHASHSEED-dependent (issue #9829).""" + + def test_set_literal_changes_hash(self) -> None: + """Changing set members must still change the hash.""" + + def fn_a(x: object) -> bool: + return x in {"A", "B", "C"} + + def fn_b(x: object) -> bool: + return x in {"A", "B", "D"} + + assert _hash_fn(fn_a) != _hash_fn(fn_b) + + def test_hash_deterministic_across_hashseed(self) -> None: + """A set-literal function must hash identically across PYTHONHASHSEED. + + The set literal is constant-folded into a frozenset in the function's + co_consts; `hash_module` is what previously serialized it with a + seed-dependent `str()`. + """ + program = textwrap.dedent( + """ + from marimo._save.hash import hash_module + + def fn(x): + return x in {"A", "B", "C", "D", "E", 1, 2.0, None} + + print(hash_module(fn.__code__).hex()) + """ + ) + digests = set() + for seed in ("0", "1", "2", "42", "12345"): + env = {**os.environ, "PYTHONHASHSEED": seed} + result = subprocess.run( + [sys.executable, "-c", program], + capture_output=True, + text=True, + env=env, + ) + assert result.returncode == 0, result.stderr + digests.add(result.stdout.strip()) + assert len(digests) == 1, f"non-deterministic across seeds: {digests}" + + def test_singleton_set_unchanged(self) -> None: + """A singleton set literal stays on the str() path (len > 1 guard). + + `str(frozenset({'A'}))` has only one possible order, so it was never + broken; its hash must equal the plain str()-based serialization so we + don't invalidate caches that already worked. + """ + import hashlib + + from marimo._save.hash import hash_module + + def fn(x: object) -> bool: + return x in {"A"} + + code = fn.__code__ + singleton = next(c for c in code.co_consts if isinstance(c, frozenset)) + assert len(singleton) == 1 + + expected = hashlib.new("sha256", usedforsecurity=False) + for const in code.co_consts: + expected.update(str(const).encode("utf8")) + expected.update(bytes("|".join(code.co_names), "utf8")) + expected.update(code.co_code) + + assert hash_module(code) == expected.digest() + + def test_exotic_const_types_dont_crash(self) -> None: + """Set literals can hold complex/bytes/bool/tuple — none may crash. + + repr-based serialization handles these; the byte serializers + (primitive_to_bytes / common_container_to_bytes) reject complex. + """ + from marimo._save.hash import hash_module + + def fn(x: object) -> bool: + return x in {1j, 2j, b"x", True, (1, 2), "s"} + + assert isinstance(hash_module(fn.__code__), bytes) + + +def test_decorator_params_affect_hash() -> None: + """Verify that changing decorator parameters changes the function hash. + + When using @mo.cache with nested decorators like: + @mo.cache + @spl(schema={"a": "int"}) + def query(): ... + + Changing the schema should invalidate the cache. + """ + from marimo._ast.transformers import get_hashable_ast + from marimo._save.hash import hash_raw_module + + def cache(fn): + return fn + + def decorator_with_param(param: str): + del param + + def wrapper(fn): + return fn + + return wrapper + + # Helper to hash with cache skipped (mimics mo.cache behavior) + def hash_fn(fn): + return hash_raw_module(get_hashable_ast(fn, skip_decorators={"cache"})) + + # Test 1: Decorator AFTER @cache - param change should affect hash + @cache + @decorator_with_param(param="value1") + def fn(): + return 42 + + hash1 = hash_fn(fn) + + @cache + @decorator_with_param(param="value2") + def fn(): + return 42 + + hash2 = hash_fn(fn) + + assert hash1 != hash2, ( + "Decorator param change AFTER @cache should invalidate hash" + ) + + # Test 2: Decorator BEFORE @cache - param change should NOT affect hash + @decorator_with_param(param="value1") + @cache + def fn(): + return 42 + + hash3 = hash_fn(fn) + + @decorator_with_param(param="value2") + @cache + def fn(): + return 42 + + hash4 = hash_fn(fn) + + assert hash3 == hash4, ( + "Decorator param change BEFORE @cache should NOT affect hash" + ) + + +async def test_decorator_ref_tracking_e2e( + k: Kernel, exec_req: ExecReqProvider +) -> None: + """End-to-end test that refs in decorator params affect cache behavior. + + When a decorator parameter references a variable: + schema = {"a": "int"} + @mo.cache + @my_decorator(schema=schema) + def query(): ... + + Changing the variable's value should produce different cache hashes. + """ + + cell_id = "test_cell" + + # First execution with schema_config = "v1" + await k.run( + [ + exec_req.get_with_id( + cell_id, + """ +import marimo as mo + +schema_config = "v1" + +def my_decorator(schema): + def wrapper(fn): + fn._schema = schema + return fn + return wrapper + +@mo.cache +@my_decorator(schema=schema_config) +def cached_query(): + return "result" + +result = cached_query() +hash_val = cached_query._last_hash + """, + ) + ] + ) + + assert not k.stderr.messages, k.stderr + assert k.globals["result"] == "result" + first_hash = k.globals["hash_val"] + + # Second execution with schema_config = "v2" + await k.run( + [ + exec_req.get_with_id( + cell_id, + """ +import marimo as mo + +schema_config = "v2" + +def my_decorator(schema): + def wrapper(fn): + fn._schema = schema + return fn + return wrapper + +@mo.cache +@my_decorator(schema=schema_config) +def cached_query(): + return "result" + +result = cached_query() +hash_val = cached_query._last_hash + """, + ) + ] + ) + + assert not k.stderr.messages, k.stderr + assert k.globals["result"] == "result" + second_hash = k.globals["hash_val"] + + # The hashes should be different because the decorator param changed + assert first_hash != second_hash, ( + f"Expected different hashes when decorator ref changes, " + f"got {first_hash} == {second_hash}" + ) + + +@dataclasses.dataclass(frozen=True) +class _Selection: + """Stand-in for a UI value (e.g. mo.ui.matplotlib selection) that is + picklable but neither a primitive nor a data-primitive.""" + + vertices: tuple[tuple[float, float], ...] + + +def test_signed_stateful_bytes_content_varies() -> None: + # A stateful value that is not content-addressable must still hash by its + # content (pickle last-resort), so a consumer re-keys when it changes. + hasher = BlockHasher.__new__(BlockHasher) + a = hasher._signed_stateful_bytes(_Selection(((0.0, 0.0),)), "ui") + b = hasher._signed_stateful_bytes(_Selection(((1.0, 2.0),)), "ui") + assert isinstance(a, bytes) + assert isinstance(b, bytes) + assert a != b + # Same value -> same bytes (deterministic). + assert a == hasher._signed_stateful_bytes(_Selection(((0.0, 0.0),)), "ui") + + +def test_signed_stateful_bytes_uses_custom_stub() -> None: + # A value with a registered custom stub hashes by the stub's canonical + # bytes (what the cache stores it as), not a generic pickle. + from marimo._save.encode import type_sign + from marimo._save.stubs import CUSTOM_STUBS, CustomStub, register_stub + + @dataclasses.dataclass + class _Model: + payload: bytes + + class _ModelStub(CustomStub): + __slots__ = ("payload",) + + def __init__(self, obj: Any) -> None: + self.payload = obj.payload + + def load(self, glbls: dict[str, Any]) -> Any: + del glbls + return _Model(self.payload) + + @staticmethod + def get_type() -> type: + return _Model + + def to_bytes(self) -> bytes: + return self.payload + + register_stub(_Model, _ModelStub) + try: + hasher = BlockHasher.__new__(BlockHasher) + out = hasher._signed_stateful_bytes(_Model(b"abc"), "ui") + assert out == type_sign(b"abc", "stub") + # Distinct stub bytes -> distinct key. + assert out != hasher._signed_stateful_bytes(_Model(b"xyz"), "ui") + finally: + CUSTOM_STUBS.pop(_Model, None) + + +def test_signed_stateful_bytes_bytearray_is_signed_bytes() -> None: + # A bytearray value must hash to signed, immutable bytes (not a raw, + # mutable bytearray), and vary by content. + hasher = BlockHasher.__new__(BlockHasher) + a = hasher._signed_stateful_bytes(bytearray(b"abc"), "ui") + b = hasher._signed_stateful_bytes(bytearray(b"xyz"), "ui") + assert type(a) is bytes + assert type(b) is bytes + assert a != b + assert a == hasher._signed_stateful_bytes(bytearray(b"abc"), "ui") + + +def test_signed_stateful_bytes_unpicklable_raises() -> None: + hasher = BlockHasher.__new__(BlockHasher) + with pytest.raises(TypeError, match="neither"): + hasher._signed_stateful_bytes(lambda: None, "ui") diff --git a/tests/_save/test_signing.py b/tests/_save/test_signing.py new file mode 100644 index 00000000000..5afabb33568 --- /dev/null +++ b/tests/_save/test_signing.py @@ -0,0 +1,416 @@ +# Copyright 2026 Marimo. All rights reserved. +"""Unit tests for marimo._save.signing.""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import patch + +import pytest + +pytest.importorskip("cryptography", reason="cryptography not installed") + +from marimo._save.signing import ( + CacheSignatureError, + CacheSigner, + _sha256hex, + fingerprint, + generate_keypair, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_signer() -> CacheSigner: + private_pem, _ = generate_keypair() + return CacheSigner.from_private_key_pem(private_pem) + + +def _make_verifier() -> tuple[CacheSigner, CacheSigner]: + """Return (signer_with_private, verifier_with_public_only).""" + private_pem, public_pem = generate_keypair() + signer = CacheSigner.from_private_key_pem(private_pem) + verifier = CacheSigner.from_public_key_pem(public_pem) + return signer, verifier + + +# --------------------------------------------------------------------------- +# generate_keypair +# --------------------------------------------------------------------------- + + +class TestGenerateKeypair: + def test_returns_two_pem_strings(self) -> None: + private_pem, public_pem = generate_keypair() + assert "PRIVATE KEY" in private_pem + assert "PUBLIC KEY" in public_pem + + def test_pem_round_trip(self) -> None: + private_pem, public_pem = generate_keypair() + signer = CacheSigner.from_private_key_pem(private_pem) + assert signer.private_key_pem() == private_pem + assert signer.public_key_pem() == public_pem + + +# --------------------------------------------------------------------------- +# CacheSigner construction +# --------------------------------------------------------------------------- + + +class TestCacheSignerConstruction: + def test_requires_at_least_one_key(self) -> None: + with pytest.raises(ValueError, match="at least one"): + CacheSigner() + + def test_private_key_implies_public(self) -> None: + signer = _make_signer() + assert signer.can_sign + # Public key is derived automatically; PEM export should work. + pem = signer.public_key_pem() + assert "PUBLIC KEY" in pem + + def test_public_key_only(self) -> None: + _, public_pem = generate_keypair() + verifier = CacheSigner.from_public_key_pem(public_pem) + assert not verifier.can_sign + + def test_sign_without_private_key_raises(self) -> None: + _, public_pem = generate_keypair() + verifier = CacheSigner.from_public_key_pem(public_pem) + with pytest.raises(ValueError, match="no private key"): + verifier.sign(b"data") + + def test_private_key_pem_without_private_raises(self) -> None: + _, public_pem = generate_keypair() + verifier = CacheSigner.from_public_key_pem(public_pem) + with pytest.raises(ValueError, match="No private key"): + verifier.private_key_pem() + + def test_from_public_key_pem_rejects_non_ed25519(self) -> None: + """A non-Ed25519 key must fail at load, not silently at verify time.""" + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ec + + ec_pub = ( + ec.generate_private_key(ec.SECP256R1()) + .public_key() + .public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode() + ) + with pytest.raises(ValueError, match="Ed25519"): + CacheSigner.from_public_key_pem(ec_pub) + + def test_from_private_key_pem_rejects_non_ed25519(self) -> None: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ec + + ec_priv = ( + ec.generate_private_key(ec.SECP256R1()) + .private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + .decode() + ) + with pytest.raises(ValueError, match="Ed25519"): + CacheSigner.from_private_key_pem(ec_priv) + + +# --------------------------------------------------------------------------- +# sign / verify +# --------------------------------------------------------------------------- + + +class TestSignVerify: + def test_sign_and_verify_succeed(self) -> None: + signer, verifier = _make_verifier() + data = b"hello world" + sig = signer.sign(data) + # Must not raise + verifier.verify(data, sig) + + def test_tampered_data_raises(self) -> None: + signer, verifier = _make_verifier() + sig = signer.sign(b"original") + with pytest.raises(CacheSignatureError): + verifier.verify(b"tampered", sig) + + def test_tampered_signature_raises(self) -> None: + signer, verifier = _make_verifier() + # Sign different data to get a valid but wrong signature + bad_sig = signer.sign(b"completely different data") + with pytest.raises(CacheSignatureError): + verifier.verify(b"data", bad_sig) + + def test_wrong_key_raises(self) -> None: + signer, _ = _make_verifier() + _, other_verifier = _make_verifier() + sig = signer.sign(b"data") + with pytest.raises(CacheSignatureError): + other_verifier.verify(b"data", sig) + + def test_self_verify(self) -> None: + """A signer with private key can also verify its own signatures.""" + signer = _make_signer() + data = b"test" + sig = signer.sign(data) + signer.verify(data, sig) # must not raise + + def test_malformed_base64_raises(self) -> None: + """Truncated/corrupted base64 in the signature string raises CacheSignatureError.""" + _, verifier = _make_verifier() + with pytest.raises( + CacheSignatureError, match="not readable|corrupted" + ): + verifier.verify(b"data", "not!!valid==base64@@") + + +# --------------------------------------------------------------------------- +# verify_blob +# --------------------------------------------------------------------------- + + +class TestVerifyBlob: + def test_correct_hash_passes(self) -> None: + signer = _make_signer() + blob = b"blob content" + expected = _sha256hex(blob) + signer.verify_blob("key", blob, expected) # must not raise + + def test_wrong_hash_raises(self) -> None: + signer = _make_signer() + blob = b"blob content" + wrong_hash = _sha256hex(b"different content") + with pytest.raises(CacheSignatureError, match="checksum"): + signer.verify_blob("key", blob, wrong_hash) + + def test_error_message_contains_key(self) -> None: + signer = _make_signer() + with pytest.raises(CacheSignatureError, match="my_blob_key"): + signer.verify_blob("my_blob_key", b"x", "0" * 64) + + +# --------------------------------------------------------------------------- +# _sha256hex +# --------------------------------------------------------------------------- + + +class TestSha256Hex: + def test_known_value(self) -> None: + # SHA-256 of empty bytes is well-known + assert _sha256hex(b"") == ( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ) + + def test_returns_64_chars(self) -> None: + assert len(_sha256hex(b"anything")) == 64 + + +# --------------------------------------------------------------------------- +# from_env +# --------------------------------------------------------------------------- + + +class TestFromEnv: + def test_returns_none_when_not_set(self) -> None: + with patch.dict( + os.environ, + {}, + clear=False, + ): + # Ensure vars aren't set in the environment + env = { + k: v + for k, v in os.environ.items() + if k + not in ( + "MARIMO_CACHE_SIGNING_PRIVATE_KEY", + "MARIMO_CACHE_SIGNING_PUBLIC_KEY", + ) + } + with patch.dict(os.environ, env, clear=True): + result = CacheSigner.from_env() + assert result is None + + def test_private_key_env_takes_precedence(self) -> None: + private_pem, public_pem = generate_keypair() + with patch.dict( + os.environ, + { + "MARIMO_CACHE_SIGNING_PRIVATE_KEY": private_pem, + "MARIMO_CACHE_SIGNING_PUBLIC_KEY": public_pem, + }, + ): + signer = CacheSigner.from_env() + assert signer is not None + assert signer.can_sign + + def test_public_key_only_env(self) -> None: + _, public_pem = generate_keypair() + env_without_private = { + k: v + for k, v in os.environ.items() + if k != "MARIMO_CACHE_SIGNING_PRIVATE_KEY" + } + env_without_private["MARIMO_CACHE_SIGNING_PUBLIC_KEY"] = public_pem + with patch.dict(os.environ, env_without_private, clear=True): + signer = CacheSigner.from_env() + assert signer is not None + assert not signer.can_sign + + def test_custom_env_var_names(self) -> None: + private_pem, _ = generate_keypair() + with patch.dict(os.environ, {"MY_PRIV": private_pem}): + signer = CacheSigner.from_env( + private_key_env="MY_PRIV", public_key_env="MY_PUB" + ) + assert signer is not None + assert signer.can_sign + + +class TestFingerprint: + def test_format_unpadded_sha256(self) -> None: + _, pub = generate_keypair() + fp = fingerprint(pub) + assert fp.startswith("SHA256:") + assert "=" not in fp + + def test_matches_signer_method(self) -> None: + priv, pub = generate_keypair() + signer = CacheSigner.from_private_key_pem(priv) + assert signer.fingerprint() == fingerprint(pub) + + def test_deterministic_and_distinct(self) -> None: + _, pub_a = generate_keypair() + _, pub_b = generate_keypair() + assert fingerprint(pub_a) == fingerprint(pub_a) + assert fingerprint(pub_a) != fingerprint(pub_b) + + def test_rejects_garbage_pem(self) -> None: + with pytest.raises(ValueError): + fingerprint( + "-----BEGIN PUBLIC KEY-----\nnope\n-----END PUBLIC KEY-----" + ) + + +class TestGetDefaultSigner: + def test_returns_none_without_cryptography(self) -> None: + from marimo._save.signing import _get_default_signer + + with patch( + "marimo._dependencies.dependencies.DependencyManager.cryptography.has", + return_value=False, + ): + assert _get_default_signer() is None + + def test_env_var_takes_precedence(self) -> None: + from marimo._save.signing import _get_default_signer + + private_pem, _ = generate_keypair() + with patch.dict( + os.environ, + {"MARIMO_CACHE_SIGNING_PRIVATE_KEY": private_pem}, + ): + signer = _get_default_signer() + assert signer is not None + assert signer.can_sign + + def test_generates_and_persists_key(self, tmp_path: Any) -> None: + from marimo._save.signing import _get_default_signer + + key_path = tmp_path / "cache_signing_key.pem" + + # Ensure env vars don't interfere + env = { + k: v + for k, v in os.environ.items() + if k + not in ( + "MARIMO_CACHE_SIGNING_PRIVATE_KEY", + "MARIMO_CACHE_SIGNING_PUBLIC_KEY", + ) + } + with ( + patch.dict(os.environ, env, clear=True), + patch( + "marimo._utils.xdg.marimo_state_dir", + return_value=tmp_path, + ), + ): + signer = _get_default_signer() + assert signer is not None + assert signer.can_sign + assert key_path.exists() + # Key file should be readable and produce the same public key + loaded = CacheSigner.from_private_key_pem(key_path.read_text()) + assert loaded.public_key_pem() == signer.public_key_pem() + + def test_loads_existing_key(self, tmp_path: Any) -> None: + from marimo._save.signing import _get_default_signer + + # Pre-create a key file + private_pem, _ = generate_keypair() + key_path = tmp_path / "cache_signing_key.pem" + key_path.write_text(private_pem) + + env = { + k: v + for k, v in os.environ.items() + if k + not in ( + "MARIMO_CACHE_SIGNING_PRIVATE_KEY", + "MARIMO_CACHE_SIGNING_PUBLIC_KEY", + ) + } + with ( + patch.dict(os.environ, env, clear=True), + patch( + "marimo._utils.xdg.marimo_state_dir", + return_value=tmp_path, + ), + ): + signer = _get_default_signer() + assert signer is not None + # Should load the existing key, not generate a new one + assert ( + signer.public_key_pem() + == CacheSigner.from_private_key_pem( + private_pem + ).public_key_pem() + ) + + def test_corrupt_key_file_regenerates(self, tmp_path: Any) -> None: + from marimo._save.signing import _get_default_signer + + key_path = tmp_path / "cache_signing_key.pem" + key_path.write_text("not a valid PEM") + + env = { + k: v + for k, v in os.environ.items() + if k + not in ( + "MARIMO_CACHE_SIGNING_PRIVATE_KEY", + "MARIMO_CACHE_SIGNING_PUBLIC_KEY", + ) + } + with ( + patch.dict(os.environ, env, clear=True), + patch( + "marimo._utils.xdg.marimo_state_dir", + return_value=tmp_path, + ), + ): + signer = _get_default_signer() + assert signer is not None + assert signer.can_sign + # Should have overwritten the corrupt file + assert key_path.exists() + assert "PRIVATE KEY" in key_path.read_text() diff --git a/tests/_schemas/test_schema.py b/tests/_schemas/test_schema.py new file mode 100644 index 00000000000..93fb994e246 --- /dev/null +++ b/tests/_schemas/test_schema.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +import yaml + + +@pytest.mark.skipif( + sys.platform == "win32" or sys.version_info != (3, 12), + reason="This test is flaky on Windows. And only test on 3.12", +) +def test_session_schema_up_to_date() -> None: + current_session_schema = yaml.safe_load( + Path("marimo/_schemas/generated/session.yaml").read_text() + ) + current_notebook_schema = yaml.safe_load( + Path("marimo/_schemas/generated/notebook.yaml").read_text() + ) + current_notifications_schema = yaml.safe_load( + Path("marimo/_schemas/generated/notifications.yaml").read_text() + ) + + import sys + + sys.path.append(str(Path(__file__).parent.parent.parent)) + from scripts.generate_schemas import generate_schema + + generated_session_schema = yaml.safe_load(generate_schema("session")) + generated_notebook_schema = yaml.safe_load(generate_schema("notebook")) + generated_notifications_schema = yaml.safe_load( + generate_schema("notifications") + ) + cmd = "python scripts/generate_schemas.py" + + assert current_session_schema == generated_session_schema, ( + f"session.yaml is not up to date. Run '{cmd}' and press 'Write schema' to update." + ) + assert current_notebook_schema == generated_notebook_schema, ( + f"notebook.yaml is not up to date. Run '{cmd}' and press 'Write schema' to update." + ) + assert current_notifications_schema == generated_notifications_schema, ( + f"notifications.yaml is not up to date. Run '{cmd}' and press 'Write schema' to update." + ) diff --git a/tests/_secrets/test_load_dotenv.py b/tests/_secrets/test_load_dotenv.py new file mode 100644 index 00000000000..7125a33ee98 --- /dev/null +++ b/tests/_secrets/test_load_dotenv.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +import pytest + +from marimo._dependencies.dependencies import DependencyManager +from marimo._secrets.load_dotenv import ( + _drop_quotes, + escape_dotenv_value, + load_dotenv_with_fallback, + load_to_environ, + parse_dotenv, + read_dotenv_with_fallback, +) + +if TYPE_CHECKING: + from pathlib import Path + + +def test_drop_quotes(): + assert _drop_quotes('"value"') == "value" + assert _drop_quotes("'value'") == "value" + assert _drop_quotes("value") == "value" + assert _drop_quotes('"value') == '"value' + assert _drop_quotes("value'") == "value'" + assert _drop_quotes('"') == '"' + assert _drop_quotes("'") == "'" + + +def test_drop_quotes_unescapes_double_quoted_values(): + # Escape sequences are only decoded inside double quotes, matching + # python-dotenv. + assert _drop_quotes(r'"{\"type\": \"service_account\"}"') == ( + '{"type": "service_account"}' + ) + assert _drop_quotes(r'"C:\\tmp"') == "C:\\tmp" + assert _drop_quotes(r'"line1\nline2"') == "line1\nline2" + # A literal backslash-n is written as `\\n`, and stays a literal. + assert _drop_quotes(r'"a\\nb"') == r"a\nb" + # Unknown escapes are left alone. + assert _drop_quotes(r'"50\% off"') == r"50\% off" + assert _drop_quotes(r"'{\"type\": 1}'") == r"{\"type\": 1}" + + +def test_parse_dotenv(tmp_path: Path): + env_file = tmp_path / ".env" + env_file.write_text( + """ +# Comment +KEY1=value1 +KEY2="value2" +KEY3='value3' +KEY4= +KEY5="value5 +KEY6=value6" +""" + ) + + env_dict = parse_dotenv(str(env_file)) + assert env_dict == { + "KEY1": "value1", + "KEY2": "value2", + "KEY3": "value3", + "KEY4": "", + "KEY5": '"value5', + "KEY6": 'value6"', + } + + +def test_parse_dotenv_nonexistent(): + env_dict = parse_dotenv("nonexistent.env") + assert env_dict == {} + + +def test_load_to_environ(): + env_dict = {"TEST_KEY": "test_value"} + load_to_environ(env_dict) + assert os.environ["TEST_KEY"] == "test_value" + del os.environ["TEST_KEY"] + + +def test_load_dotenv_with_fallback(tmp_path: Path): + # Should work regardless of whether dotenv is installed + env_file = tmp_path / ".env" + env_file.write_text("TEST_KEY=test_value") + + load_dotenv_with_fallback(str(env_file)) + assert os.environ["TEST_KEY"] == "test_value" + del os.environ["TEST_KEY"] + + +def test_read_dotenv_with_fallback(tmp_path: Path): + # Should work regardless of whether dotenv is installed + env_file = tmp_path / ".env" + env_file.write_text("TEST_KEY=test_value") + + env_dict = read_dotenv_with_fallback(str(env_file)) + assert env_dict == {"TEST_KEY": "test_value"} + + +ROUND_TRIP_VALUES = [ + "simple", + 'json {"type": "service_account", "id": 1}', + r'{"private_key": "-----BEGIN-----\nabc\n-----END-----\n"}', + "C:\\Users\\tmp", + "multi\nline", + "carriage\r\nreturn", + "single 'quoted'", + "tab\tseparated", + "trailing backslash \\", + "# not a comment", +] + + +@pytest.mark.parametrize("value", ROUND_TRIP_VALUES) +def test_write_key_round_trips(tmp_path: Path, value: str): + from marimo._secrets.env_provider import DotEnvSecretsProvider + + env_file = tmp_path / ".env" + env_file.touch() + DotEnvSecretsProvider(str(env_file)).write_key("SECRET", value) + + # The fallback parser, which is what most installs use + assert parse_dotenv(str(env_file))["SECRET"] == value + # ...and python-dotenv, which must agree with it + assert read_dotenv_with_fallback(str(env_file))["SECRET"] == value + + +@pytest.mark.skipif( + not DependencyManager.dotenv.has(), reason="dotenv is not installed" +) +@pytest.mark.parametrize("value", ROUND_TRIP_VALUES) +def test_escape_dotenv_value_matches_dotenv(tmp_path: Path, value: str): + from dotenv import dotenv_values + + env_file = tmp_path / ".env" + env_file.write_text( + f'SECRET="{escape_dotenv_value(value)}"\n', encoding="utf-8" + ) + assert dotenv_values(str(env_file))["SECRET"] == value + assert parse_dotenv(str(env_file))["SECRET"] == value + + +def test_load_dotenv_no_override(tmp_path: Path): + # Test that existing environment variables are not overridden + env_file = tmp_path / ".env" + env_file.write_text("SOME_KEY=env_value") + + # Set the environment variable before loading + os.environ["SOME_KEY"] = "env_value" + + env_file_2 = tmp_path / ".env2" + env_file_2.write_text("SOME_KEY=a_new_value") + + # Load the .env file + load_dotenv_with_fallback(str(env_file)) + + # Verify the original value is preserved + assert os.environ["SOME_KEY"] == "env_value" + + # Clean up + del os.environ["SOME_KEY"] diff --git a/tests/_secrets/test_secrets.py b/tests/_secrets/test_secrets.py new file mode 100644 index 00000000000..74d234d80d4 --- /dev/null +++ b/tests/_secrets/test_secrets.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +import pytest + +from marimo._config.config import MarimoConfig +from marimo._secrets.secrets import get_secret_keys, write_secret +from marimo._server.models.secrets import CreateSecretRequest + +if TYPE_CHECKING: + from pathlib import Path + + +def test_get_secret_keys_basic(monkeypatch: pytest.MonkeyPatch): + # Setup environment variables + monkeypatch.setenv("ENV_SECRET", "value") + original_environ = os.environ.copy() + + config = MarimoConfig( + runtime={ + "dotenv": [], + } + ) + + result = get_secret_keys(config, original_environ) + + # Should have one provider (env) + assert len(result) == 1 + assert result[0].provider == "env" + assert "ENV_SECRET" in result[0].keys + + +def test_get_secret_keys_empty(monkeypatch: pytest.MonkeyPatch): + # Clear any test environment variables + config = MarimoConfig( + runtime={ + "dotenv": [], + } + ) + + # Add a test key + test_key = "TEST_SECRET_KEY_UNIQUE" + monkeypatch.setenv(test_key, "value") + original_environ = os.environ.copy() + + result = get_secret_keys(config, original_environ) + + # Should have one provider (env) + assert len(result) == 1 + assert result[0].provider == "env" + + # Our test key should be in the result + assert test_key in result[0].keys + + +def test_get_secret_keys_with_dotenv( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + # Create a .env file + env_file = tmp_path / ".env" + env_file.write_text( + "DOTENV_SECRET=secret_value\nSHARED_SECRET=dotenv_value" + ) + + # Set an environment variable that will override the .env file + monkeypatch.setenv("SHARED_SECRET", "env_value") + original_environ = os.environ.copy() + + config = MarimoConfig( + runtime={ + "dotenv": [str(env_file)], + } + ) + + result = get_secret_keys(config, original_environ) + + # Should have two providers (env and dotenv) + assert len(result) == 2 + + # First provider should be env + assert result[0].provider == "env" + assert "SHARED_SECRET" in result[0].keys + + # Second provider should be dotenv + assert result[1].provider == "dotenv" + assert "DOTENV_SECRET" in result[1].keys + # SHARED_SECRET should not be in dotenv keys since it's already in env + assert "SHARED_SECRET" not in result[1].keys + + +def test_get_secret_keys_multiple_dotenv( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + # Create two .env files + env_file1 = tmp_path / ".env1" + env_file1.write_text("DOTENV1_SECRET=value1\nSHARED_SECRET=dotenv1_value") + + env_file2 = tmp_path / ".env2" + env_file2.write_text("DOTENV2_SECRET=value2\nSHARED_SECRET=dotenv2_value") + + # Set an environment variable + monkeypatch.setenv("ENV_SECRET", "env_value") + original_environ = os.environ.copy() + + config = MarimoConfig( + runtime={ + "dotenv": [str(env_file1), str(env_file2)], + } + ) + + result = get_secret_keys(config, original_environ) + + # Should have three providers (env and two dotenv) + assert len(result) == 3 + + # First provider should be env + assert result[0].provider == "env" + assert "ENV_SECRET" in result[0].keys + + # Second provider should be first dotenv + assert result[1].provider == "dotenv" + assert "DOTENV1_SECRET" in result[1].keys + assert "SHARED_SECRET" in result[1].keys + + # Third provider should be second dotenv + assert result[2].provider == "dotenv" + assert "DOTENV2_SECRET" in result[2].keys + # SHARED_SECRET should not be in second dotenv keys since it's already in first dotenv + assert "SHARED_SECRET" not in result[2].keys + + +def test_write_secret(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + # Create a .env file + env_file = tmp_path / ".env" + env_file.write_text("EXISTING_SECRET=value") + + # Set an environment variable + monkeypatch.setenv("ENV_SECRET", "env_value") + + config = MarimoConfig( + runtime={ + "dotenv": [str(env_file)], + } + ) + + # Write a new secret to the dotenv file + write_secret( + CreateSecretRequest( + key="NEW_SECRET", + value="new_value", + provider="dotenv", + name=".env", + ), + config, + ) + + # Verify the secret was written to the file + content = env_file.read_text() + assert "EXISTING_SECRET=value" in content + assert 'NEW_SECRET="new_value"' in content + + # Try to write to env provider (should raise an error) + with pytest.raises(NotImplementedError): + write_secret( + CreateSecretRequest( + key="ENV_SECRET", + value="new_env_value", + provider="env", + name="Environment variables", + ), + config, + ) + + +def test_write_invalid_secret(tmp_path: Path): + env_file = tmp_path / ".env" + env_file.write_text("EXISTING_SECRET=value") + + config = MarimoConfig( + runtime={ + "dotenv": [str(env_file)], + } + ) + + # Empty key + with pytest.raises(AssertionError): + write_secret( + CreateSecretRequest( + key="", + value="new_env_value", + provider="dotenv", + name=".env", + ), + config, + ) + + # Empty value + with pytest.raises(AssertionError): + write_secret( + CreateSecretRequest( + key="NEW_SECRET", + value="", + provider="dotenv", + name=".env", + ), + config, + ) + + # Key with whitespace + with pytest.raises(ValueError): + write_secret( + CreateSecretRequest( + key="NEW SECRET", + value="new_env_value", + provider="dotenv", + name=".env", + ), + config, + ) + + +def test_write_secret_multiple_dotenv(tmp_path: Path): + # Create two .env files + env_file1 = tmp_path / ".env1" + env_file1.write_text("DOTENV1_SECRET=value1") + + env_file2 = tmp_path / ".env2" + env_file2.write_text("DOTENV2_SECRET=value2") + + original_environ = os.environ.copy() + + config = MarimoConfig( + runtime={ + "dotenv": [str(env_file1), str(env_file2)], + } + ) + + # Write a new secret - should go to the first dotenv file by default + write_secret( + CreateSecretRequest( + key="NEW_SECRET", + value="new_value", + provider="dotenv", + name=".env1", + ), + config, + ) + # Verify the secret was written to the first file + content1 = env_file1.read_text() + assert content1 == 'DOTENV1_SECRET=value1\nNEW_SECRET="new_value"\n' + + # Verify the second file is unchanged + content2 = env_file2.read_text() + assert "NEW_SECRET" not in content2 + + +def test_write_secret_nonexistent_file(tmp_path: Path): + nonexistent_file = tmp_path / "nonexistent.env" + + config = MarimoConfig( + runtime={ + "dotenv": [str(nonexistent_file)], + } + ) + + with pytest.raises(ValueError): + write_secret( + CreateSecretRequest( + key="NEW_SECRET", + value="new_value", + provider="dotenv", + name=str(nonexistent_file), + ), + config, + ) + + # Will write non existent file, if its the default .env file + env_file = tmp_path / ".env" + assert not env_file.exists() + + config = MarimoConfig( + runtime={ + "dotenv": [str(tmp_path / ".env")], + } + ) + + write_secret( + CreateSecretRequest( + key="NEW_SECRET", + value="new_value", + provider="dotenv", + name=".env", + ), + config, + ) + + content = env_file.read_text() + assert content == 'NEW_SECRET="new_value"\n' diff --git a/tests/_server/ai/skills/test_skills_utils.py b/tests/_server/ai/skills/test_skills_utils.py new file mode 100644 index 00000000000..ec177d702a6 --- /dev/null +++ b/tests/_server/ai/skills/test_skills_utils.py @@ -0,0 +1,49 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from marimo._server.ai.skills.utils import ( + REFERENCE_NAMES, + SKILL_NAMES, + load_reference, + load_skill, +) + + +def test_load_skill_returns_skill_contents() -> None: + content = load_skill("marimo-pair") + assert content.strip() + # The bundled skill carries its frontmatter name. + assert "name: marimo-pair" in content + + +def test_load_skill_is_cached() -> None: + # lru_cache returns the identical object on repeated calls. + assert load_skill("marimo-pair") is load_skill("marimo-pair") + + +def test_all_declared_skills_are_loadable() -> None: + for skill_name in SKILL_NAMES: + assert load_skill(skill_name).strip() + + +def test_load_reference_returns_reference_contents() -> None: + content = load_reference("gotchas") + assert content.strip() + assert content.startswith("# Gotchas") + + +def test_load_reference_is_cached() -> None: + assert load_reference("gotchas") is load_reference("gotchas") + + +def test_all_declared_references_are_loadable() -> None: + for reference_name in REFERENCE_NAMES: + assert load_reference(reference_name).strip() + + +def test_reference_files_point_to_load_capability() -> None: + for reference_name in REFERENCE_NAMES: + content = load_reference(reference_name) + assert "load_capability" in content + assert f"`{reference_name}` capability" in content + assert "Loaded on demand via" in content diff --git a/tests/_server/ai/snapshots/chat_system_prompts.txt b/tests/_server/ai/snapshots/chat_system_prompts.txt index 8d653790cf7..94dc236ac10 100644 --- a/tests/_server/ai/snapshots/chat_system_prompts.txt +++ b/tests/_server/ai/snapshots/chat_system_prompts.txt @@ -2,136 +2,768 @@ ==================== no custom rules ==================== -You are a helpful assistant working in a marimo notebook. You can answer questions and help with tasks. You may respond with markdown, code, or a combination of both. If you respond in code, you must use the appropriate language block. And you only work with two languages: Python and SQL. When responding in code, think of each block of code as a separate cell in the notebook. The notebook has 2 hard rules: -1. Do not import the same library twice. -2. Do not define a variable if it already exists. You may reference variables from previous cells, but you may not define a variable if it already exists. +You are Marimo Copilot, an AI assistant integrated into the marimo notebook code editor. +Your primary function is to help users create, analyze, and improve data science notebooks using marimo's reactive programming model. +## Capabilities +- Answer questions and provide guidance using only your internal knowledge and the notebook context provided by the user. + +## Limitations +- You do NOT have access to any external tools, plugins, or APIs. +- You may not perform any actions beyond generating text and code suggestions. + +Current notebook session ID: s_test. Use this session_id with tools that require it. + +Your goal is to do one of the following two things: + +1. Help users answer questions related to their notebook. +2. Answer general-purpose questions unrelated to their particular notebook. + +It will be up to you to decide which of these you are doing based on what the user has told you. When unclear, ask clarifying questions to understand the user's intent before proceeding. + +The user may reference additional context in the form @kind://name. You can use this context to help you with the current task. + +You can respond with markdown, code, or a combination of both. You only work with two languages: Python and SQL. +When responding in code, think of each block of code as a separate cell in the notebook. + +You have the following rules: + +- Do not import the same library twice. +- Do not define a variable if it already exists. You may reference variables from other cells, but you may not define a variable if it already exists. + +# Marimo fundamentals + +Marimo is a reactive notebook that differs from traditional notebooks in key ways: +- Cells execute automatically when their dependencies change +- Variables cannot be redeclared across cells +- The notebook forms a directed acyclic graph (DAG) +- The last expression in a cell is automatically displayed +- UI elements are reactive and update the notebook automatically + +Marimo's reactivity means: +- When a variable changes, all cells that use that variable automatically re-execute +- UI elements trigger updates when their values change without explicit callbacks +- UI element values are accessed through `.value` attribute +- You cannot access a UI element's value in the same cell where it's defined + +## Best Practices + + +- Use polars for data manipulation +- Implement proper data validation +- Handle missing values appropriately +- Use efficient data structures +- A variable in the last expression of a cell is automatically displayed as a table + + + +- Access UI element values with .value attribute (e.g., slider.value) +- Create UI elements in one cell and reference them in later cells +- Create intuitive layouts with mo.hstack(), mo.vstack(), and mo.tabs() +- Prefer reactive updates over callbacks (marimo handles reactivity automatically) +- Group related UI elements for better organization + + +## Available UI elements + +* `mo.ui.altair_chart(altair_chart)` - create a reactive Altair chart +* `mo.ui.button(value=None, kind='primary')` - create a clickable button +* `mo.ui.run_button(label=None, tooltip=None, kind='primary')` - create a button that runs code +* `mo.ui.checkbox(label='', value=False)` - create a checkbox +* `mo.ui.chat(placeholder='', value=None)` - create a chat interface +* `mo.ui.date(value=None, label=None, full_width=False)` - create a date picker +* `mo.ui.dropdown(options, value=None, label=None, full_width=False)` - create a dropdown menu +* `mo.ui.file(label='', multiple=False, full_width=False)` - create a file upload element +* `mo.ui.number(value=None, label=None, full_width=False)` - create a number input +* `mo.ui.radio(options, value=None, label=None, full_width=False)` - create radio buttons +* `mo.ui.refresh(options: List[str], default_interval: str)` - create a refresh control +* `mo.ui.slider(start, stop, value=None, label=None, full_width=False, step=None)` - create a slider +* `mo.ui.range_slider(start, stop, value=None, label=None, full_width=False, step=None)` - create a range slider +* `mo.ui.table(data, columns=None, on_select=None, sortable=True, filterable=True)` - create an interactive table +* `mo.ui.text(value='', label=None, full_width=False)` - create a text input +* `mo.ui.text_area(value='', label=None, full_width=False)` - create a multi-line text input +* `mo.ui.data_explorer(df)` - create an interactive dataframe explorer +* `mo.ui.dataframe(df)` - display a dataframe with search, filter, and sort capabilities +* `mo.ui.plotly(plotly_figure)` - create a reactive Plotly chart (supports scatter, treemap, and sunburst) +* `mo.ui.tabs(elements: dict[str, mo.ui.Element])` - create a tabbed interface from a dictionary +* `mo.ui.array(elements: list[mo.ui.Element])` - create an array of UI elements +* `mo.ui.form(element: mo.ui.Element, label='', bordered=True)` - wrap an element in a form + +## Layout and utility functions + +* `mo.stop(predicate, output=None)` - stop execution conditionally +* `mo.Html(html)` - display HTML +* `mo.image(image)` - display an image +* `mo.hstack(elements)` - stack elements horizontally +* `mo.vstack(elements)` - stack elements vertically +* `mo.tabs(elements)` - create a tabbed interface +* `mo.mpl.interactive()` - make matplotlib plots interactive + +## Examples + + +import marimo as mo +import altair as alt +import polars as pl +import numpy as np -Rules for python: -1. When using matplotlib to show plots, use plt.gca() instead of plt.show(). -2. If an import already exists, do not import it again. -3. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. - -Rules for sql: +# Create a slider and display it +n_points = mo.ui.slider(10, 100, value=50, label="Number of points") +n_points # Display the slider + +# Generate random data based on slider value +# This cell automatically re-executes when n_points.value changes +x = np.random.rand(n_points.value) +y = np.random.rand(n_points.value) + +df = pl.DataFrame({"x": x, "y": y}) + +chart = alt.Chart(df).mark_circle(opacity=0.7).encode( + x=alt.X('x', title='X axis'), + y=alt.Y('y', title='Y axis') +).properties( + title=f"Scatter plot with {n_points.value} points", + width=400, + height=300 +) + +chart + + +## Rules for python: +1. For matplotlib: use plt.gca() as the last expression instead of plt.show(). +2. For plotly: return the figure object directly. +3. For altair: return the chart object directly. Add tooltips where appropriate. You can pass polars dataframes directly to altair (e.g., alt.Chart(df)). +4. Include proper labels, titles, and color schemes. +5. Make visualizations interactive where appropriate. +6. If an import already exists, do not import it again. +7. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. + +## Rules for markdown: +1. Use double dollar signs ($$) for ALL mathematical expressions (both inline and block) in markdown responses. +2. Example: $$E=mc^2$$ +3. Do NOT use single dollar signs or square brackets for math. + +## Rules for sql: 1. The SQL must use duckdb syntax. ==================== with custom rules ==================== -You are a helpful assistant working in a marimo notebook. You can answer questions and help with tasks. You may respond with markdown, code, or a combination of both. If you respond in code, you must use the appropriate language block. And you only work with two languages: Python and SQL. When responding in code, think of each block of code as a separate cell in the notebook. The notebook has 2 hard rules: -1. Do not import the same library twice. -2. Do not define a variable if it already exists. You may reference variables from previous cells, but you may not define a variable if it already exists. +You are Marimo Copilot, an AI assistant integrated into the marimo notebook code editor. +Your primary function is to help users create, analyze, and improve data science notebooks using marimo's reactive programming model. +## Capabilities +- Answer questions and provide guidance using only your internal knowledge and the notebook context provided by the user. + +## Limitations +- You do NOT have access to any external tools, plugins, or APIs. +- You may not perform any actions beyond generating text and code suggestions. + +Current notebook session ID: s_test. Use this session_id with tools that require it. + +Your goal is to do one of the following two things: + +1. Help users answer questions related to their notebook. +2. Answer general-purpose questions unrelated to their particular notebook. + +It will be up to you to decide which of these you are doing based on what the user has told you. When unclear, ask clarifying questions to understand the user's intent before proceeding. + +The user may reference additional context in the form @kind://name. You can use this context to help you with the current task. + +You can respond with markdown, code, or a combination of both. You only work with two languages: Python and SQL. +When responding in code, think of each block of code as a separate cell in the notebook. + +You have the following rules: + +- Do not import the same library twice. +- Do not define a variable if it already exists. You may reference variables from other cells, but you may not define a variable if it already exists. + +# Marimo fundamentals + +Marimo is a reactive notebook that differs from traditional notebooks in key ways: +- Cells execute automatically when their dependencies change +- Variables cannot be redeclared across cells +- The notebook forms a directed acyclic graph (DAG) +- The last expression in a cell is automatically displayed +- UI elements are reactive and update the notebook automatically + +Marimo's reactivity means: +- When a variable changes, all cells that use that variable automatically re-execute +- UI elements trigger updates when their values change without explicit callbacks +- UI element values are accessed through `.value` attribute +- You cannot access a UI element's value in the same cell where it's defined + +## Best Practices + + +- Use polars for data manipulation +- Implement proper data validation +- Handle missing values appropriately +- Use efficient data structures +- A variable in the last expression of a cell is automatically displayed as a table + + + +- Access UI element values with .value attribute (e.g., slider.value) +- Create UI elements in one cell and reference them in later cells +- Create intuitive layouts with mo.hstack(), mo.vstack(), and mo.tabs() +- Prefer reactive updates over callbacks (marimo handles reactivity automatically) +- Group related UI elements for better organization + + +## Available UI elements + +* `mo.ui.altair_chart(altair_chart)` - create a reactive Altair chart +* `mo.ui.button(value=None, kind='primary')` - create a clickable button +* `mo.ui.run_button(label=None, tooltip=None, kind='primary')` - create a button that runs code +* `mo.ui.checkbox(label='', value=False)` - create a checkbox +* `mo.ui.chat(placeholder='', value=None)` - create a chat interface +* `mo.ui.date(value=None, label=None, full_width=False)` - create a date picker +* `mo.ui.dropdown(options, value=None, label=None, full_width=False)` - create a dropdown menu +* `mo.ui.file(label='', multiple=False, full_width=False)` - create a file upload element +* `mo.ui.number(value=None, label=None, full_width=False)` - create a number input +* `mo.ui.radio(options, value=None, label=None, full_width=False)` - create radio buttons +* `mo.ui.refresh(options: List[str], default_interval: str)` - create a refresh control +* `mo.ui.slider(start, stop, value=None, label=None, full_width=False, step=None)` - create a slider +* `mo.ui.range_slider(start, stop, value=None, label=None, full_width=False, step=None)` - create a range slider +* `mo.ui.table(data, columns=None, on_select=None, sortable=True, filterable=True)` - create an interactive table +* `mo.ui.text(value='', label=None, full_width=False)` - create a text input +* `mo.ui.text_area(value='', label=None, full_width=False)` - create a multi-line text input +* `mo.ui.data_explorer(df)` - create an interactive dataframe explorer +* `mo.ui.dataframe(df)` - display a dataframe with search, filter, and sort capabilities +* `mo.ui.plotly(plotly_figure)` - create a reactive Plotly chart (supports scatter, treemap, and sunburst) +* `mo.ui.tabs(elements: dict[str, mo.ui.Element])` - create a tabbed interface from a dictionary +* `mo.ui.array(elements: list[mo.ui.Element])` - create an array of UI elements +* `mo.ui.form(element: mo.ui.Element, label='', bordered=True)` - wrap an element in a form + +## Layout and utility functions + +* `mo.stop(predicate, output=None)` - stop execution conditionally +* `mo.Html(html)` - display HTML +* `mo.image(image)` - display an image +* `mo.hstack(elements)` - stack elements horizontally +* `mo.vstack(elements)` - stack elements vertically +* `mo.tabs(elements)` - create a tabbed interface +* `mo.mpl.interactive()` - make matplotlib plots interactive + +## Examples + + +import marimo as mo +import altair as alt +import polars as pl +import numpy as np -Rules for python: -1. When using matplotlib to show plots, use plt.gca() instead of plt.show(). -2. If an import already exists, do not import it again. -3. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. - -Rules for sql: +# Create a slider and display it +n_points = mo.ui.slider(10, 100, value=50, label="Number of points") +n_points # Display the slider + +# Generate random data based on slider value +# This cell automatically re-executes when n_points.value changes +x = np.random.rand(n_points.value) +y = np.random.rand(n_points.value) + +df = pl.DataFrame({"x": x, "y": y}) + +chart = alt.Chart(df).mark_circle(opacity=0.7).encode( + x=alt.X('x', title='X axis'), + y=alt.Y('y', title='Y axis') +).properties( + title=f"Scatter plot with {n_points.value} points", + width=400, + height=300 +) + +chart + + +## Rules for python: +1. For matplotlib: use plt.gca() as the last expression instead of plt.show(). +2. For plotly: return the figure object directly. +3. For altair: return the chart object directly. Add tooltips where appropriate. You can pass polars dataframes directly to altair (e.g., alt.Chart(df)). +4. Include proper labels, titles, and color schemes. +5. Make visualizations interactive where appropriate. +6. If an import already exists, do not import it again. +7. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. + +## Rules for markdown: +1. Use double dollar signs ($$) for ALL mathematical expressions (both inline and block) in markdown responses. +2. Example: $$E=mc^2$$ +3. Do NOT use single dollar signs or square brackets for math. + +## Rules for sql: 1. The SQL must use duckdb syntax. -Additional rules: +## Additional rules: Always be polite. -==================== with variables ==================== - -You are a helpful assistant working in a marimo notebook. You can answer questions and help with tasks. You may respond with markdown, code, or a combination of both. If you respond in code, you must use the appropriate language block. And you only work with two languages: Python and SQL. When responding in code, think of each block of code as a separate cell in the notebook. The notebook has 2 hard rules: -1. Do not import the same library twice. -2. Do not define a variable if it already exists. You may reference variables from previous cells, but you may not define a variable if it already exists. - - -Rules for python: -1. When using matplotlib to show plots, use plt.gca() instead of plt.show(). -2. If an import already exists, do not import it again. -3. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. - -Rules for sql: -1. The SQL must use duckdb syntax. - -Variables to use but not define: -['var1', 'var2'] - -==================== with context ==================== - -You are a helpful assistant working in a marimo notebook. You can answer questions and help with tasks. You may respond with markdown, code, or a combination of both. If you respond in code, you must use the appropriate language block. And you only work with two languages: Python and SQL. When responding in code, think of each block of code as a separate cell in the notebook. The notebook has 2 hard rules: -1. Do not import the same library twice. -2. Do not define a variable if it already exists. You may reference variables from previous cells, but you may not define a variable if it already exists. - - -Rules for python: -1. When using matplotlib to show plots, use plt.gca() instead of plt.show(). -2. If an import already exists, do not import it again. -3. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. - -Rules for sql: -1. The SQL must use duckdb syntax. - -Available schema: -- Table: df_1 - - Column: age - - Type: int - - Sample values: 1, 2, 3 - - Column: name - - Type: str - - Sample values: Alice, Bob, Charlie -- Table: d2_2 - - Column: a - - Type: int - - Sample values: 1, 2, 3 - - Column: b - - Type: int - - Sample values: 4, 5, 6 - - ==================== with other code ==================== -You are a helpful assistant working in a marimo notebook. You can answer questions and help with tasks. You may respond with markdown, code, or a combination of both. If you respond in code, you must use the appropriate language block. And you only work with two languages: Python and SQL. When responding in code, think of each block of code as a separate cell in the notebook. The notebook has 2 hard rules: -1. Do not import the same library twice. -2. Do not define a variable if it already exists. You may reference variables from previous cells, but you may not define a variable if it already exists. - -Rules for python: -1. When using matplotlib to show plots, use plt.gca() instead of plt.show(). -2. If an import already exists, do not import it again. -3. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. +You are Marimo Copilot, an AI assistant integrated into the marimo notebook code editor. +Your primary function is to help users create, analyze, and improve data science notebooks using marimo's reactive programming model. +## Capabilities +- Answer questions and provide guidance using only your internal knowledge and the notebook context provided by the user. + +## Limitations +- You do NOT have access to any external tools, plugins, or APIs. +- You may not perform any actions beyond generating text and code suggestions. + +Current notebook session ID: s_test. Use this session_id with tools that require it. + +Your goal is to do one of the following two things: + +1. Help users answer questions related to their notebook. +2. Answer general-purpose questions unrelated to their particular notebook. + +It will be up to you to decide which of these you are doing based on what the user has told you. When unclear, ask clarifying questions to understand the user's intent before proceeding. + +The user may reference additional context in the form @kind://name. You can use this context to help you with the current task. + +You can respond with markdown, code, or a combination of both. You only work with two languages: Python and SQL. +When responding in code, think of each block of code as a separate cell in the notebook. + +You have the following rules: + +- Do not import the same library twice. +- Do not define a variable if it already exists. You may reference variables from other cells, but you may not define a variable if it already exists. + +# Marimo fundamentals + +Marimo is a reactive notebook that differs from traditional notebooks in key ways: +- Cells execute automatically when their dependencies change +- Variables cannot be redeclared across cells +- The notebook forms a directed acyclic graph (DAG) +- The last expression in a cell is automatically displayed +- UI elements are reactive and update the notebook automatically + +Marimo's reactivity means: +- When a variable changes, all cells that use that variable automatically re-execute +- UI elements trigger updates when their values change without explicit callbacks +- UI element values are accessed through `.value` attribute +- You cannot access a UI element's value in the same cell where it's defined + +## Best Practices + + +- Use polars for data manipulation +- Implement proper data validation +- Handle missing values appropriately +- Use efficient data structures +- A variable in the last expression of a cell is automatically displayed as a table + + + +- Access UI element values with .value attribute (e.g., slider.value) +- Create UI elements in one cell and reference them in later cells +- Create intuitive layouts with mo.hstack(), mo.vstack(), and mo.tabs() +- Prefer reactive updates over callbacks (marimo handles reactivity automatically) +- Group related UI elements for better organization + + +## Available UI elements + +* `mo.ui.altair_chart(altair_chart)` - create a reactive Altair chart +* `mo.ui.button(value=None, kind='primary')` - create a clickable button +* `mo.ui.run_button(label=None, tooltip=None, kind='primary')` - create a button that runs code +* `mo.ui.checkbox(label='', value=False)` - create a checkbox +* `mo.ui.chat(placeholder='', value=None)` - create a chat interface +* `mo.ui.date(value=None, label=None, full_width=False)` - create a date picker +* `mo.ui.dropdown(options, value=None, label=None, full_width=False)` - create a dropdown menu +* `mo.ui.file(label='', multiple=False, full_width=False)` - create a file upload element +* `mo.ui.number(value=None, label=None, full_width=False)` - create a number input +* `mo.ui.radio(options, value=None, label=None, full_width=False)` - create radio buttons +* `mo.ui.refresh(options: List[str], default_interval: str)` - create a refresh control +* `mo.ui.slider(start, stop, value=None, label=None, full_width=False, step=None)` - create a slider +* `mo.ui.range_slider(start, stop, value=None, label=None, full_width=False, step=None)` - create a range slider +* `mo.ui.table(data, columns=None, on_select=None, sortable=True, filterable=True)` - create an interactive table +* `mo.ui.text(value='', label=None, full_width=False)` - create a text input +* `mo.ui.text_area(value='', label=None, full_width=False)` - create a multi-line text input +* `mo.ui.data_explorer(df)` - create an interactive dataframe explorer +* `mo.ui.dataframe(df)` - display a dataframe with search, filter, and sort capabilities +* `mo.ui.plotly(plotly_figure)` - create a reactive Plotly chart (supports scatter, treemap, and sunburst) +* `mo.ui.tabs(elements: dict[str, mo.ui.Element])` - create a tabbed interface from a dictionary +* `mo.ui.array(elements: list[mo.ui.Element])` - create an array of UI elements +* `mo.ui.form(element: mo.ui.Element, label='', bordered=True)` - wrap an element in a form + +## Layout and utility functions + +* `mo.stop(predicate, output=None)` - stop execution conditionally +* `mo.Html(html)` - display HTML +* `mo.image(image)` - display an image +* `mo.hstack(elements)` - stack elements horizontally +* `mo.vstack(elements)` - stack elements vertically +* `mo.tabs(elements)` - create a tabbed interface +* `mo.mpl.interactive()` - make matplotlib plots interactive + +## Examples + + +import marimo as mo +import altair as alt +import polars as pl +import numpy as np -Rules for sql: +# Create a slider and display it +n_points = mo.ui.slider(10, 100, value=50, label="Number of points") +n_points # Display the slider + +# Generate random data based on slider value +# This cell automatically re-executes when n_points.value changes +x = np.random.rand(n_points.value) +y = np.random.rand(n_points.value) + +df = pl.DataFrame({"x": x, "y": y}) + +chart = alt.Chart(df).mark_circle(opacity=0.7).encode( + x=alt.X('x', title='X axis'), + y=alt.Y('y', title='Y axis') +).properties( + title=f"Scatter plot with {n_points.value} points", + width=400, + height=300 +) + +chart + + +## Rules for python: +1. For matplotlib: use plt.gca() as the last expression instead of plt.show(). +2. For plotly: return the figure object directly. +3. For altair: return the chart object directly. Add tooltips where appropriate. You can pass polars dataframes directly to altair (e.g., alt.Chart(df)). +4. Include proper labels, titles, and color schemes. +5. Make visualizations interactive where appropriate. +6. If an import already exists, do not import it again. +7. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. + +## Rules for markdown: +1. Use double dollar signs ($$) for ALL mathematical expressions (both inline and block) in markdown responses. +2. Example: $$E=mc^2$$ +3. Do NOT use single dollar signs or square brackets for math. + +## Rules for sql: 1. The SQL must use duckdb syntax. -Code from other cells: + import pandas as pd import numpy as np + + +==================== with agent mode ==================== + + +You are Marimo Copilot, an AI assistant integrated into the marimo notebook code editor. +Your primary function is to help users create, analyze, and improve data science notebooks using marimo's reactive programming model. +You are in agent mode - you have autonomy to resolve the user's query by using the tools provided. Please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. + + +## Agent Mode +- You are encouraged to edit existing cells in the notebook or add new cells. +- You should do the following things after editing the notebook: + 1. Use the lint notebook tool to check for errors and lint issues + 2. Run stale cells tool to run the code + 3. If there are errors in cells you have added, edit the existing cell. Don't add new cells to correct errors. +- If you say you're about to do something, actually do it in the same turn (run the tool call right after). +- Group code into logical cells, eg. functions should be in separate cells and all the calls will be in one cell. When asked for explanations or summaries, use markdown cells with proper formatting. + +## Capabilities +- You can use a set of read and write tools to gather additional context from the notebook or environment (e.g., searching code, summarizing data, or reading documentation) and to modify the notebook (e.g., adding cells, editing cells, deleting cells). +## Limitations +- You must always explain to the user why you are using a tool before invoking it. + +Current notebook session ID: s_test. Use this session_id with tools that require it. + +Your goal is to do one of the following two things: + +1. Help users answer questions related to their notebook. +2. Answer general-purpose questions unrelated to their particular notebook. + +It will be up to you to decide which of these you are doing based on what the user has told you. When unclear, ask clarifying questions to understand the user's intent before proceeding. + +The user may reference additional context in the form @kind://name. You can use this context to help you with the current task. + +You can respond with markdown, code, or a combination of both. You only work with two languages: Python and SQL. +When responding in code, think of each block of code as a separate cell in the notebook. + +You have the following rules: + +- Do not import the same library twice. +- Do not define a variable if it already exists. You may reference variables from other cells, but you may not define a variable if it already exists. + +# Marimo fundamentals + +Marimo is a reactive notebook that differs from traditional notebooks in key ways: +- Cells execute automatically when their dependencies change +- Variables cannot be redeclared across cells +- The notebook forms a directed acyclic graph (DAG) +- The last expression in a cell is automatically displayed +- UI elements are reactive and update the notebook automatically + +Marimo's reactivity means: +- When a variable changes, all cells that use that variable automatically re-execute +- UI elements trigger updates when their values change without explicit callbacks +- UI element values are accessed through `.value` attribute +- You cannot access a UI element's value in the same cell where it's defined + +## Best Practices + + +- Use polars for data manipulation +- Implement proper data validation +- Handle missing values appropriately +- Use efficient data structures +- A variable in the last expression of a cell is automatically displayed as a table + + + +- Access UI element values with .value attribute (e.g., slider.value) +- Create UI elements in one cell and reference them in later cells +- Create intuitive layouts with mo.hstack(), mo.vstack(), and mo.tabs() +- Prefer reactive updates over callbacks (marimo handles reactivity automatically) +- Group related UI elements for better organization + + +## Available UI elements + +* `mo.ui.altair_chart(altair_chart)` - create a reactive Altair chart +* `mo.ui.button(value=None, kind='primary')` - create a clickable button +* `mo.ui.run_button(label=None, tooltip=None, kind='primary')` - create a button that runs code +* `mo.ui.checkbox(label='', value=False)` - create a checkbox +* `mo.ui.chat(placeholder='', value=None)` - create a chat interface +* `mo.ui.date(value=None, label=None, full_width=False)` - create a date picker +* `mo.ui.dropdown(options, value=None, label=None, full_width=False)` - create a dropdown menu +* `mo.ui.file(label='', multiple=False, full_width=False)` - create a file upload element +* `mo.ui.number(value=None, label=None, full_width=False)` - create a number input +* `mo.ui.radio(options, value=None, label=None, full_width=False)` - create radio buttons +* `mo.ui.refresh(options: List[str], default_interval: str)` - create a refresh control +* `mo.ui.slider(start, stop, value=None, label=None, full_width=False, step=None)` - create a slider +* `mo.ui.range_slider(start, stop, value=None, label=None, full_width=False, step=None)` - create a range slider +* `mo.ui.table(data, columns=None, on_select=None, sortable=True, filterable=True)` - create an interactive table +* `mo.ui.text(value='', label=None, full_width=False)` - create a text input +* `mo.ui.text_area(value='', label=None, full_width=False)` - create a multi-line text input +* `mo.ui.data_explorer(df)` - create an interactive dataframe explorer +* `mo.ui.dataframe(df)` - display a dataframe with search, filter, and sort capabilities +* `mo.ui.plotly(plotly_figure)` - create a reactive Plotly chart (supports scatter, treemap, and sunburst) +* `mo.ui.tabs(elements: dict[str, mo.ui.Element])` - create a tabbed interface from a dictionary +* `mo.ui.array(elements: list[mo.ui.Element])` - create an array of UI elements +* `mo.ui.form(element: mo.ui.Element, label='', bordered=True)` - wrap an element in a form + +## Layout and utility functions + +* `mo.stop(predicate, output=None)` - stop execution conditionally +* `mo.Html(html)` - display HTML +* `mo.image(image)` - display an image +* `mo.hstack(elements)` - stack elements horizontally +* `mo.vstack(elements)` - stack elements vertically +* `mo.tabs(elements)` - create a tabbed interface +* `mo.mpl.interactive()` - make matplotlib plots interactive + +## Examples + + +import marimo as mo +import altair as alt +import polars as pl +import numpy as np -==================== kitchen sink ==================== +# Create a slider and display it +n_points = mo.ui.slider(10, 100, value=50, label="Number of points") +n_points # Display the slider + +# Generate random data based on slider value +# This cell automatically re-executes when n_points.value changes +x = np.random.rand(n_points.value) +y = np.random.rand(n_points.value) + +df = pl.DataFrame({"x": x, "y": y}) + +chart = alt.Chart(df).mark_circle(opacity=0.7).encode( + x=alt.X('x', title='X axis'), + y=alt.Y('y', title='Y axis') +).properties( + title=f"Scatter plot with {n_points.value} points", + width=400, + height=300 +) + +chart + + +## Rules for python: +1. For matplotlib: use plt.gca() as the last expression instead of plt.show(). +2. For plotly: return the figure object directly. +3. For altair: return the chart object directly. Add tooltips where appropriate. You can pass polars dataframes directly to altair (e.g., alt.Chart(df)). +4. Include proper labels, titles, and color schemes. +5. Make visualizations interactive where appropriate. +6. If an import already exists, do not import it again. +7. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. + +## Rules for sql: +1. SQL cells start with df = mo.sql(f"""""") for DuckDB, or df = mo.sql(f"""""", engine=engine) for other SQL engines. You should always write queries inline as the code snippet above, do not use variables to store queries. +2. This will automatically display the result in the UI. You do not need to return the dataframe in the cell. +3. The SQL must use the syntax of the database engine specified in the `engine` variable. If no engine, then use duckdb syntax. + +## Rules for markdown: +1. Use double dollar signs ($$) for ALL mathematical expressions (both inline and block) in markdown responses. +2. Example: $$E=mc^2$$ +3. Do NOT use single dollar signs or square brackets for math. + +## Rules for inserting cells: +For markdown cells, use `mo.md(f"""{content}""")` +For sql cells, use `mo.sql(f"""{content}""")`. If a database engine is specified, use `mo.sql(f"""{content}""", engine=engine)` instead. -You are a helpful assistant working in a marimo notebook. You can answer questions and help with tasks. You may respond with markdown, code, or a combination of both. If you respond in code, you must use the appropriate language block. And you only work with two languages: Python and SQL. When responding in code, think of each block of code as a separate cell in the notebook. The notebook has 2 hard rules: -1. Do not import the same library twice. -2. Do not define a variable if it already exists. You may reference variables from previous cells, but you may not define a variable if it already exists. +==================== kitchen sink ==================== -Rules for python: -1. When using matplotlib to show plots, use plt.gca() instead of plt.show(). -2. If an import already exists, do not import it again. -3. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. -Rules for sql: +You are Marimo Copilot, an AI assistant integrated into the marimo notebook code editor. +Your primary function is to help users create, analyze, and improve data science notebooks using marimo's reactive programming model. +## Capabilities +- Answer questions and provide guidance using only your internal knowledge and the notebook context provided by the user. + +## Limitations +- You do NOT have access to any external tools, plugins, or APIs. +- You may not perform any actions beyond generating text and code suggestions. + +Current notebook session ID: s_test. Use this session_id with tools that require it. + +Your goal is to do one of the following two things: + +1. Help users answer questions related to their notebook. +2. Answer general-purpose questions unrelated to their particular notebook. + +It will be up to you to decide which of these you are doing based on what the user has told you. When unclear, ask clarifying questions to understand the user's intent before proceeding. + +The user may reference additional context in the form @kind://name. You can use this context to help you with the current task. + +You can respond with markdown, code, or a combination of both. You only work with two languages: Python and SQL. +When responding in code, think of each block of code as a separate cell in the notebook. + +You have the following rules: + +- Do not import the same library twice. +- Do not define a variable if it already exists. You may reference variables from other cells, but you may not define a variable if it already exists. + +# Marimo fundamentals + +Marimo is a reactive notebook that differs from traditional notebooks in key ways: +- Cells execute automatically when their dependencies change +- Variables cannot be redeclared across cells +- The notebook forms a directed acyclic graph (DAG) +- The last expression in a cell is automatically displayed +- UI elements are reactive and update the notebook automatically + +Marimo's reactivity means: +- When a variable changes, all cells that use that variable automatically re-execute +- UI elements trigger updates when their values change without explicit callbacks +- UI element values are accessed through `.value` attribute +- You cannot access a UI element's value in the same cell where it's defined + +## Best Practices + + +- Use polars for data manipulation +- Implement proper data validation +- Handle missing values appropriately +- Use efficient data structures +- A variable in the last expression of a cell is automatically displayed as a table + + + +- Access UI element values with .value attribute (e.g., slider.value) +- Create UI elements in one cell and reference them in later cells +- Create intuitive layouts with mo.hstack(), mo.vstack(), and mo.tabs() +- Prefer reactive updates over callbacks (marimo handles reactivity automatically) +- Group related UI elements for better organization + + +## Available UI elements + +* `mo.ui.altair_chart(altair_chart)` - create a reactive Altair chart +* `mo.ui.button(value=None, kind='primary')` - create a clickable button +* `mo.ui.run_button(label=None, tooltip=None, kind='primary')` - create a button that runs code +* `mo.ui.checkbox(label='', value=False)` - create a checkbox +* `mo.ui.chat(placeholder='', value=None)` - create a chat interface +* `mo.ui.date(value=None, label=None, full_width=False)` - create a date picker +* `mo.ui.dropdown(options, value=None, label=None, full_width=False)` - create a dropdown menu +* `mo.ui.file(label='', multiple=False, full_width=False)` - create a file upload element +* `mo.ui.number(value=None, label=None, full_width=False)` - create a number input +* `mo.ui.radio(options, value=None, label=None, full_width=False)` - create radio buttons +* `mo.ui.refresh(options: List[str], default_interval: str)` - create a refresh control +* `mo.ui.slider(start, stop, value=None, label=None, full_width=False, step=None)` - create a slider +* `mo.ui.range_slider(start, stop, value=None, label=None, full_width=False, step=None)` - create a range slider +* `mo.ui.table(data, columns=None, on_select=None, sortable=True, filterable=True)` - create an interactive table +* `mo.ui.text(value='', label=None, full_width=False)` - create a text input +* `mo.ui.text_area(value='', label=None, full_width=False)` - create a multi-line text input +* `mo.ui.data_explorer(df)` - create an interactive dataframe explorer +* `mo.ui.dataframe(df)` - display a dataframe with search, filter, and sort capabilities +* `mo.ui.plotly(plotly_figure)` - create a reactive Plotly chart (supports scatter, treemap, and sunburst) +* `mo.ui.tabs(elements: dict[str, mo.ui.Element])` - create a tabbed interface from a dictionary +* `mo.ui.array(elements: list[mo.ui.Element])` - create an array of UI elements +* `mo.ui.form(element: mo.ui.Element, label='', bordered=True)` - wrap an element in a form + +## Layout and utility functions + +* `mo.stop(predicate, output=None)` - stop execution conditionally +* `mo.Html(html)` - display HTML +* `mo.image(image)` - display an image +* `mo.hstack(elements)` - stack elements horizontally +* `mo.vstack(elements)` - stack elements vertically +* `mo.tabs(elements)` - create a tabbed interface +* `mo.mpl.interactive()` - make matplotlib plots interactive + +## Examples + + +import marimo as mo +import altair as alt +import polars as pl +import numpy as np + +# Create a slider and display it +n_points = mo.ui.slider(10, 100, value=50, label="Number of points") +n_points # Display the slider + +# Generate random data based on slider value +# This cell automatically re-executes when n_points.value changes +x = np.random.rand(n_points.value) +y = np.random.rand(n_points.value) + +df = pl.DataFrame({"x": x, "y": y}) + +chart = alt.Chart(df).mark_circle(opacity=0.7).encode( + x=alt.X('x', title='X axis'), + y=alt.Y('y', title='Y axis') +).properties( + title=f"Scatter plot with {n_points.value} points", + width=400, + height=300 +) + +chart + + +## Rules for python: +1. For matplotlib: use plt.gca() as the last expression instead of plt.show(). +2. For plotly: return the figure object directly. +3. For altair: return the chart object directly. Add tooltips where appropriate. You can pass polars dataframes directly to altair (e.g., alt.Chart(df)). +4. Include proper labels, titles, and color schemes. +5. Make visualizations interactive where appropriate. +6. If an import already exists, do not import it again. +7. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. + +## Rules for markdown: +1. Use double dollar signs ($$) for ALL mathematical expressions (both inline and block) in markdown responses. +2. Example: $$E=mc^2$$ +3. Do NOT use single dollar signs or square brackets for math. + +## Rules for sql: 1. The SQL must use duckdb syntax. -Additional rules: +## Additional rules: Always be polite. -Variables to use but not define: -['var1', 'var2'] - -Code from other cells: + import pandas as pd import numpy as np - -Available schema: -- Table: df_1 - - Column: age - - Type: int - - Sample values: 1, 2, 3 - - Column: name - - Type: str - - Sample values: Alice, Bob, Charlie + \ No newline at end of file diff --git a/tests/_server/ai/snapshots/edit_inline_prompts.txt b/tests/_server/ai/snapshots/edit_inline_prompts.txt new file mode 100644 index 00000000000..0b04994a5d5 --- /dev/null +++ b/tests/_server/ai/snapshots/edit_inline_prompts.txt @@ -0,0 +1 @@ +You are a python code completion assistant. Complete the missing code between the prefix and suffix while maintaining proper syntax, style, and functionality.Only output the code that goes after the <|fim_suffix|> part. Do not add any explanation or markdown. \ No newline at end of file diff --git a/tests/_server/ai/snapshots/system_prompts.txt b/tests/_server/ai/snapshots/system_prompts.txt index a564170284e..7bab6bbf63d 100644 --- a/tests/_server/ai/snapshots/system_prompts.txt +++ b/tests/_server/ai/snapshots/system_prompts.txt @@ -2,52 +2,131 @@ ==================== python ==================== -You are a helpful assistant that can answer questions about python. Here are your rules: -1. Do not describe the code, just write the code. -2. Do not output markdown or backticks. -3. When using matplotlib to show plots, use plt.gca() instead of plt.show(). -4. If an import already exists, do not import it again. -5. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. +You are an AI assistant integrated into the marimo notebook code editor. +You goal is to create a new cell in the notebook. +Your output must be valid python code. +The user may reference additional context in the form @kind://name. You can use this context to help you with the current task. +You can reference variables from other cells, but you cannot redefine a variable if it already exists. +Immediately start with the following format. Do NOT comment on the code, just output the code itself: + +``` +{CELL_CODE} +``` + +## Rules for python +1. For matplotlib: use plt.gca() as the last expression instead of plt.show(). +2. For plotly: return the figure object directly. +3. For altair: return the chart object directly. Add tooltips where appropriate. You can pass polars dataframes directly to altair (e.g., alt.Chart(df)). +4. Include proper labels, titles, and color schemes. +5. Make visualizations interactive where appropriate. +6. If an import already exists, do not import it again. +7. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. + +Again, just output the code itself and make sure to return the code as just python. ==================== markdown ==================== -You are a helpful assistant that can answer questions about markdown. Here are your rules: -1. Do not describe the code, just write the code. -2. Do not output markdown or backticks. +You are an AI assistant integrated into the marimo notebook code editor. +You goal is to create a new cell in the notebook. +Your output must be valid markdown code. +The user may reference additional context in the form @kind://name. You can use this context to help you with the current task. +You can reference variables from other cells, but you cannot redefine a variable if it already exists. +Immediately start with the following format. Do NOT comment on the code, just output the code itself: + +``` +{CELL_CODE} +``` + +## Rules for markdown +1. Use double dollar signs ($$) for ALL mathematical expressions (both inline and block) in markdown responses. +2. Example: $$E=mc^2$$ +3. Do NOT use single dollar signs or square brackets for math. + +Again, just output the code itself and make sure to return the code as just markdown. ==================== sql ==================== -You are a helpful assistant that can answer questions about sql. Here are your rules: -1. Do not describe the code, just write the code. -2. Do not output markdown or backticks. -3. The SQL must use duckdb syntax. +You are an AI assistant integrated into the marimo notebook code editor. +You goal is to create a new cell in the notebook. +Your output must be valid sql code. +The user may reference additional context in the form @kind://name. You can use this context to help you with the current task. +You can reference variables from other cells, but you cannot redefine a variable if it already exists. +Immediately start with the following format. Do NOT comment on the code, just output the code itself: + +``` +{CELL_CODE} +``` + +## Rules for sql +1. The SQL must use duckdb syntax. + +Again, just output the code itself and make sure to return the code as just sql. ==================== idk ==================== -You are a helpful assistant that can answer questions. +You are an AI assistant integrated into the marimo notebook code editor. +You goal is to create a new cell in the notebook. +Your output must be valid idk code. +The user may reference additional context in the form @kind://name. You can use this context to help you with the current task. +You can reference variables from other cells, but you cannot redefine a variable if it already exists. +Immediately start with the following format. Do NOT comment on the code, just output the code itself: + +``` +{CELL_CODE} +``` + +Again, just output the code itself and make sure to return the code as just idk. ==================== with custom rules ==================== -You are a helpful assistant that can answer questions about python. Here are your rules: -1. Do not describe the code, just write the code. -2. Do not output markdown or backticks. -3. When using matplotlib to show plots, use plt.gca() instead of plt.show(). -4. If an import already exists, do not import it again. -5. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. +You are an AI assistant integrated into the marimo notebook code editor. +You goal is to create a new cell in the notebook. +Your output must be valid python code. +The user may reference additional context in the form @kind://name. You can use this context to help you with the current task. +You can reference variables from other cells, but you cannot redefine a variable if it already exists. +Immediately start with the following format. Do NOT comment on the code, just output the code itself: + +``` +{CELL_CODE} +``` + +## Rules for python +1. For matplotlib: use plt.gca() as the last expression instead of plt.show(). +2. For plotly: return the figure object directly. +3. For altair: return the chart object directly. Add tooltips where appropriate. You can pass polars dataframes directly to altair (e.g., alt.Chart(df)). +4. Include proper labels, titles, and color schemes. +5. Make visualizations interactive where appropriate. +6. If an import already exists, do not import it again. +7. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. -Additional rules: +## Additional rules: Always use type hints. +Again, just output the code itself and make sure to return the code as just python. + ==================== with context ==================== -You are a helpful assistant that can answer questions about python. Here are your rules: -1. Do not describe the code, just write the code. -2. Do not output markdown or backticks. -3. When using matplotlib to show plots, use plt.gca() instead of plt.show(). -4. If an import already exists, do not import it again. -5. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. +You are an AI assistant integrated into the marimo notebook code editor. +You goal is to create a new cell in the notebook. +Your output must be valid python code. +The user may reference additional context in the form @kind://name. You can use this context to help you with the current task. +You can reference variables from other cells, but you cannot redefine a variable if it already exists. +Immediately start with the following format. Do NOT comment on the code, just output the code itself: + +``` +{CELL_CODE} +``` -Available schema: +## Rules for python +1. For matplotlib: use plt.gca() as the last expression instead of plt.show(). +2. For plotly: return the figure object directly. +3. For altair: return the chart object directly. Add tooltips where appropriate. You can pass polars dataframes directly to altair (e.g., alt.Chart(df)). +4. Include proper labels, titles, and color schemes. +5. Make visualizations interactive where appropriate. +6. If an import already exists, do not import it again. +7. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. + +## Available schema: - Table: df_1 - Column: age - Type: int @@ -55,3 +134,240 @@ Available schema: - Column: name - Type: str - Sample values: Alice, Bob, Charlie + + +Again, just output the code itself and make sure to return the code as just python. + +==================== with is_insert=True ==================== + +Here's a python document from a Python notebook that I'm going to ask you to make an edit to. + +The point you'll need to insert at is marked with . + + + +def fib(n): + + + +You can't replace the content, your answer will be inserted in place of the tags. Don't include the insert_here tags in your output. +Match the indentation in the original file in the inserted content, don't include any indentation on blank lines. +Immediately start with the following format. Do NOT comment on the code, just output the code itself: + +``` +{INSERTED_CODE} +``` + +## Rules for python +1. For matplotlib: use plt.gca() as the last expression instead of plt.show(). +2. For plotly: return the figure object directly. +3. For altair: return the chart object directly. Add tooltips where appropriate. You can pass polars dataframes directly to altair (e.g., alt.Chart(df)). +4. Include proper labels, titles, and color schemes. +5. Make visualizations interactive where appropriate. +6. If an import already exists, do not import it again. +7. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. + +Again, just output the code itself and make sure to return the code as just python. + +==================== with cell_code ==================== + +Here's a python document from a Python notebook that I'm going to ask you to make an edit to. + +The section you'll need to rewrite is marked with tags. + + + +def hello(): + print('Hello, world!') + + +Only make changes that are necessary to fulfill the prompt, leave everything else as-is. All surrounding content will be preserved. +Start at the indentation level in the original file in the rewritten content. Don't stop until you've rewritten the entire section, even if you have no more changes to make, always write out the whole section with no unnecessary elisions. +Immediately start with the following format. Do NOT comment on the code, just output the code itself: + +``` +{REWRITTEN_CODE} +``` + +And here's the section to rewrite based on that prompt again for reference: + + +print('Hello, world!') + + +## Rules for python +1. For matplotlib: use plt.gca() as the last expression instead of plt.show(). +2. For plotly: return the figure object directly. +3. For altair: return the chart object directly. Add tooltips where appropriate. You can pass polars dataframes directly to altair (e.g., alt.Chart(df)). +4. Include proper labels, titles, and color schemes. +5. Make visualizations interactive where appropriate. +6. If an import already exists, do not import it again. +7. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. + +Again, just output the code itself and make sure to return the code as just python. + +==================== with selected_text ==================== + +Here's a python document from a Python notebook that I'm going to ask you to make an edit to. + +The section you'll need to rewrite is marked with tags. + + + +def hello(): + print('Hello, world!') + + +Only make changes that are necessary to fulfill the prompt, leave everything else as-is. All surrounding content will be preserved. +Start at the indentation level in the original file in the rewritten content. Don't stop until you've rewritten the entire section, even if you have no more changes to make, always write out the whole section with no unnecessary elisions. +Immediately start with the following format. Do NOT comment on the code, just output the code itself: + +``` +{REWRITTEN_CODE} +``` + +And here's the section to rewrite based on that prompt again for reference: + + +print('Hello, world!') + + +## Rules for python +1. For matplotlib: use plt.gca() as the last expression instead of plt.show(). +2. For plotly: return the figure object directly. +3. For altair: return the chart object directly. Add tooltips where appropriate. You can pass polars dataframes directly to altair (e.g., alt.Chart(df)). +4. Include proper labels, titles, and color schemes. +5. Make visualizations interactive where appropriate. +6. If an import already exists, do not import it again. +7. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. + +Again, just output the code itself and make sure to return the code as just python. + +==================== with other_cell_codes ==================== + +Here's a python document from a Python notebook that I'm going to ask you to make an edit to. + +The section you'll need to rewrite is marked with tags. + + + +pl.DataFrame() + + +Only make changes that are necessary to fulfill the prompt, leave everything else as-is. All surrounding content will be preserved. +Start at the indentation level in the original file in the rewritten content. Don't stop until you've rewritten the entire section, even if you have no more changes to make, always write out the whole section with no unnecessary elisions. +Immediately start with the following format. Do NOT comment on the code, just output the code itself: + +``` +{REWRITTEN_CODE} +``` + +And here's the section to rewrite based on that prompt again for reference: + + +pl.DataFrame() + + +## Rules for python +1. For matplotlib: use plt.gca() as the last expression instead of plt.show(). +2. For plotly: return the figure object directly. +3. For altair: return the chart object directly. Add tooltips where appropriate. You can pass polars dataframes directly to altair (e.g., alt.Chart(df)). +4. Include proper labels, titles, and color schemes. +5. Make visualizations interactive where appropriate. +6. If an import already exists, do not import it again. +7. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. + + +import pandas as pd +import numpy as np + + +Again, just output the code itself and make sure to return the code as just python. + +==================== with VariableContext objects ==================== + +You are an AI assistant integrated into the marimo notebook code editor. +You goal is to create a new cell in the notebook. +Your output must be valid python code. +The user may reference additional context in the form @kind://name. You can use this context to help you with the current task. +You can reference variables from other cells, but you cannot redefine a variable if it already exists. +Immediately start with the following format. Do NOT comment on the code, just output the code itself: + +``` +{CELL_CODE} +``` + +## Rules for python +1. For matplotlib: use plt.gca() as the last expression instead of plt.show(). +2. For plotly: return the figure object directly. +3. For altair: return the chart object directly. Add tooltips where appropriate. You can pass polars dataframes directly to altair (e.g., alt.Chart(df)). +4. Include proper labels, titles, and color schemes. +5. Make visualizations interactive where appropriate. +6. If an import already exists, do not import it again. +7. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. + +## Available variables from other cells: +- variable: `df` + - value_type: DataFrame + - value_preview: +- variable: `model` + - value_type: Model + - value_preview: + + +Again, just output the code itself and make sure to return the code as just python. + +==================== with support_multiple_cells=True ==================== + +You are an AI assistant integrated into the marimo notebook code editor. +Your goal is to create new cells in the notebook. +You can create multiple cells with different languages. Each cell should be wrapped in backticks. +The user may reference additional context in the form @kind://name. You can use this context to help you with the current task. +You can reference variables from other cells, but you cannot redefine a variable if it already exists. +Immediately start with the following format. Do NOT comment on the code, just output the code itself: + +```python +{PYTHON_CODE} +``` + +```sql +df_name = mo.sql(f"""{SQL_QUERY}""") +``` + +```markdown +mo.md(f"""{MARKDOWN_CONTENT}""") +``` + +You can have multiple cells of any type. Each cell is wrapped in backticks with the appropriate language identifier. +Create clear variable names if they will be used in other cells. Do not prefix with underscore. +Separate logic into multiple cells to keep the code organized and readable. + +## Rules for python: +1. For matplotlib: use plt.gca() as the last expression instead of plt.show(). +2. For plotly: return the figure object directly. +3. For altair: return the chart object directly. Add tooltips where appropriate. You can pass polars dataframes directly to altair (e.g., alt.Chart(df)). +4. Include proper labels, titles, and color schemes. +5. Make visualizations interactive where appropriate. +6. If an import already exists, do not import it again. +7. If a variable is already defined, use another name, or make it private by adding an underscore at the beginning. + +## Rules for sql: +1. SQL cells start with df = mo.sql(f"""""") for DuckDB, or df = mo.sql(f"""""", engine=engine) for other SQL engines. You should always write queries inline as the code snippet above, do not use variables to store queries. +2. This will automatically display the result in the UI. You do not need to return the dataframe in the cell. +3. The SQL must use the syntax of the database engine specified in the `engine` variable. If no engine, then use duckdb syntax. + +## Rules for markdown: +1. Use double dollar signs ($$) for ALL mathematical expressions (both inline and block) in markdown responses. +2. Example: $$E=mc^2$$ +3. Do NOT use single dollar signs or square brackets for math. + +## Available variables from other cells: +- variable: `df` + - value_type: DataFrame + - value_preview: +- variable: `model` + - value_type: Model + - value_preview: + + +Again, just output code wrapped in cells. Each cell is wrapped in backticks with the appropriate language identifier (python, sql, markdown). \ No newline at end of file diff --git a/tests/_server/ai/snapshots/user_prompts.txt b/tests/_server/ai/snapshots/user_prompts.txt deleted file mode 100644 index a1030c79f1e..00000000000 --- a/tests/_server/ai/snapshots/user_prompts.txt +++ /dev/null @@ -1,47 +0,0 @@ - - -==================== no code ==================== - -Create a pandas dataframe - -==================== with code ==================== - -Create a pandas dataframe - - -df = pd.DataFrame({'a': [1, 2, 3], 'b': [4, 5, 6]}) - - -==================== with code and other code ==================== - -Create a pandas dataframe - - -import pandas as pd -import numpy as np - - - -df = pd.DataFrame({'a': [1, 2, 3], 'b': [4, 5, 6]}) - - -==================== with just other code ==================== - -Create a pandas dataframe - - -import pandas as pd -import numpy as np - - -==================== with context ==================== - -Create a pandas dataframe - - -import marimo as mo - - - -import pandas as pd - \ No newline at end of file diff --git a/tests/_server/ai/test_ai_config.py b/tests/_server/ai/test_ai_config.py new file mode 100644 index 00000000000..b5c89969667 --- /dev/null +++ b/tests/_server/ai/test_ai_config.py @@ -0,0 +1,1374 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +import os +from typing import Any, cast +from unittest.mock import patch + +import pytest +from starlette.exceptions import HTTPException + +from marimo._config.config import ( + AiConfig, + MarimoConfig, +) +from marimo._dependencies.dependencies import DependencyManager +from marimo._server.ai.config import ( + GITHUB_COPILOT_BASE_URL, + AnyProviderConfig, + _get_ai_config, + _get_base_url, + _get_key, + get_autocomplete_model, + get_chat_model, + get_edit_model, + get_max_tokens, +) +from marimo._server.ai.constants import DEFAULT_MODEL +from marimo._server.ai.tools.types import ToolDefinition +from marimo._utils.http import HTTPStatus + + +class TestAnyProviderConfig: + """Tests for AnyProviderConfig class.""" + + def test_for_openai_basic(self): + """Test basic OpenAI configuration.""" + config: AiConfig = { + "open_ai": { + "api_key": "test-openai-key", + "model": "gpt-4", + } + } + + provider_config = AnyProviderConfig.for_openai(config) + + assert provider_config.api_key == "test-openai-key" + assert provider_config.base_url is None + assert provider_config.ssl_verify is True + assert provider_config.ca_bundle_path is None + assert provider_config.client_pem is None + assert provider_config.extra_headers is None + + def test_for_openai_with_base_url(self): + """Test OpenAI configuration with custom base URL.""" + config: AiConfig = { + "open_ai": { + "api_key": "test-key", + "base_url": "https://custom.openai.com", + "ssl_verify": False, + "ca_bundle_path": "/path/to/ca.pem", + "client_pem": "/path/to/client.pem", + "extra_headers": {"test-header": "test-value"}, + } + } + + provider_config = AnyProviderConfig.for_openai(config) + + assert provider_config.api_key == "test-key" + assert provider_config.base_url == "https://custom.openai.com" + assert provider_config.ssl_verify is False + assert provider_config.ca_bundle_path == "/path/to/ca.pem" + assert provider_config.client_pem == "/path/to/client.pem" + assert provider_config.extra_headers == {"test-header": "test-value"} + + def test_for_azure(self): + """Test Azure OpenAI configuration.""" + config: AiConfig = { + "azure": { + "api_key": "test-azure-key", + "base_url": "https://test.openai.azure.com", + } + } + + provider_config = AnyProviderConfig.for_azure(config) + + assert provider_config.api_key == "test-azure-key" + assert provider_config.base_url == "https://test.openai.azure.com" + + def test_for_openai_compatible(self): + """Test OpenAI compatible service configuration.""" + config: AiConfig = { + "open_ai_compatible": { + "api_key": "test-compatible-key", + "base_url": "https://compatible.service.com", + } + } + + provider_config = AnyProviderConfig.for_openai_compatible(config) + + assert provider_config.api_key == "test-compatible-key" + assert provider_config.base_url == "https://compatible.service.com" + + def test_for_ollama(self): + """Test Ollama configuration.""" + config: AiConfig = { + "ollama": { + "api_key": "test-ollama-key", + "base_url": "http://localhost:11434", + } + } + + provider_config = AnyProviderConfig.for_ollama(config) + + assert provider_config.api_key == "test-ollama-key" + assert provider_config.base_url == "http://localhost:11434" + + def test_for_ollama_empty(self): + config: AiConfig = {} + provider_config = AnyProviderConfig.for_ollama(config) + assert isinstance(provider_config, AnyProviderConfig) + assert provider_config.api_key == "ollama-placeholder" + assert provider_config.base_url == "http://127.0.0.1:11434/v1" + + def test_for_ollama_placeholder_key(self): + """Test Ollama configuration with default URL gets placeholder key.""" + config: AiConfig = { + "ollama": { + "base_url": "http://127.0.0.1:11434/", + } + } + + provider_config = AnyProviderConfig.for_ollama(config) + + assert provider_config.api_key == "ollama-placeholder" + + def test_for_ollama_fallback_url(self): + """Test Ollama configuration with fallback base URL.""" + config: AiConfig = {"ollama": {}} + + provider_config = AnyProviderConfig.for_ollama(config) + + assert provider_config.api_key == "ollama-placeholder" + assert provider_config.base_url == "http://127.0.0.1:11434/v1" + + def test_for_github(self): + """Test GitHub configuration.""" + config: AiConfig = { + "github": { + "api_key": "test-github-key", + "base_url": "https://some-base-url", + } + } + + provider_config = AnyProviderConfig.for_github(config) + + assert provider_config.api_key == "test-github-key" + assert provider_config.base_url == "https://some-base-url" + + def test_for_github_with_fallback_base_url(self): + """Test GitHub configuration uses fallback base URL when not specified.""" + config: AiConfig = { + "github": { + "api_key": "test-github-key", + } + } + + provider_config = AnyProviderConfig.for_github(config) + + assert provider_config.api_key == "test-github-key" + assert provider_config.base_url == "https://models.github.ai/inference" + + @pytest.mark.skipif( + not DependencyManager.pydantic_ai.has(), + reason="pydantic-ai is not installed", + ) + def test_github_default_base_url_matches_pydantic_ai(self): + """Test GitHub configuration base URL matches pydantic-ai.""" + from pydantic_ai.providers.github import GitHubProvider + + assert ( + GitHubProvider(api_key="dummy").base_url == GITHUB_COPILOT_BASE_URL + ) + + def test_for_github_default_extra_headers(self): + """Test GitHub configuration includes default extra headers.""" + config: AiConfig = { + "github": { + "api_key": "test-github-key", + } + } + + provider_config = AnyProviderConfig.for_github(config) + + assert provider_config.extra_headers is not None + assert ( + provider_config.extra_headers["editor-version"] == "vscode/1.95.0" + ) + assert ( + provider_config.extra_headers["Copilot-Integration-Id"] + == "vscode-chat" + ) + + def test_for_github_user_headers_override_defaults(self): + """Test GitHub configuration allows user headers to override defaults.""" + config: AiConfig = { + "github": { + "api_key": "test-github-key", + "extra_headers": { + "editor-version": "custom-editor/2.0.0", + "X-Custom-Header": "custom-value", + }, + } + } + + provider_config = AnyProviderConfig.for_github(config) + + assert provider_config.extra_headers is not None + # User header should override default + assert ( + provider_config.extra_headers["editor-version"] + == "custom-editor/2.0.0" + ) + # Default header not overridden should remain + assert ( + provider_config.extra_headers["Copilot-Integration-Id"] + == "vscode-chat" + ) + # Custom user header should be preserved + assert ( + provider_config.extra_headers["X-Custom-Header"] == "custom-value" + ) + + def test_for_github_with_copilot_settings(self): + """Test GitHub configuration with copilot_settings is accepted.""" + config: AiConfig = { + "github": { + "api_key": "test-github-key", + "copilot_settings": { + "http": { + "proxy": "http://proxy.example.com:8888", + "proxyStrictSSL": True, + }, + "telemetry": {"telemetryLevel": "off"}, + }, + } + } + + # Should not raise an error - copilot_settings is a valid field + provider_config = AnyProviderConfig.for_github(config) + + # Note: copilot_settings is stored in config but not used by AnyProviderConfig + # It's used by the frontend LSP client + assert provider_config.api_key == "test-github-key" + assert provider_config.base_url == "https://models.github.ai/inference" + + def test_for_openrouter(self): + """Test OpenRouter configuration.""" + config: AiConfig = { + "openrouter": { + "api_key": "test-openrouter-key", + "base_url": "https://openrouter.ai/api/v1/", + } + } + + provider_config = AnyProviderConfig.for_openrouter(config) + + assert provider_config.api_key == "test-openrouter-key" + assert provider_config.base_url == "https://openrouter.ai/api/v1/" + + def test_for_openrouter_with_fallback_base_url(self): + """Test OpenRouter configuration uses fallback base URL when not specified.""" + config: AiConfig = { + "openrouter": { + "api_key": "test-openrouter-key", + } + } + + provider_config = AnyProviderConfig.for_openrouter(config) + + assert provider_config.api_key == "test-openrouter-key" + assert provider_config.base_url == "https://openrouter.ai/api/v1/" + + def test_for_wandb(self): + """Test Weights & Biases configuration.""" + config: AiConfig = { + "wandb": { + "api_key": "test-wandb-key", + "base_url": "https://api.inference.wandb.ai/v1/", + } + } + + provider_config = AnyProviderConfig.for_wandb(config) + + assert provider_config.api_key == "test-wandb-key" + assert provider_config.base_url == "https://api.inference.wandb.ai/v1/" + + def test_for_wandb_with_fallback_base_url(self): + """Test Weights & Biases configuration uses fallback base URL when not specified.""" + config: AiConfig = { + "wandb": { + "api_key": "test-wandb-key", + } + } + + provider_config = AnyProviderConfig.for_wandb(config) + + assert provider_config.api_key == "test-wandb-key" + assert provider_config.base_url == "https://api.inference.wandb.ai/v1/" + + def test_for_wandb_with_project(self): + """Test Weights & Biases configuration with project field.""" + config: AiConfig = { + "wandb": { + "api_key": "test-wandb-key", + "project": "my-project", + } + } + + provider_config = AnyProviderConfig.for_wandb(config) + + def test_for_opencode_go(self): + """Test OpenCode Go configuration.""" + config: AiConfig = { + "opencode_go": { + "api_key": "test-opencode-key", + "base_url": "https://opencode.ai/zen/go/v1/", + } + } + + provider_config = AnyProviderConfig.for_opencode_go(config) + + assert provider_config.api_key == "test-opencode-key" + assert provider_config.base_url == "https://opencode.ai/zen/go/v1/" + + def test_for_opencode_go_with_fallback_base_url(self): + """Test OpenCode Go configuration uses fallback base URL when not specified.""" + config: AiConfig = { + "opencode_go": { + "api_key": "test-opencode-key", + } + } + + provider_config = AnyProviderConfig.for_opencode_go(config) + + assert provider_config.api_key == "test-opencode-key" + assert provider_config.base_url == "https://opencode.ai/zen/go/v1/" + + def test_for_openai_with_project(self): + """Test OpenAI configuration with project field.""" + config: AiConfig = { + "open_ai": { + "api_key": "test-openai-key", + "project": "my-openai-project", + } + } + + provider_config = AnyProviderConfig.for_openai(config) + + assert provider_config.api_key == "test-openai-key" + assert provider_config.project == "my-openai-project" + + def test_for_anthropic(self): + """Test Anthropic configuration.""" + config: AiConfig = { + "anthropic": { + "api_key": "test-anthropic-key", + } + } + + provider_config = AnyProviderConfig.for_anthropic(config) + + assert provider_config.api_key == "test-anthropic-key" + assert provider_config.base_url is None + + def test_for_google(self): + """Test Google AI configuration.""" + config: AiConfig = { + "google": { + "api_key": "test-google-key", + } + } + + provider_config = AnyProviderConfig.for_google(config) + + assert provider_config.api_key == "test-google-key" + assert provider_config.base_url is None + + def test_for_bedrock_with_profile(self): + """Test Bedrock configuration with profile name.""" + config: AiConfig = { + "bedrock": { + "profile_name": "test-profile", + "region_name": "us-east-1", + } + } + + provider_config = AnyProviderConfig.for_bedrock(config) + + assert provider_config.api_key == "profile:test-profile" + assert provider_config.base_url == "us-east-1" + + def test_for_bedrock_with_credentials(self): + """Test Bedrock configuration with AWS credentials.""" + config: AiConfig = { + "bedrock": { + "aws_access_key_id": "test-access-key", + "aws_secret_access_key": "test-secret-key", + "region_name": "us-west-2", + } + } + + provider_config = AnyProviderConfig.for_bedrock(config) + + assert provider_config.api_key == "test-access-key:test-secret-key" + assert provider_config.base_url == "us-west-2" + + def test_for_model_openai(self) -> None: + """Test for_model with OpenAI model.""" + config: AiConfig = {"open_ai": {"api_key": "test-key"}} + + provider_config = AnyProviderConfig.for_model("gpt-4", config) + + assert provider_config.api_key == "test-key" + + def test_for_model_anthropic(self) -> None: + """Test for_model with Anthropic model.""" + config: AiConfig = {"anthropic": {"api_key": "test-anthropic-key"}} + + provider_config = AnyProviderConfig.for_model("claude-3-opus", config) + + assert provider_config.api_key == "test-anthropic-key" + + def test_for_model_github(self) -> None: + """Test for_model with GitHub model.""" + config: AiConfig = {"github": {"api_key": "test-github-key"}} + + provider_config = AnyProviderConfig.for_model("github/gpt-4o", config) + + assert provider_config.api_key == "test-github-key" + + def test_for_model_openrouter(self) -> None: + """Test for_model with OpenRouter model.""" + config: AiConfig = {"openrouter": {"api_key": "test-openrouter-key"}} + + provider_config = AnyProviderConfig.for_model( + "openrouter/openai/gpt-4", config + ) + + assert provider_config.api_key == "test-openrouter-key" + assert provider_config.base_url == "https://openrouter.ai/api/v1/" + + def test_for_model_wandb(self) -> None: + """Test for_model with Weights & Biases model.""" + config: AiConfig = {"wandb": {"api_key": "test-wandb-key"}} + + provider_config = AnyProviderConfig.for_model("wandb/llama-3", config) + + assert provider_config.api_key == "test-wandb-key" + assert provider_config.base_url == "https://api.inference.wandb.ai/v1/" + + def test_for_model_unknown_defaults_to_ollama(self) -> None: + """Test for_model with unknown provider defaults to Ollama.""" + config: AiConfig = {"ollama": {"api_key": "test-key"}} + + provider_config = AnyProviderConfig.for_model("unknown-model", config) + + assert provider_config.api_key == "test-key" + + def test_for_model_unknown_provider_defaults_to_openai_compatible( + self, + ) -> None: + """Test for_model with unknown provider defaults to OpenAI compatible.""" + config: AiConfig = { + "open_ai_compatible": {"api_key": "test-key"}, + "open_ai": {"api_key": "other-key"}, + } + + provider_config = AnyProviderConfig.for_model( + "provider/unknown-model", config + ) + + assert provider_config.api_key == "test-key" + + # Fallback to OpenAI if OpenAI compatible is not configured + config: AiConfig = { + "open_ai": {"api_key": "other-key"}, + } + + provider_config = AnyProviderConfig.for_model( + "provider/unknown-model", config + ) + + assert provider_config.api_key == "other-key" + + @patch("marimo._server.ai.config._get_tools") + def test_tools_included_when_available(self, mock_get_tools: Any) -> None: + """Test that tools are included when available.""" + mock_tool = ToolDefinition( + name="test_tool", + description="Test tool", + parameters={}, + source="backend", + mode=["manual"], + ) + mock_get_tools.return_value = [mock_tool] + + config: AiConfig = { + "open_ai": {"api_key": "test-key"}, + "mode": "manual", + } + + provider_config = AnyProviderConfig.for_openai(config) + + assert len(provider_config.tools) == 1 + assert provider_config.tools[0] == mock_tool + + @patch("marimo._server.ai.config._get_tools") + def test_tools_excluded_when_empty(self, mock_get_tools: Any) -> None: + """Test that tools are excluded when empty to prevent errors with deepseek.""" + mock_get_tools.return_value = [] + + config: AiConfig = { + "open_ai": {"api_key": "test-key"}, + "mode": "manual", + } + + provider_config = AnyProviderConfig.for_openai(config) + + assert provider_config.tools is None + + +class TestOsKey: + """Tests for os_key method.""" + + @patch.dict(os.environ, {"OPENAI_API_KEY": "test-api-key"}) + def test_os_key_exists(self) -> None: + """Test os_key returns value when environment variable exists.""" + result = AnyProviderConfig.os_key("OPENAI_API_KEY") + assert result == "test-api-key" + + @patch.dict(os.environ, {}, clear=True) + def test_os_key_not_exists(self) -> None: + """Test os_key returns None when environment variable doesn't exist.""" + result = AnyProviderConfig.os_key("NONEXISTENT_KEY") + assert result is None + + @patch.dict(os.environ, {"EMPTY_KEY": ""}) + def test_os_key_empty_string(self) -> None: + """Test os_key returns empty string when environment variable is empty.""" + result = AnyProviderConfig.os_key("EMPTY_KEY") + assert result == "" + + +class TestProviderConfigWithFallback: + """Tests for provider config methods with OS environment fallback.""" + + @patch.dict(os.environ, {"OPENAI_API_KEY": "env-openai-key"}) + def test_for_openai_with_fallback_key(self) -> None: + """Test OpenAI config uses fallback key when config is missing api_key.""" + config: AiConfig = {"open_ai": {}} + + provider_config = AnyProviderConfig.for_openai(config) + + assert provider_config.api_key == "env-openai-key" + + @patch.dict(os.environ, {"OPENAI_API_KEY": "env-openai-key"}) + def test_for_openai_empty(self) -> None: + """Test OpenAI config uses fallback key when config is missing api_key and config is empty.""" + config: AiConfig = {} + provider_config = AnyProviderConfig.for_openai(config) + assert provider_config.api_key == "env-openai-key" + + @patch.dict(os.environ, {"OPENAI_API_KEY": "env-openai-key"}) + def test_for_openai_config_key_takes_precedence(self) -> None: + """Test OpenAI config key takes precedence over environment variable.""" + config: AiConfig = {"open_ai": {"api_key": "config-openai-key"}} + + provider_config = AnyProviderConfig.for_openai(config) + + assert provider_config.api_key == "config-openai-key" + + @patch.dict(os.environ, {}, clear=True) + def test_for_openai_no_fallback_available(self) -> None: + """Test OpenAI config fails when no config key and no env var.""" + config: AiConfig = {"open_ai": {}} + + with pytest.raises(HTTPException) as exc_info: + AnyProviderConfig.for_openai(config) + + assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST + assert "OpenAI API key not configured" in str(exc_info.value.detail) + + @patch.dict(os.environ, {"AZURE_API_KEY": "env-azure-key"}) + def test_for_azure_with_fallback_key(self) -> None: + """Test Azure config uses fallback key when config is missing api_key.""" + config: AiConfig = { + "azure": {"base_url": "https://test.openai.azure.com"} + } + + provider_config = AnyProviderConfig.for_azure(config) + + assert provider_config.api_key == "env-azure-key" + + @patch.dict(os.environ, {"AZURE_API_KEY": "env-azure-key"}) + def test_for_azure_config_key_takes_precedence(self) -> None: + """Test Azure config key takes precedence over environment variable.""" + config: AiConfig = { + "azure": { + "api_key": "config-azure-key", + "base_url": "https://test.openai.azure.com", + } + } + + provider_config = AnyProviderConfig.for_azure(config) + + assert provider_config.api_key == "config-azure-key" + + @patch.dict(os.environ, {"ANTHROPIC_API_KEY": "env-anthropic-key"}) + def test_for_anthropic_with_fallback_key(self) -> None: + """Test Anthropic config uses fallback key when config is missing api_key.""" + config: AiConfig = {"anthropic": {}} + + provider_config = AnyProviderConfig.for_anthropic(config) + + assert provider_config.api_key == "env-anthropic-key" + + @patch.dict(os.environ, {"ANTHROPIC_API_KEY": "env-anthropic-key"}) + def test_for_anthropic_config_key_takes_precedence(self) -> None: + """Test Anthropic config key takes precedence over environment variable.""" + config: AiConfig = {"anthropic": {"api_key": "config-anthropic-key"}} + + provider_config = AnyProviderConfig.for_anthropic(config) + + assert provider_config.api_key == "config-anthropic-key" + + @patch.dict(os.environ, {"GEMINI_API_KEY": "env-gemini-key"}) + def test_for_google_with_gemini_fallback_key(self) -> None: + """Test Google config uses GEMINI_API_KEY fallback when config is missing api_key.""" + config: AiConfig = {"google": {}} + + provider_config = AnyProviderConfig.for_google(config) + + assert provider_config.api_key == "env-gemini-key" + + @patch.dict(os.environ, {"GOOGLE_API_KEY": "env-google-key"}, clear=True) + def test_for_google_with_google_fallback_key(self) -> None: + """Test Google config uses GOOGLE_API_KEY fallback when GEMINI_API_KEY is not available.""" + config: AiConfig = {"google": {}} + + provider_config = AnyProviderConfig.for_google(config) + + assert provider_config.api_key == "env-google-key" + + @patch.dict( + os.environ, + { + "GEMINI_API_KEY": "env-gemini-key", + "GOOGLE_API_KEY": "env-google-key", + }, + ) + def test_for_google_gemini_takes_precedence_over_google(self) -> None: + """Test Google config prefers GEMINI_API_KEY over GOOGLE_API_KEY.""" + config: AiConfig = {"google": {}} + + provider_config = AnyProviderConfig.for_google(config) + + assert provider_config.api_key == "env-gemini-key" + + @patch.dict(os.environ, {"GEMINI_API_KEY": "env-gemini-key"}) + def test_for_google_config_key_takes_precedence(self) -> None: + """Test Google config key takes precedence over environment variables.""" + config: AiConfig = {"google": {"api_key": "config-google-key"}} + + provider_config = AnyProviderConfig.for_google(config) + + assert provider_config.api_key == "config-google-key" + + @patch.dict(os.environ, {}, clear=True) + def test_for_google_no_fallback_available(self) -> None: + """Test Google config succeeds with empty key when no env vars.""" + config: AiConfig = {"google": {}} + + provider_config = AnyProviderConfig.for_google(config) + + assert provider_config == AnyProviderConfig( + base_url=None, + api_key="", + ssl_verify=True, + ) + + @patch.dict(os.environ, {"GITHUB_TOKEN": "env-github-token"}) + def test_for_github_with_fallback_key(self) -> None: + """Test GitHub config uses fallback key when config is missing api_key.""" + config: AiConfig = {"github": {}} + + provider_config = AnyProviderConfig.for_github(config) + + assert provider_config.api_key == "env-github-token" + + @patch.dict(os.environ, {"GITHUB_TOKEN": "env-github-token"}) + def test_for_github_config_key_takes_precedence(self) -> None: + """Test GitHub config key takes precedence over environment variable.""" + config: AiConfig = {"github": {"api_key": "config-github-token"}} + + provider_config = AnyProviderConfig.for_github(config) + + assert provider_config.api_key == "config-github-token" + + @patch.dict(os.environ, {}, clear=True) + def test_for_github_no_fallback_available(self) -> None: + """Test GitHub config fails when no config key and no env var.""" + config: AiConfig = {"github": {}} + + with pytest.raises(HTTPException) as exc_info: + AnyProviderConfig.for_github(config) + + assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST + assert "GitHub API key not configured" in str(exc_info.value.detail) + + @patch.dict(os.environ, {"OPENROUTER_API_KEY": "env-openrouter-token"}) + def test_for_openrouter_with_fallback_key(self) -> None: + """Test OpenRouter config uses fallback key when config is missing api_key.""" + config: AiConfig = {"openrouter": {}} + provider_config = AnyProviderConfig.for_openrouter(config) + assert provider_config.api_key == "env-openrouter-token" + + @patch.dict(os.environ, {"OPENROUTER_API_KEY": "env-openrouter-token"}) + def test_for_openrouter_config_key_takes_precedence(self) -> None: + """Test OpenRouter config key takes precedence over environment variable.""" + config: AiConfig = { + "openrouter": {"api_key": "config-openrouter-token"} + } + provider_config = AnyProviderConfig.for_openrouter(config) + assert provider_config.api_key == "config-openrouter-token" + + @patch.dict(os.environ, {}, clear=True) + def test_for_openrouter_no_fallback_available(self) -> None: + """Test OpenRouter config fails when no config key and no env var.""" + config: AiConfig = {"openrouter": {}} + with pytest.raises(HTTPException) as exc_info: + AnyProviderConfig.for_openrouter(config) + + assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST + assert "OpenRouter API key not configured" in str( + exc_info.value.detail + ) + + @patch.dict(os.environ, {"WANDB_API_KEY": "env-wandb-token"}) + def test_for_wandb_with_fallback_key(self) -> None: + """Test Weights & Biases config uses fallback key when config is missing api_key.""" + config: AiConfig = {"wandb": {}} + provider_config = AnyProviderConfig.for_wandb(config) + assert provider_config.api_key == "env-wandb-token" + + @patch.dict(os.environ, {"WANDB_API_KEY": "env-wandb-token"}) + def test_for_wandb_config_key_takes_precedence(self) -> None: + """Test Weights & Biases config key takes precedence over environment variable.""" + config: AiConfig = {"wandb": {"api_key": "config-wandb-token"}} + provider_config = AnyProviderConfig.for_wandb(config) + assert provider_config.api_key == "config-wandb-token" + + @patch.dict(os.environ, {}, clear=True) + def test_for_wandb_no_fallback_available(self) -> None: + """Test Weights & Biases config fails when no config key and no env var.""" + config: AiConfig = {"wandb": {}} + with pytest.raises(HTTPException) as exc_info: + AnyProviderConfig.for_wandb(config) + + assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST + assert "Weights & Biases API key not configured" in str( + exc_info.value.detail + ) + + @patch.dict(os.environ, {"OPENCODE_API_KEY": "env-opencode-token"}) + def test_for_opencode_go_with_fallback_key(self) -> None: + """Test OpenCode Go config uses fallback key when config is missing api_key.""" + config: AiConfig = {"opencode_go": {}} + provider_config = AnyProviderConfig.for_opencode_go(config) + assert provider_config.api_key == "env-opencode-token" + + @patch.dict(os.environ, {"OPENCODE_API_KEY": "env-opencode-token"}) + def test_for_opencode_go_config_key_takes_precedence(self) -> None: + """Test OpenCode Go config key takes precedence over environment variable.""" + config: AiConfig = { + "opencode_go": {"api_key": "config-opencode-token"} + } + provider_config = AnyProviderConfig.for_opencode_go(config) + assert provider_config.api_key == "config-opencode-token" + + @patch.dict(os.environ, {}, clear=True) + def test_for_opencode_go_no_fallback_available(self) -> None: + """Test OpenCode Go config fails when no config key and no env var.""" + config: AiConfig = {"opencode_go": {}} + with pytest.raises(HTTPException) as exc_info: + AnyProviderConfig.for_opencode_go(config) + + assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST + assert "OpenCode Go API key not configured" in str( + exc_info.value.detail + ) + + +class TestGetKey: + """Tests for _get_key function.""" + + def test_get_key_with_api_key(self): + """Test getting API key from config.""" + config = {"api_key": "test-key"} + + result = _get_key(config, "Test Service") + + assert result == "test-key" + + def test_get_key_bedrock_profile(self): + """Test getting Bedrock key with profile name.""" + config = {"profile_name": "aws-profile"} + + result = _get_key(config, "Bedrock") + + assert result == "profile:aws-profile" + + def test_get_key_bedrock_credentials(self): + """Test getting Bedrock key with AWS credentials.""" + config = { + "aws_access_key_id": "access-key", + "aws_secret_access_key": "secret-key", + } + + result = _get_key(config, "Bedrock") + + assert result == "access-key:secret-key" + + def test_get_key_bedrock_fallback(self): + """Test Bedrock key fallback when no credentials.""" + config = {} + + result = _get_key(config, "Bedrock") + + assert result == "" + + def test_get_key_ollama_placeholder(self): + """Test Ollama gets placeholder key for local URL.""" + config = {"base_url": "http://127.0.0.1:11434/"} + + result = _get_key(config, "Ollama") + + assert result == "ollama-placeholder" + + def test_get_key_invalid_config(self): + """Test error when config is not a dict.""" + with pytest.raises(HTTPException) as exc_info: + _get_key("invalid", "Test Service") + + assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST + assert "Invalid config" in str(exc_info.value.detail) + + def test_get_key_missing_api_key(self): + """Test error when API key is missing.""" + config = {} + + assert _get_key(config, "Test Service") == "" + + def test_get_key_empty_api_key(self): + """Test error when API key is empty.""" + config = {"api_key": ""} + + assert _get_key(config, "Test Service") == "" + + def test_get_key_none_api_key(self): + """Test error when API key is None.""" + config = {"api_key": None} + + assert _get_key(config, "Test Service") == "" + + def test_get_key_with_fallback_key(self): + """Test using fallback key when api_key is missing.""" + config = {} + + result = _get_key(config, "Test Service", fallback_key="fallback-key") + + assert result == "fallback-key" + + def test_get_key_with_fallback_key_empty_api_key(self): + """Test using fallback key when api_key is empty.""" + config = {"api_key": ""} + + result = _get_key(config, "Test Service", fallback_key="fallback-key") + + assert result == "fallback-key" + + def test_get_key_with_fallback_key_none_api_key(self): + """Test using fallback key when api_key is None.""" + config = {"api_key": None} + + result = _get_key(config, "Test Service", fallback_key="fallback-key") + + assert result == "fallback-key" + + def test_get_key_config_takes_precedence_over_fallback(self): + """Test that config api_key takes precedence over fallback_key.""" + config = {"api_key": "config-key"} + + result = _get_key(config, "Test Service", fallback_key="fallback-key") + + assert result == "config-key" + + def test_get_key_no_fallback_key_provided(self): + """Test error when no fallback key provided and api_key missing.""" + config = {} + + assert _get_key(config, "Test Service", fallback_key=None) == "" + + def test_get_key_empty_fallback_key(self): + """Test error when fallback key is empty string.""" + config = {} + + assert _get_key(config, "Test Service", fallback_key="") == "" + + def test_get_key_bedrock_profile_ignores_fallback(self): + """Test that Bedrock profile handling ignores fallback key.""" + config = {"profile_name": "aws-profile"} + + result = _get_key(config, "Bedrock", fallback_key="fallback-key") + + assert result == "profile:aws-profile" + + def test_get_key_bedrock_credentials_ignores_fallback(self): + """Test that Bedrock credentials handling ignores fallback key.""" + config = { + "aws_access_key_id": "access-key", + "aws_secret_access_key": "secret-key", + } + + result = _get_key(config, "Bedrock", fallback_key="fallback-key") + + assert result == "access-key:secret-key" + + def test_get_key_ollama_placeholder_ignores_fallback(self): + """Test that Ollama placeholder handling ignores fallback key.""" + config = {"base_url": "http://127.0.0.1:11434/"} + + result = _get_key(config, "Ollama", fallback_key="fallback-key") + + assert result == "ollama-placeholder" + + +class TestGetBaseUrl: + """Tests for _get_base_url function.""" + + def test_get_base_url_with_url(self): + """Test getting base URL from config.""" + config = {"base_url": "https://api.example.com"} + + result = _get_base_url(config) + + assert result == "https://api.example.com" + + def test_get_base_url_bedrock_region(self): + """Test getting Bedrock base URL from region.""" + config = {"region_name": "us-east-1"} + + result = _get_base_url(config, "Bedrock") + + assert result == "us-east-1" + + def test_get_base_url_bedrock_without_name_param(self): + """Test that Bedrock base URL is None when name param is not passed.""" + config = {"region_name": "us-east-1"} + + result = _get_base_url(config) # No name parameter + + assert result is None + + def test_get_base_url_bedrock_no_region(self): + """Test Bedrock base URL when no region specified.""" + config = {} + + result = _get_base_url(config, "Bedrock") + + assert result is None + + def test_get_base_url_missing(self): + """Test when base URL is not in config.""" + config = {} + + result = _get_base_url(config) + + assert result is None + + def test_get_base_url_invalid_config(self): + """Test error when config is not a dict.""" + with pytest.raises(HTTPException) as exc_info: + _get_base_url("invalid") + + assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST + assert "Invalid config" in str(exc_info.value.detail) + + +class TestGetAiConfig: + """Tests for _get_ai_config function.""" + + def test_get_ai_config_success(self): + """Test successful retrieval of AI config.""" + config: AiConfig = {"open_ai": {"api_key": "test-key"}} + + result = _get_ai_config(config, "open_ai") + + assert result == {"api_key": "test-key"} + + def test_get_ai_config_missing_key(self): + """Test that _get_ai_config returns empty dict when AI config key is missing.""" + config: AiConfig = {} + + result = _get_ai_config(config, "open_ai") + + assert result == {} + + def test_get_ai_config_empty_tools(self): + """Test that _get_ai_config returns empty dict when AI config key is missing.""" + config: AiConfig = { + "open_ai": {"api_key": "test-key"}, + "mode": "manual", + } + + result = _get_ai_config(config, "open_ai") + + assert result == {"api_key": "test-key"} + + +class TestUtilityFunctions: + """Tests for utility functions.""" + + def test_get_model_with_openai_config(self): + """Test getting model from OpenAI config.""" + config: AiConfig = { + "models": { + "chat_model": "gpt-4", + "edit_model": "gpt-5", + "displayed_models": [], + "custom_models": [], + }, + "open_ai": {"api_key": "test-key"}, + } + + result = get_chat_model(config) + assert result == "gpt-4" + + result = get_edit_model(config) + assert result == "gpt-5" + + def test_get_model_default(self): + """Test getting default model when not specified.""" + config: AiConfig = { + "models": { + "displayed_models": [], + "custom_models": [], + }, + "open_ai": {"api_key": "test-key"}, + } + + result = get_chat_model(config) + assert result == DEFAULT_MODEL + + result = get_edit_model(config) + assert result == DEFAULT_MODEL + + result = get_autocomplete_model({"ai": config}) + assert result == DEFAULT_MODEL + + def test_get_max_tokens_from_config(self): + """Test getting max tokens from config.""" + config = cast( + MarimoConfig, + { + "ai": {"max_tokens": 2048}, + }, + ) + + result = get_max_tokens(config) + + assert result == 2048 + + def test_get_max_tokens_default_no_ai_config(self): + """Test getting max tokens returns None when no AI config.""" + config = cast( + MarimoConfig, + { + "completion": {"activate_on_typing": True, "copilot": False}, + }, + ) + + result = get_max_tokens(config) + + assert result is None + + def test_get_max_tokens_default_no_max_tokens(self): + """Test getting max tokens returns None when max_tokens not specified.""" + config = cast( + MarimoConfig, + { + "ai": {}, + }, + ) + + result = get_max_tokens(config) + + assert result is None + + def test_get_autocomplete_model(self) -> None: + """Test get_autocomplete_model with new ai.models.autocomplete_model config.""" + + config: AiConfig = { + "models": { + "chat_model": "openai/gpt-4o", + "edit_model": "openai/gpt-4o-mini", + "autocomplete_model": "openai/gpt-3.5-turbo-instruct", + "displayed_models": [], + "custom_models": [], + } + } + + assert ( + get_autocomplete_model({"ai": config}) + == "openai/gpt-3.5-turbo-instruct" + ) + + def test_get_chat_model(self) -> None: + """Test get_chat_model with new ai.models.chat_model config.""" + + config: AiConfig = { + "models": { + "chat_model": "anthropic/claude-3-5-sonnet-20241022", + "edit_model": "openai/gpt-4o-mini", + "displayed_models": [], + "custom_models": [], + } + } + + assert get_chat_model(config) == "anthropic/claude-3-5-sonnet-20241022" + + def test_get_edit_model(self) -> None: + """Test get_edit_model with new ai.models.edit_model config.""" + + config: AiConfig = { + "models": { + "chat_model": "openai/gpt-4o", + "edit_model": "anthropic/claude-3-5-haiku-20241022", + "displayed_models": [], + "custom_models": [], + } + } + + assert get_edit_model(config) == "anthropic/claude-3-5-haiku-20241022" + + def test_get_edit_model_fallback_to_chat_model(self) -> None: + """Test get_edit_model falls back to chat_model when edit_model is not set.""" + + config: AiConfig = { + "models": { + "chat_model": "openai/gpt-4o", + "displayed_models": [], + "custom_models": [], + # Note: no edit_model + } + } + + assert get_edit_model(config) == "openai/gpt-4o" + + def test_get_models_with_legacy_openai_config(self) -> None: + """Test that the new get_*_model functions work with legacy open_ai.model config.""" + config: AiConfig = { + "open_ai": { + "api_key": "test-key", + "model": "gpt-4-legacy", + } + } + + # Should fall back to open_ai.model for both chat and edit + assert get_chat_model(config) == "gpt-4-legacy" + assert get_edit_model(config) == "gpt-4-legacy" + assert get_autocomplete_model({"ai": config}) == DEFAULT_MODEL + + def test_for_model_with_autocomplete_model(self) -> None: + """Test AnyProviderConfig.for_model works with autocomplete models from new config.""" + config: AiConfig = { + "open_ai": {"api_key": "test-key"}, + "models": { + "autocomplete_model": "openai/gpt-3.5-turbo-instruct", + "displayed_models": [], + "custom_models": [], + }, + } + + provider_config = AnyProviderConfig.for_model( + "openai/gpt-3.5-turbo-instruct", config + ) + + assert provider_config.api_key == "test-key" + assert provider_config.tools is None + + +class TestSSLConfiguration: + """Tests for SSL configuration across all OpenAI-like providers.""" + + @pytest.mark.parametrize( + ("provider_name", "provider_method", "api_key_config"), + [ + ("openai", "for_openai", {"open_ai": {"api_key": "test-key"}}), + ("github", "for_github", {"github": {"api_key": "test-key"}}), + ("ollama", "for_ollama", {"ollama": {"api_key": "test-key"}}), + ], + ) + def test_ssl_config_from_provider_config( + self, + provider_name: str, + provider_method: str, + api_key_config: AiConfig, + ) -> None: + """Test SSL configuration is read from provider config.""" + # Get the provider key from api_key_config + provider_key = next(iter(api_key_config.keys())) + + config: AiConfig = { + **api_key_config, + } + config[provider_key]["ssl_verify"] = False + config[provider_key]["ca_bundle_path"] = "/custom/path/to/ca.pem" + config[provider_key]["client_pem"] = "/custom/path/to/client.pem" + config[provider_key]["extra_headers"] = {"X-Custom": "header"} + + method = getattr(AnyProviderConfig, provider_method) + provider_config = method(config) + + assert provider_config.ssl_verify is False, ( + f"{provider_name}: ssl_verify should be False" + ) + assert provider_config.ca_bundle_path == "/custom/path/to/ca.pem", ( + f"{provider_name}: ca_bundle_path should match" + ) + assert provider_config.client_pem == "/custom/path/to/client.pem", ( + f"{provider_name}: client_pem should match" + ) + # GitHub includes default headers that are merged with user headers + if provider_name == "github": + assert provider_config.extra_headers is not None + assert "X-Custom" in provider_config.extra_headers + assert provider_config.extra_headers["X-Custom"] == "header" + # GitHub should also include default headers + assert "editor-version" in provider_config.extra_headers + assert "Copilot-Integration-Id" in provider_config.extra_headers + else: + assert provider_config.extra_headers == {"X-Custom": "header"}, ( + f"{provider_name}: extra_headers should match" + ) + + @pytest.mark.parametrize( + ("provider_name", "provider_method", "api_key_config"), + [ + ("openai", "for_openai", {"open_ai": {"api_key": "test-key"}}), + ("github", "for_github", {"github": {"api_key": "test-key"}}), + ("ollama", "for_ollama", {"ollama": {"api_key": "test-key"}}), + ], + ) + @patch.dict(os.environ, {"SSL_CERT_FILE": "/env/path/to/ca.pem"}) + def test_ssl_cert_file_fallback( + self, + provider_name: str, + provider_method: str, + api_key_config: AiConfig, + ) -> None: + """Test SSL_CERT_FILE environment variable is used as fallback.""" + config: AiConfig = {**api_key_config} + + method = getattr(AnyProviderConfig, provider_method) + provider_config = method(config) + + assert provider_config.ca_bundle_path == "/env/path/to/ca.pem", ( + f"{provider_name}: should use SSL_CERT_FILE env var as fallback" + ) + + +class TestEdgeCases: + """Tests for edge cases and error conditions.""" + + def test_openai_config_missing(self): + """Test error when OpenAI config is missing.""" + config: AiConfig = {} + + with pytest.raises(HTTPException) as exc_info: + AnyProviderConfig.for_openai(config) + + assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST + assert "OpenAI API key not configured" in str(exc_info.value.detail) + + def test_anthropic_config_missing(self): + """Test error when Anthropic config is missing.""" + config: AiConfig = {} + + with pytest.raises(HTTPException) as exc_info: + AnyProviderConfig.for_anthropic(config) + + assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST + assert "Anthropic API key not configured" in str(exc_info.value.detail) + + def test_google_config_missing(self): + """Test Google config defaults to empty key when config is missing.""" + config: AiConfig = {} + + provider_config = AnyProviderConfig.for_google(config) + assert provider_config == AnyProviderConfig( + base_url=None, + api_key="", + ssl_verify=True, + ) + + def test_bedrock_config_missing(self): + """Test when Bedrock config is missing, should not error since could use environment variables.""" + config: AiConfig = {} + + provider_config = AnyProviderConfig.for_bedrock(config) + assert provider_config == AnyProviderConfig( + base_url=None, + api_key="", + ) + + def test_azure_config_missing(self): + """Test error when Azure config is missing.""" + config: AiConfig = {} + + with pytest.raises(HTTPException) as exc_info: + AnyProviderConfig.for_azure(config) + + assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST + assert "Azure OpenAI API key not configured" in str( + exc_info.value.detail + ) + + def test_ollama_config_missing(self): + """Test should not error when Ollama config is missing.""" + config: AiConfig = {} + + provider_config = AnyProviderConfig.for_ollama(config) + assert provider_config == AnyProviderConfig( + base_url="http://127.0.0.1:11434/v1", + api_key="ollama-placeholder", + ssl_verify=True, + ) + + def test_openai_compatible_config_missing(self): + """Test error when OpenAI Compatible config is missing.""" + config: AiConfig = {} + + assert AnyProviderConfig.for_openai_compatible( + config + ) == AnyProviderConfig(base_url=None, api_key="", ssl_verify=True) + + def test_github_config_missing(self): + """Test error when GitHub config is missing.""" + config: AiConfig = {} + + with pytest.raises(HTTPException) as exc_info: + AnyProviderConfig.for_github(config) + + assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST + assert "GitHub API key not configured" in str(exc_info.value.detail) + + def test_tools_empty_list(self): + """Test that tools are not included when empty list.""" + provider_config = AnyProviderConfig( + tools=[], + api_key="test-key", + base_url="test-base-url", + ) + assert provider_config.tools is None diff --git a/tests/_server/ai/test_ai_ids.py b/tests/_server/ai/test_ai_ids.py new file mode 100644 index 00000000000..b11434093b7 --- /dev/null +++ b/tests/_server/ai/test_ai_ids.py @@ -0,0 +1,175 @@ +from unittest.mock import Mock, patch + +from marimo._server.ai.ids import ( + AiModelId, + AiProviderId, + QualifiedModelId, + ShortModelId, + _guess_provider, +) + + +class TestAiModelId: + def test_init(self): + """Test AiModelId initialization.""" + model_id = AiModelId( + provider=AiProviderId("openai"), model=ShortModelId("gpt-4") + ) + assert model_id.provider == "openai" + assert model_id.model == "gpt-4" + + def test_str_conversion(self): + """Test string conversion returns qualified model ID.""" + model_id = AiModelId( + provider=AiProviderId("anthropic"), model=ShortModelId("claude-3") + ) + result = str(model_id) + assert result == "anthropic/claude-3" + assert isinstance(result, str) + + def test_repr(self): + """Test repr returns proper representation.""" + model_id = AiModelId( + provider=AiProviderId("google"), model=ShortModelId("gemini-pro") + ) + result = repr(model_id) + assert result == "AiModelId(provider=google, model=gemini-pro)" + + def test_from_model_valid_format(self): + """Test from_model with valid provider/model format.""" + model_id = AiModelId.from_model("openai/gpt-4") + assert model_id.provider == "openai" + assert model_id.model == "gpt-4" + + def test_from_model_complex_model_name(self): + """Test from_model with complex model names containing slashes.""" + model_id = AiModelId.from_model( + "huggingface/microsoft/DialoGPT-medium" + ) + assert model_id.provider == "huggingface" + assert model_id.model == "microsoft/DialoGPT-medium" + + @patch("marimo._server.ai.ids.LOGGER") + def test_from_model_invalid_format_openai(self, mock_logger: Mock): + """Test from_model with invalid format - should guess OpenAI.""" + model_id = AiModelId.from_model("gpt-4") + + assert model_id.provider == "openai" + assert model_id.model == "gpt-4" + + # Verify warning was logged + mock_logger.warning.assert_any_call( + "Invalid model ID: gpt-4. Model ID must be in the format /" + ) + mock_logger.warning.assert_any_call( + "Guessing provider for gpt-4 as openai" + ) + + @patch("marimo._server.ai.ids.LOGGER") + def test_from_model_invalid_format_anthropic(self, mock_logger: Mock): + """Test from_model with invalid format - should guess Anthropic.""" + model_id = AiModelId.from_model("claude-3-opus") + + assert model_id.provider == "anthropic" + assert model_id.model == "claude-3-opus" + + mock_logger.warning.assert_any_call( + "Invalid model ID: claude-3-opus. Model ID must be in the format /" + ) + + @patch("marimo._server.ai.ids.LOGGER") + def test_from_model_invalid_format_google(self, mock_logger: Mock): + del mock_logger + """Test from_model with invalid format - should guess Google.""" + model_id = AiModelId.from_model("gemini-pro") + + assert model_id.provider == "google" + assert model_id.model == "gemini-pro" + + @patch("marimo._server.ai.ids.LOGGER") + def test_from_model_invalid_format_ollama_fallback( + self, mock_logger: Mock + ): + del mock_logger + """Test from_model with invalid format - should fallback to Ollama.""" + model_id = AiModelId.from_model("llama2") + + assert model_id.provider == "ollama" + assert model_id.model == "llama2" + + +class TestGuessProvider: + def test_guess_openai_gpt(self): + """Test guessing OpenAI provider for GPT models.""" + assert _guess_provider("gpt-4") == "openai" + assert _guess_provider("gpt-3.5-turbo") == "openai" + assert _guess_provider("gpt-4o") == "openai" + + def test_guess_openai_o3(self): + """Test guessing OpenAI provider for O3 models.""" + assert _guess_provider("o3-mini") == "openai" + assert _guess_provider("o3-max") == "openai" + + def test_guess_openai_o1(self): + """Test guessing OpenAI provider for O1 models.""" + assert _guess_provider("o1-preview") == "openai" + assert _guess_provider("o1-mini") == "openai" + + def test_guess_anthropic_claude(self): + """Test guessing Anthropic provider for Claude models.""" + assert _guess_provider("claude-3-opus") == "anthropic" + assert _guess_provider("claude-3-sonnet") == "anthropic" + assert _guess_provider("claude-3-haiku") == "anthropic" + assert _guess_provider("claude-2") == "anthropic" + + def test_guess_google_gemini(self): + """Test guessing Google provider for Gemini models.""" + assert _guess_provider("gemini-pro") == "google" + assert _guess_provider("gemini-1.5-pro") == "google" + assert _guess_provider("gemini-flash") == "google" + + def test_guess_google_google_prefix(self): + """Test guessing Google provider for models with google prefix.""" + assert _guess_provider("google-palm") == "google" + assert _guess_provider("google-bard") == "google" + + def test_guess_ollama_fallback(self): + """Test fallback to Ollama for unknown models.""" + assert _guess_provider("llama2") == "ollama" + assert _guess_provider("mistral") == "ollama" + assert _guess_provider("codellama") == "ollama" + assert _guess_provider("unknown-model") == "ollama" + + def test_guess_provider_edge_cases(self): + """Test edge cases for provider guessing.""" + # Empty string + assert _guess_provider("") == "ollama" + + # Models that might be ambiguous + assert _guess_provider("gpt") == "openai" # Starts with gpt + assert _guess_provider("claude") == "anthropic" # Starts with claude + assert _guess_provider("gemini") == "google" # Starts with gemini + + # Case sensitivity (should work as expected) + assert ( + _guess_provider("GPT-4") == "ollama" + ) # Doesn't start with lowercase "gpt" + assert ( + _guess_provider("Claude-3") == "ollama" + ) # Doesn't start with lowercase "claude" + + +class TestTypeAliases: + def test_type_aliases_are_strings(self): + """Test that type aliases behave as strings.""" + provider_id = AiProviderId("test-provider") + qualified_id = QualifiedModelId("test/model") + short_id = ShortModelId("model") + + assert isinstance(provider_id, str) + assert isinstance(qualified_id, str) + assert isinstance(short_id, str) + + assert provider_id == "test-provider" + assert qualified_id == "test/model" + assert short_id == "model" diff --git a/tests/_server/ai/test_mcp.py b/tests/_server/ai/test_mcp.py new file mode 100644 index 00000000000..ddcb23720b6 --- /dev/null +++ b/tests/_server/ai/test_mcp.py @@ -0,0 +1,1938 @@ +# Copyright 2026 Marimo. All rights reserved. +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from marimo._config.config import ( + MCPConfig, + MCPServerStdioConfig, + MCPServerStreamableHttpConfig, +) +from marimo._dependencies.dependencies import DependencyManager +from marimo._server.ai.mcp import ( + MCP_PRESETS, + MCPClient, + MCPConfigComparator, + MCPServerConnection, + MCPServerDefinition, + MCPServerDefinitionFactory, + MCPServerStatus, + MCPTransportRegistry, + MCPTransportType, + StdioTransportConnector, + StreamableHTTPTransportConnector, + append_presets, + get_mcp_client, +) + +# test fixtures and helpers + + +@pytest.fixture +def mock_session_setup(): + """Create a properly configured mock session with async context manager behavior.""" + + def _create_mock_session(additional_methods=None, side_effects=None): + mock_session = AsyncMock() + mock_session.initialize = AsyncMock() + mock_session.list_tools = AsyncMock() + mock_session.list_tools.return_value.tools = [] + + # Add any additional methods specified + if additional_methods: + for method_name, method_mock in additional_methods.items(): + setattr(mock_session, method_name, method_mock) + + # Apply any side effects + if side_effects: + for method_name, side_effect in side_effects.items(): + getattr(mock_session, method_name).side_effect = side_effect + + mock_session_context = AsyncMock() + mock_session_context.__aenter__ = AsyncMock(return_value=mock_session) + mock_session_context.__aexit__ = AsyncMock(return_value=None) + + return mock_session, mock_session_context + + return _create_mock_session + + +@pytest.fixture +def mock_stdio_setup(): + """Create a properly configured mock stdio client with async context manager behavior.""" + + def _create_mock_stdio(): + mock_read = AsyncMock() + mock_write = AsyncMock() + mock_stdio_context = AsyncMock() + mock_stdio_context.__aenter__ = AsyncMock( + return_value=(mock_read, mock_write) + ) + mock_stdio_context.__aexit__ = AsyncMock(return_value=None) + + return mock_read, mock_write, mock_stdio_context + + return _create_mock_stdio + + +def create_test_server_definition( + name: str = "test_server", + command: str = "test", + args: list | None = None, + env: dict | None = None, + timeout: float | None = None, +) -> MCPServerDefinition: + """Create a test server definition with sensible defaults.""" + if args is None: + args = [] + if env is None: + env = {} + + config = MCPServerStdioConfig(command=command, args=args, env=env) + server_def = MCPServerDefinitionFactory.from_config(name, config) + + if timeout is not None: + server_def.timeout = timeout + + return server_def + + +def create_test_server_connection( + name: str = "test_server", + command: str = "test", + args: list | None = None, + env: dict | None = None, + status: MCPServerStatus = MCPServerStatus.DISCONNECTED, + session=None, + timeout: float | None = None, +) -> MCPServerConnection: + """Create a test server connection with sensible defaults.""" + server_def = create_test_server_definition( + name, command, args, env, timeout + ) + connection = MCPServerConnection(definition=server_def) + connection.status = status + connection.session = session + return connection + + +def create_test_tool( + name: str = "test_tool", + description: str = "Test tool", + server_name: str = "test_server", + namespaced_name: str | None = None, + input_schema: dict | None = None, +): + """Create a test tool with sensible defaults.""" + if DependencyManager.mcp.has(): + from mcp.types import Tool + + if input_schema is None: + input_schema = {"type": "object"} + if namespaced_name is None: + namespaced_name = f"mcp_{server_name}_{name}" + + return Tool( + name=name, + description=description, + inputSchema=input_schema, + _meta={ + "server_name": server_name, + "namespaced_name": namespaced_name, + }, + ) + return None + + +# tests + + +class TestMCPServerDefinition: + """Test cases for MCPServerDefinition class.""" + + @pytest.mark.parametrize( + ("config_type", "expected_transport", "config_kwargs"), + [ + pytest.param( + MCPServerStdioConfig, + MCPTransportType.STDIO, + { + "command": "python", + "args": ["server.py"], + "env": {"API_KEY": "test"}, + }, + id="stdio_transport", + ), + pytest.param( + MCPServerStreamableHttpConfig, + MCPTransportType.STREAMABLE_HTTP, + { + "url": "https://api.example.com/mcp", + "headers": {"Auth": "Bearer token"}, + "timeout": 45.0, + }, + id="http_transport", + ), + ], + ) + def test_from_config_transport_detection( + self, config_type, expected_transport, config_kwargs + ): + """Test that transport types are correctly auto-detected from configuration.""" + config = config_type(**config_kwargs) + server_def = MCPServerDefinitionFactory.from_config( + "test_server", config + ) + + assert server_def.name == "test_server" + assert server_def.transport == expected_transport + assert server_def.config == config + + # Verify transport-specific attributes are available from config + if expected_transport == MCPTransportType.STDIO: + assert server_def.config["command"] == config_kwargs["command"] + assert server_def.config.get("args") == config_kwargs["args"] + assert server_def.config.get("env") == config_kwargs["env"] + elif expected_transport == MCPTransportType.STREAMABLE_HTTP: + assert server_def.config["url"] == config_kwargs["url"] + assert server_def.config.get("headers") == config_kwargs["headers"] + assert server_def.timeout == config_kwargs["timeout"] + + +class TestMCPConfigComparator: + """Test cases for MCPConfigComparator utility class.""" + + def test_compute_diff_no_changes(self): + """Test that compute_diff detects no changes when configs are identical.""" + server1 = MCPServerDefinition( + name="server1", + transport=MCPTransportType.STDIO, + config=MCPServerStdioConfig(command="test", args=[], env={}), + timeout=30.0, + ) + + current = {"server1": server1} + new = {"server1": server1} + + diff = MCPConfigComparator.compute_diff(current, new) + + assert not diff.has_changes() + assert len(diff.servers_to_add) == 0 + assert len(diff.servers_to_remove) == 0 + assert len(diff.servers_to_update) == 0 + assert "server1" in diff.servers_unchanged + + def test_compute_diff_add_servers(self): + """Test that compute_diff detects new servers.""" + server1 = MCPServerDefinition( + name="server1", + transport=MCPTransportType.STDIO, + config=MCPServerStdioConfig(command="test1", args=[], env={}), + timeout=30.0, + ) + server2 = MCPServerDefinition( + name="server2", + transport=MCPTransportType.STDIO, + config=MCPServerStdioConfig(command="test2", args=[], env={}), + timeout=30.0, + ) + + current = {"server1": server1} + new = {"server1": server1, "server2": server2} + + diff = MCPConfigComparator.compute_diff(current, new) + + assert diff.has_changes() + assert "server2" in diff.servers_to_add + assert len(diff.servers_to_remove) == 0 + assert len(diff.servers_to_update) == 0 + assert "server1" in diff.servers_unchanged + + def test_compute_diff_remove_servers(self): + """Test that compute_diff detects removed servers.""" + server1 = MCPServerDefinition( + name="server1", + transport=MCPTransportType.STDIO, + config=MCPServerStdioConfig(command="test1", args=[], env={}), + timeout=30.0, + ) + server2 = MCPServerDefinition( + name="server2", + transport=MCPTransportType.STDIO, + config=MCPServerStdioConfig(command="test2", args=[], env={}), + timeout=30.0, + ) + + current = {"server1": server1, "server2": server2} + new = {"server1": server1} + + diff = MCPConfigComparator.compute_diff(current, new) + + assert diff.has_changes() + assert "server2" in diff.servers_to_remove + assert len(diff.servers_to_add) == 0 + assert len(diff.servers_to_update) == 0 + assert "server1" in diff.servers_unchanged + + def test_compute_diff_update_servers(self): + """Test that compute_diff detects modified servers.""" + server1_old = MCPServerDefinition( + name="server1", + transport=MCPTransportType.STDIO, + config=MCPServerStdioConfig( + command="test", args=["--old"], env={} + ), + timeout=30.0, + ) + server1_new = MCPServerDefinition( + name="server1", + transport=MCPTransportType.STDIO, + config=MCPServerStdioConfig( + command="test", args=["--new"], env={} + ), + timeout=30.0, + ) + + current = {"server1": server1_old} + new = {"server1": server1_new} + + diff = MCPConfigComparator.compute_diff(current, new) + + assert diff.has_changes() + assert "server1" in diff.servers_to_update + assert len(diff.servers_to_add) == 0 + assert len(diff.servers_to_remove) == 0 + assert len(diff.servers_unchanged) == 0 + + def test_compute_diff_mixed_changes(self): + """Test compute_diff with multiple types of changes.""" + server1 = MCPServerDefinition( + name="unchanged", + transport=MCPTransportType.STDIO, + config=MCPServerStdioConfig(command="test1", args=[], env={}), + timeout=30.0, + ) + server2_old = MCPServerDefinition( + name="updated", + transport=MCPTransportType.STDIO, + config=MCPServerStdioConfig( + command="test2", args=["--old"], env={} + ), + timeout=30.0, + ) + server2_new = MCPServerDefinition( + name="updated", + transport=MCPTransportType.STDIO, + config=MCPServerStdioConfig( + command="test2", args=["--new"], env={} + ), + timeout=30.0, + ) + server3 = MCPServerDefinition( + name="removed", + transport=MCPTransportType.STDIO, + config=MCPServerStdioConfig(command="test3", args=[], env={}), + timeout=30.0, + ) + server4 = MCPServerDefinition( + name="added", + transport=MCPTransportType.STDIO, + config=MCPServerStdioConfig(command="test4", args=[], env={}), + timeout=30.0, + ) + + current = { + "unchanged": server1, + "updated": server2_old, + "removed": server3, + } + new = {"unchanged": server1, "updated": server2_new, "added": server4} + + diff = MCPConfigComparator.compute_diff(current, new) + + assert diff.has_changes() + assert "unchanged" in diff.servers_unchanged + assert "updated" in diff.servers_to_update + assert "removed" in diff.servers_to_remove + assert "added" in diff.servers_to_add + + +class TestMCPPresets: + """Test cases for MCP preset configuration system.""" + + def test_preset_definitions_exist(self): + """Test that expected presets are defined.""" + assert "marimo" in MCP_PRESETS + assert "context7" in MCP_PRESETS + + # Verify preset structure + assert "url" in MCP_PRESETS["marimo"] + assert "url" in MCP_PRESETS["context7"] + + def test_append_presets_no_presets_list(self): + """Test append_presets with config that has no presets list.""" + config = MCPConfig( + mcpServers={ + "custom": MCPServerStdioConfig(command="test", args=[]) + } + ) + + result = append_presets(config) + + # Should return config unchanged + assert "custom" in result["mcpServers"] + assert len(result["mcpServers"]) == 1 + + def test_append_presets_empty_presets_list(self): + """Test append_presets with empty presets list.""" + config = MCPConfig(mcpServers={}, presets=[]) + + result = append_presets(config) + + assert len(result["mcpServers"]) == 0 + + def test_append_presets_adds_marimo_preset(self): + """Test that marimo preset is added when specified.""" + config = MCPConfig(mcpServers={}, presets=["marimo"]) + + result = append_presets(config) + + assert "marimo" in result["mcpServers"] + assert ( + result["mcpServers"]["marimo"]["url"] + == MCP_PRESETS["marimo"]["url"] + ) + + def test_append_presets_adds_context7_preset(self): + """Test that context7 preset is added when specified.""" + config = MCPConfig(mcpServers={}, presets=["context7"]) + + result = append_presets(config) + + assert "context7" in result["mcpServers"] + assert ( + result["mcpServers"]["context7"]["url"] + == MCP_PRESETS["context7"]["url"] + ) + + def test_append_presets_adds_multiple_presets(self): + """Test that multiple presets can be added.""" + config = MCPConfig(mcpServers={}, presets=["marimo", "context7"]) + + result = append_presets(config) + + assert "marimo" in result["mcpServers"] + assert "context7" in result["mcpServers"] + assert len(result["mcpServers"]) == 2 + + def test_append_presets_preserves_existing_servers(self): + """Test that existing servers are preserved when adding presets.""" + config = MCPConfig( + mcpServers={ + "custom": MCPServerStdioConfig(command="test", args=[]) + }, + presets=["marimo"], + ) + + result = append_presets(config) + + assert "custom" in result["mcpServers"] + assert "marimo" in result["mcpServers"] + assert len(result["mcpServers"]) == 2 + + def test_append_presets_does_not_override_existing(self): + """Test that presets don't override existing servers with same name.""" + custom_url = "https://custom.marimo.app/mcp" + config = MCPConfig( + mcpServers={ + "marimo": MCPServerStreamableHttpConfig(url=custom_url) + }, + presets=["marimo"], + ) + + result = append_presets(config) + + # Original server should be preserved + assert result["mcpServers"]["marimo"]["url"] == custom_url + assert len(result["mcpServers"]) == 1 + + def test_append_presets_does_not_mutate_original(self): + """Test that append_presets doesn't mutate the original config.""" + config = MCPConfig(mcpServers={}, presets=["marimo"]) + + result = append_presets(config) + + # Original config should be unchanged + assert "marimo" not in config["mcpServers"] + # Result should have the preset + assert "marimo" in result["mcpServers"] + + +class TestMCPTransportConnectors: + """Test cases for transport connector classes.""" + + def test_transport_registry_functionality(self): + """Test that the transport registry properly handles all transport types.""" + registry = MCPTransportRegistry() + + # Test that all transport types are supported + for transport_type in MCPTransportType: + connector = registry.get_connector(transport_type) + assert connector is not None + + # Test unsupported transport type + with pytest.raises(ValueError, match="Unsupported transport type"): + registry.get_connector("unsupported_transport") # type: ignore + + @pytest.mark.skipif( + not DependencyManager.mcp.has(), reason="MCP SDK not available" + ) + @patch("mcp.client.stdio.stdio_client") + async def test_stdio_connector_connect(self, mock_stdio_client): + """Test STDIO transport connector connection.""" + # Setup mocks + mock_read = AsyncMock() + mock_write = AsyncMock() + mock_context = AsyncMock() + mock_context.__aenter__ = AsyncMock( + return_value=(mock_read, mock_write) + ) + mock_context.__aexit__ = AsyncMock(return_value=None) + mock_stdio_client.return_value = mock_context + + # Create connector and test connection + connector = StdioTransportConnector() + config = MCPServerStdioConfig( + command="python", args=["server.py"], env={"TEST_VAR": "value"} + ) + server_def = MCPServerDefinition( + name="test", transport=MCPTransportType.STDIO, config=config + ) + + from contextlib import AsyncExitStack + + async with AsyncExitStack() as exit_stack: + read, write = await connector.connect(server_def, exit_stack) + assert read == mock_read + assert write == mock_write + + @pytest.mark.skipif( + not DependencyManager.mcp.has(), reason="MCP SDK not available" + ) + @patch("mcp.client.streamable_http.streamable_http_client") + async def test_http_connector_connect(self, mock_http_client): + """Test HTTP transport connector connection.""" + # Setup mocks + mock_read = AsyncMock() + mock_write = AsyncMock() + mock_context = AsyncMock() + mock_context.__aenter__ = AsyncMock( + return_value=(mock_read, mock_write) + ) + mock_context.__aexit__ = AsyncMock(return_value=None) + mock_http_client.return_value = mock_context + + # Create connector and test connection + connector = StreamableHTTPTransportConnector() + config = MCPServerStreamableHttpConfig( + url="https://api.example.com/mcp", + headers={"Authorization": "Bearer token"}, + timeout=30.0, + ) + server_def = MCPServerDefinition( + name="test", + transport=MCPTransportType.STREAMABLE_HTTP, + config=config, + timeout=30.0, + ) + + from contextlib import AsyncExitStack + + async with AsyncExitStack() as exit_stack: + read, write = await connector.connect(server_def, exit_stack) + assert read == mock_read + assert write == mock_write + + @pytest.mark.skipif( + not DependencyManager.mcp.has(), reason="MCP SDK not available" + ) + @patch("mcp.client.streamable_http.streamablehttp_client") + @patch.dict("sys.modules", {}) + async def test_http_connector_connect_legacy_fallback( + self, mock_streamablehttp_client + ): + """Test HTTP transport connector fallback to streamablehttp_client on ImportError.""" + mock_read = AsyncMock() + mock_write = AsyncMock() + mock_context = AsyncMock() + mock_context.__aenter__ = AsyncMock( + return_value=(mock_read, mock_write) + ) + mock_context.__aexit__ = AsyncMock(return_value=None) + mock_streamablehttp_client.return_value = mock_context + + connector = StreamableHTTPTransportConnector() + config = MCPServerStreamableHttpConfig( + url="https://api.example.com/mcp", + headers={"Authorization": "Bearer token"}, + timeout=30.0, + ) + server_def = MCPServerDefinition( + name="test", + transport=MCPTransportType.STREAMABLE_HTTP, + config=config, + timeout=30.0, + ) + + from contextlib import AsyncExitStack + + with patch( + "mcp.client.streamable_http.streamable_http_client", + side_effect=ImportError, + ): + async with AsyncExitStack() as exit_stack: + read, write = await connector.connect(server_def, exit_stack) + assert read == mock_read + assert write == mock_write + mock_streamablehttp_client.assert_called_once_with( + "https://api.example.com/mcp", + headers={"Authorization": "Bearer token"}, + timeout=30.0, + ) + + +class TestMCPClientConfiguration: + """Test cases for MCPClient configuration parsing and initialization.""" + + def test_init_with_empty_config(self): + """Test MCPClient initialization with empty config.""" + client = MCPClient() + assert client.servers == {} + assert client.connections == {} + assert client.tool_registry == {} + + @pytest.mark.parametrize( + ("server_configs", "expected_servers"), + [ + pytest.param( + { + "stdio_server": MCPServerStdioConfig( + command="python", + args=["test.py"], + env={"TEST": "value"}, + ), + }, + ["stdio_server"], + id="single_stdio_server", + ), + pytest.param( + { + "http_server": MCPServerStreamableHttpConfig( + url="https://api.example.com/mcp", + headers={"Auth": "Bearer token"}, + ), + }, + ["http_server"], + id="single_http_server", + ), + pytest.param( + { + "stdio_server": MCPServerStdioConfig( + command="python", args=["test.py"] + ), + "http_server": MCPServerStreamableHttpConfig( + url="https://api.example.com/mcp" + ), + }, + ["stdio_server", "http_server"], + id="mixed_servers", + ), + ], + ) + def test_parse_config_valid_servers( + self, server_configs, expected_servers + ): + """Test parsing valid server configurations.""" + config = MCPConfig(mcpServers=server_configs) + client = MCPClient() + + # Parse the config to populate servers + parsed_servers = client._parse_config(config) + client.servers = parsed_servers + + assert len(client.servers) == len(expected_servers) + for server_name in expected_servers: + assert server_name in client.servers + server_def = client.servers[server_name] + assert server_def.name == server_name + + +@pytest.mark.skipif( + not DependencyManager.mcp.has(), reason="MCP SDK not available" +) +class TestMCPClientReconfiguration: + """Test cases for MCPClient dynamic reconfiguration functionality.""" + + async def test_configure_noop_when_no_changes(self, mock_session_setup): + """Test that configure() does nothing when config hasn't changed.""" + del mock_session_setup + config = MCPConfig( + mcpServers={ + "server1": MCPServerStdioConfig( + command="test", args=[], env={} + ) + } + ) + client = MCPClient() + + # Initial configure + await client.configure(config) + + # Track calls to connect_to_server + original_connect = client.connect_to_server + connect_calls = [] + + async def track_connect(server_name: str): + connect_calls.append(server_name) + return await original_connect(server_name) + + client.connect_to_server = track_connect + + # Configure with same config + await client.configure(config) + + # Should not have called connect_to_server + assert len(connect_calls) == 0 + + async def test_configure_adds_new_servers(self, mock_session_setup): + """Test that configure() adds new servers.""" + del mock_session_setup + initial_config = MCPConfig( + mcpServers={ + "server1": MCPServerStdioConfig( + command="test1", args=[], env={} + ) + } + ) + client = MCPClient() + await client.configure(initial_config) + + # New config with additional server + new_config = MCPConfig( + mcpServers={ + "server1": MCPServerStdioConfig( + command="test1", args=[], env={} + ), + "server2": MCPServerStdioConfig( + command="test2", args=[], env={} + ), + } + ) + + # Mock the connection methods + mock_connect = AsyncMock(return_value=True) + with patch.object(client, "connect_to_server", mock_connect): + await client.configure(new_config) + + # Verify server2 was added + assert "server1" in client.servers + assert "server2" in client.servers + assert mock_connect.called + # Should only connect to server2 (the new one) + assert mock_connect.call_count == 1 + mock_connect.assert_called_with("server2") + + async def test_configure_removes_old_servers(self, mock_session_setup): + """Test that configure() removes servers not in new config.""" + del mock_session_setup + initial_config = MCPConfig( + mcpServers={ + "server1": MCPServerStdioConfig( + command="test1", args=[], env={} + ), + "server2": MCPServerStdioConfig( + command="test2", args=[], env={} + ), + } + ) + client = MCPClient() + await client.configure(initial_config) + + # Create mock connections + client.connections["server1"] = create_test_server_connection( + "server1", MCPServerStatus.CONNECTED + ) + client.connections["server2"] = create_test_server_connection( + "server2", MCPServerStatus.CONNECTED + ) + + # New config with only server1 + new_config = MCPConfig( + mcpServers={ + "server1": MCPServerStdioConfig( + command="test1", args=[], env={} + ) + } + ) + + # Mock disconnect_from_server + mock_disconnect = AsyncMock(return_value=True) + with patch.object(client, "disconnect_from_server", mock_disconnect): + await client.configure(new_config) + + # Verify server2 was removed + assert "server1" in client.servers + assert "server2" not in client.servers + assert "server2" not in client.connections + + # Should have called disconnect for server2 + mock_disconnect.assert_called_once_with("server2") + + async def test_configure_updates_modified_servers( + self, mock_session_setup + ): + """Test that configure() reconnects to servers with changed config.""" + del mock_session_setup + initial_config = MCPConfig( + mcpServers={ + "server1": MCPServerStdioConfig( + command="test1", args=["--old"], env={} + ) + } + ) + client = MCPClient() + await client.configure(initial_config) + + # Create mock connection + client.connections["server1"] = create_test_server_connection( + "server1", MCPServerStatus.CONNECTED + ) + + # New config with modified server1 + new_config = MCPConfig( + mcpServers={ + "server1": MCPServerStdioConfig( + command="test1", args=["--new"], env={} + ) + } + ) + + # Mock methods + mock_disconnect = AsyncMock(return_value=True) + mock_connect = AsyncMock(return_value=True) + with ( + patch.object(client, "disconnect_from_server", mock_disconnect), + patch.object(client, "connect_to_server", mock_connect), + ): + await client.configure(new_config) + + # Should have disconnected and reconnected to server1 + mock_disconnect.assert_called_once_with("server1") + mock_connect.assert_called_once_with("server1") + + # Verify config was updated + assert client.servers["server1"].config["args"] == ["--new"] + + async def test_configure_mixed_changes(self, mock_session_setup): + """Test configure() with add, remove, and update operations.""" + del mock_session_setup + initial_config = MCPConfig( + mcpServers={ + "keep_unchanged": MCPServerStdioConfig( + command="test1", args=[], env={} + ), + "to_update": MCPServerStdioConfig( + command="test2", args=["--old"], env={} + ), + "to_remove": MCPServerStdioConfig( + command="test3", args=[], env={} + ), + } + ) + client = MCPClient() + await client.configure(initial_config) + + # Create mock connections + for name in ["keep_unchanged", "to_update", "to_remove"]: + client.connections[name] = create_test_server_connection( + name, MCPServerStatus.CONNECTED + ) + + # New config + new_config = MCPConfig( + mcpServers={ + "keep_unchanged": MCPServerStdioConfig( + command="test1", args=[], env={} + ), + "to_update": MCPServerStdioConfig( + command="test2", args=["--new"], env={} + ), + "to_add": MCPServerStdioConfig( + command="test4", args=[], env={} + ), + } + ) + + # Mock methods + mock_disconnect = AsyncMock(return_value=True) + mock_connect = AsyncMock(return_value=True) + with ( + patch.object(client, "disconnect_from_server", mock_disconnect), + patch.object(client, "connect_to_server", mock_connect), + ): + await client.configure(new_config) + + # Verify results + assert "keep_unchanged" in client.servers + assert "to_update" in client.servers + assert "to_add" in client.servers + assert "to_remove" not in client.servers + assert "to_remove" not in client.connections + + # Verify disconnect was called for removed and updated + assert mock_disconnect.call_count == 2 + disconnect_calls = [ + call[0][0] for call in mock_disconnect.call_args_list + ] + assert "to_remove" in disconnect_calls + assert "to_update" in disconnect_calls + + # Verify connect was called for added and updated + assert mock_connect.call_count == 2 + connect_calls = [call[0][0] for call in mock_connect.call_args_list] + assert "to_add" in connect_calls + assert "to_update" in connect_calls + + async def test_configure_connection_failures_logged( + self, mock_session_setup + ): + """Test that configure() handles connection failures gracefully.""" + del mock_session_setup + initial_config = MCPConfig(mcpServers={}) + client = MCPClient() + await client.configure(initial_config) + + new_config = MCPConfig( + mcpServers={ + "server1": MCPServerStdioConfig( + command="test1", args=[], env={} + ) + } + ) + + # Mock connect_to_server to fail + mock_connect = AsyncMock(side_effect=Exception("Connection failed")) + with patch.object(client, "connect_to_server", mock_connect): + # Should not raise, just log + await client.configure(new_config) + + # Server should still be in registry even if connection failed + assert "server1" in client.servers + + +class TestMCPClientToolManagement: + """Test cases for MCPClient tool management functionality.""" + + def test_create_namespaced_tool_name_no_conflict(self): + """Test creating namespaced tool name without conflicts.""" + client = MCPClient() + name = client._create_namespaced_tool_name("github", "create_issue") + assert name == "mcp_github_create_issue" + + @pytest.mark.skipif( + not DependencyManager.mcp.has(), reason="MCP SDK not available" + ) + def test_create_namespaced_tool_name_with_conflicts(self): + """Test creating namespaced tool name with conflicts and counter resolution.""" + client = MCPClient() + + from mcp.types import Tool + + # Create first tool - should get base name + name1 = client._create_namespaced_tool_name("github", "create_issue") + assert name1 == "mcp_github_create_issue" + + # Add it to registry + tool1 = Tool( + name="create_issue", + description="Test tool", + inputSchema={}, + _meta={"server_name": "github", "namespaced_name": name1}, + ) + client.tool_registry[name1] = tool1 + + # Create second tool with same name - should get numbered suffix + name2 = client._create_namespaced_tool_name("github", "create_issue") + assert name2 == "mcp_github1_create_issue" + + # Create third tool - should get next counter + name3 = client._create_namespaced_tool_name("github", "create_issue") + assert name3 == "mcp_github2_create_issue" + + # All names should be unique + assert len({name1, name2, name3}) == 3 + + @pytest.mark.skipif( + not DependencyManager.mcp.has(), reason="MCP SDK not available" + ) + def test_add_server_tools(self): + """Test adding tools from a server to registry and connection.""" + client = MCPClient() + from mcp.types import Tool + + # Create server connection + connection = create_test_server_connection() + + # Create raw tools to add + raw_tools = [ + Tool( + name="tool1", + description="Test tool 1", + inputSchema={"type": "object"}, + ), + Tool( + name="tool2", + description="Test tool 2", + inputSchema={"type": "object"}, + ), + ] + + # Add tools + client._add_server_tools(connection, raw_tools) + + # Verify tools are added to connection + assert len(connection.tools) == 2 + + # Verify tools are added to registry with proper namespacing + assert "mcp_test_server_tool1" in client.tool_registry + assert "mcp_test_server_tool2" in client.tool_registry + + # Verify tool metadata + tool1 = client.tool_registry["mcp_test_server_tool1"] + assert tool1.meta["server_name"] == "test_server" + assert tool1.meta["namespaced_name"] == "mcp_test_server_tool1" + + @pytest.mark.skipif( + not DependencyManager.mcp.has(), reason="MCP SDK not available" + ) + def test_remove_server_tools(self): + """Test removing tools from a server.""" + client = MCPClient() + from mcp.types import Tool + + # Create tools from different servers + server1_tools = [ + ( + "mcp_server1_tool1", + Tool( + name="tool1", + description="Test", + inputSchema={}, + _meta={ + "server_name": "server1", + "namespaced_name": "mcp_server1_tool1", + }, + ), + ), + ( + "mcp_server1_tool2", + Tool( + name="tool2", + description="Test", + inputSchema={}, + _meta={ + "server_name": "server1", + "namespaced_name": "mcp_server1_tool2", + }, + ), + ), + ] + server2_tools = [ + ( + "mcp_server2_tool3", + Tool( + name="tool3", + description="Test", + inputSchema={}, + _meta={ + "server_name": "server2", + "namespaced_name": "mcp_server2_tool3", + }, + ), + ) + ] + + # Add tools to registry + for namespaced_name, tool in server1_tools + server2_tools: + client.tool_registry[namespaced_name] = tool + + # Create connection and add tools + connection = create_test_server_connection(name="server1") + connection.tools = [tool for _, tool in server1_tools] + client.connections["server1"] = connection + + # Set a counter for the server + client.server_counters["server1"] = 3 + + # Remove tools from server1 + client._remove_server_tools("server1") + + # Verify server1 tools are removed + for namespaced_name, _ in server1_tools: + assert namespaced_name not in client.tool_registry + + # Verify server2 tools remain + for namespaced_name, _ in server2_tools: + assert namespaced_name in client.tool_registry + + # Verify connection tools are cleared + assert len(connection.tools) == 0 + + # Verify counter is reset + assert "server1" not in client.server_counters + + @pytest.mark.skipif( + not DependencyManager.mcp.has(), reason="MCP SDK not available" + ) + @pytest.mark.parametrize( + ("server_name", "expected_tool_count"), + [ + pytest.param("server1", 2, id="existing_server"), + pytest.param("nonexistent", 0, id="nonexistent_server"), + ], + ) + def test_get_tools_by_server(self, server_name, expected_tool_count): + """Test getting tools by server name.""" + client = MCPClient() + from mcp.types import Tool + + # Add tools from different servers + tools_data = [ + ("mcp_server1_tool1", "server1"), + ("mcp_server1_tool2", "server1"), + ("mcp_server2_tool3", "server2"), + ] + + for namespaced_name, server in tools_data: + tool = Tool( + name=namespaced_name.split("_")[-1], + description="Test", + inputSchema={}, + _meta={ + "server_name": server, + "namespaced_name": namespaced_name, + }, + ) + client.tool_registry[namespaced_name] = tool + + # Get tools by server + tools = client.get_tools_by_server(server_name) + assert len(tools) == expected_tool_count + + +@pytest.mark.skipif( + not DependencyManager.mcp.has(), reason="MCP SDK not available" +) +class TestMCPClientToolExecution: + """Test cases for MCPClient tool execution functionality.""" + + def test_create_tool_params(self): + """Test creating properly typed CallToolRequestParams.""" + client = MCPClient() + + # Add a mock tool to the registry + mock_tool = create_test_tool() + client.tool_registry["mcp_test_server_test_tool"] = mock_tool + + # Test creating tool params with arguments + params = client.create_tool_params( + "mcp_test_server_test_tool", {"arg1": "value1"} + ) + assert params.name == "test_tool" + assert params.arguments == {"arg1": "value1"} + + # Test with no arguments + params_no_args = client.create_tool_params("mcp_test_server_test_tool") + assert params_no_args.name == "test_tool" + assert params_no_args.arguments is None + + # Test with non-existent tool + with pytest.raises(ValueError, match="Tool 'nonexistent' not found"): + client.create_tool_params("nonexistent") + + @pytest.mark.parametrize( + ("tool_setup", "connection_setup", "expected_error_pattern"), + [ + pytest.param( + None, # No tool setup + {"status": MCPServerStatus.CONNECTED, "session": AsyncMock()}, + "Tool 'nonexistent_tool' not found", + id="tool_not_found", + ), + pytest.param( + {"server_name": "test_server"}, + {"status": MCPServerStatus.DISCONNECTED, "session": None}, + "Server 'test_server' is not connected", + id="server_not_connected", + ), + pytest.param( + {"server_name": "test_server"}, + {"status": MCPServerStatus.CONNECTED, "session": None}, + "No active session for server 'test_server'", + id="no_active_session", + ), + ], + ) + async def test_invoke_tool_error_cases( + self, tool_setup, connection_setup, expected_error_pattern + ): + """Test invoke_tool error handling scenarios.""" + client = MCPClient() + from mcp.types import Tool + + # Setup tool if provided + if tool_setup: + mock_tool = Tool( + name="test_tool", + description="Test tool", + inputSchema={}, + _meta={ + "server_name": tool_setup["server_name"], + "namespaced_name": "mcp_test_server_test_tool", + }, + ) + client.tool_registry["mcp_test_server_test_tool"] = mock_tool + + # Setup connection + server_def = MCPServerDefinitionFactory.from_config( + "test_server", MCPServerStdioConfig(command="test", args=[]) + ) + connection = MCPServerConnection(definition=server_def) + connection.status = connection_setup["status"] + connection.session = connection_setup["session"] + client.connections["test_server"] = connection + + # Create params for the tool + params = client.create_tool_params( + "mcp_test_server_test_tool", {"arg1": "value1"} + ) + tool_name = "mcp_test_server_test_tool" + else: + # Use non-existent tool + from mcp.types import CallToolRequestParams + + params = CallToolRequestParams( + name="nonexistent", arguments={"arg1": "value1"} + ) + tool_name = "nonexistent_tool" + + # Test tool invocation + result = await client.invoke_tool(tool_name, params) + + # Verify it's an error result + assert client.is_error_result(result) is True + + # Verify error message + error_messages = client.extract_text_content(result) + assert len(error_messages) > 0 + assert expected_error_pattern in error_messages[0] + + async def test_invoke_tool_success(self): + """Test successful tool invocation.""" + client = MCPClient() + from mcp.types import CallToolResult, TextContent + + # Setup tool + mock_tool = create_test_tool() + client.tool_registry["mcp_test_server_test_tool"] = mock_tool + + # Setup connection with mock session + connection = create_test_server_connection( + status=MCPServerStatus.CONNECTED, session=AsyncMock() + ) + + # Mock successful tool result + expected_result = CallToolResult( + content=[ + TextContent(type="text", text="Tool executed successfully") + ] + ) + connection.session.call_tool = AsyncMock(return_value=expected_result) + client.connections["test_server"] = connection + + # Create params and invoke tool + params = client.create_tool_params( + "mcp_test_server_test_tool", {"arg1": "value1"} + ) + result = await client.invoke_tool("mcp_test_server_test_tool", params) + + # Verify result + assert client.is_error_result(result) is False + text_contents = client.extract_text_content(result) + assert "Tool executed successfully" in text_contents[0] + + # Verify session was called correctly + connection.session.call_tool.assert_called_once_with( + "test_tool", {"arg1": "value1"} + ) + + async def test_invoke_tool_timeout(self): + """Test tool invocation timeout handling.""" + client = MCPClient() + + # Setup tool + mock_tool = create_test_tool() + client.tool_registry["mcp_test_server_test_tool"] = mock_tool + + # Setup connection with timeout + connection = create_test_server_connection( + timeout=0.1, # Very short timeout + status=MCPServerStatus.CONNECTED, + session=AsyncMock(), + ) + + # Mock session to hang longer than timeout + async def slow_call_tool(_name, _args): + await asyncio.sleep(1) # Longer than timeout + + connection.session.call_tool = AsyncMock(side_effect=slow_call_tool) + client.connections["test_server"] = connection + + # Create params and invoke tool + params = client.create_tool_params( + "mcp_test_server_test_tool", {"arg1": "value1"} + ) + result = await client.invoke_tool("mcp_test_server_test_tool", params) + + # Verify timeout error + assert client.is_error_result(result) is True + error_messages = client.extract_text_content(result) + assert "timed out" in error_messages[0] + + @pytest.mark.parametrize( + ("result_content", "expected_is_error", "expected_text_count"), + [ + pytest.param( + [{"type": "text", "text": "Success message"}], + False, + 1, + id="success_result", + ), + pytest.param( + [{"type": "text", "text": "Error occurred"}], + True, + 1, + id="error_result", + ), + pytest.param( + [ + {"type": "text", "text": "First message"}, + {"type": "text", "text": "Second message"}, + ], + False, + 2, + id="multiple_text_content", + ), + ], + ) + def test_result_handling_helpers( + self, result_content, expected_is_error, expected_text_count + ): + """Test CallToolResult helper methods.""" + from mcp.types import CallToolResult, TextContent + + client = MCPClient() + + # Create result + content = [TextContent(**item) for item in result_content] + result = CallToolResult(isError=expected_is_error, content=content) + + # Test error detection + assert client.is_error_result(result) == expected_is_error + + # Test text extraction + text_contents = client.extract_text_content(result) + assert len(text_contents) == expected_text_count + + for i, expected_text in enumerate( + [item["text"] for item in result_content] + ): + assert text_contents[i] == expected_text + + +@pytest.mark.skipif( + not DependencyManager.mcp.has(), reason="MCP SDK not available" +) +class TestMCPClientConnectionManagement: + """Test cases for MCPClient connection management functionality.""" + + async def test_discover_tools_success(self): + """Test successful tool discovery from an MCP server.""" + client = MCPClient() + from mcp.types import ListToolsResult, Tool + + # Create mock connection with session + mock_session = AsyncMock() + connection = create_test_server_connection(session=mock_session) + + # Mock tools response + mock_tools = [ + Tool( + name="tool1", + description="First tool", + inputSchema={"type": "object"}, + ), + Tool( + name="tool2", + description="Second tool", + inputSchema={"type": "object"}, + ), + ] + mock_response = ListToolsResult(tools=mock_tools) + mock_session.list_tools = AsyncMock(return_value=mock_response) + + # Test tool discovery + await client._discover_tools(connection) + + # Verify tools were added + assert len(connection.tools) == 2 + assert "mcp_test_server_tool1" in client.tool_registry + assert "mcp_test_server_tool2" in client.tool_registry + + # Verify session was called + mock_session.list_tools.assert_called_once() + + async def test_discover_tools_no_session(self): + """Test tool discovery with no active session.""" + client = MCPClient() + + # Create connection without session + connection = create_test_server_connection(session=None) + + # Test tool discovery (should handle gracefully) + await client._discover_tools(connection) + + # Verify no tools were added + assert len(connection.tools) == 0 + assert len(client.tool_registry) == 0 + + @patch("mcp.ClientSession") + @patch("mcp.client.stdio.stdio_client") + async def test_connect_to_server_success( + self, + mock_stdio_client, + mock_session_class, + mock_stdio_setup, + mock_session_setup, + ): + """Test successful server connection with complete flow.""" + # Setup stdio and session mocks using fixtures + mock_read, mock_write, mock_stdio_context = mock_stdio_setup() + mock_stdio_client.return_value = mock_stdio_context + + _mock_session, mock_session_context = mock_session_setup() + mock_session_class.return_value = mock_session_context + + # Mock AsyncExitStack + with patch( + "marimo._server.ai.mcp.StdioTransportConnector.connect" + ) as mock_connector_connect: + # Mock connector.connect to return the expected streams + mock_connector_connect.return_value = (mock_read, mock_write) + + # Create client with test config + config = MCPConfig( + mcpServers={ + "test_server": MCPServerStdioConfig( + command="python", args=["test.py"], env={} + ) + } + ) + client = MCPClient() + await client.configure(config) + + # Test connection + result = await client.connect_to_server("test_server") + + assert result is True + assert "test_server" in client.connections + assert ( + client.connections["test_server"].status + == MCPServerStatus.CONNECTED + ) + + @pytest.mark.parametrize( + ("server_exists", "already_connected", "expected_result"), + [ + pytest.param(False, False, False, id="server_not_found"), + pytest.param(True, True, True, id="already_connected"), + ], + ) + async def test_connect_to_server_edge_cases( + self, server_exists, already_connected, expected_result + ): + """Test server connection edge cases.""" + config = MCPConfig(mcpServers={}) + if server_exists: + config["mcpServers"]["test_server"] = MCPServerStdioConfig( + command="python", args=["test.py"] + ) + + client = MCPClient() + await client.configure(config) + + if already_connected: + # Setup existing connection + server_def = MCPServerDefinitionFactory.from_config( + "test_server", MCPServerStdioConfig(command="test", args=[]) + ) + connection = MCPServerConnection(definition=server_def) + connection.status = MCPServerStatus.CONNECTED + client.connections["test_server"] = connection + + result = await client.connect_to_server("test_server") + assert result == expected_result + + @pytest.mark.xfail(reason="Flaky test") + @patch("mcp.ClientSession") + async def test_connect_to_all_servers_mixed_results( + self, mock_session_class + ): + """Test connecting to multiple servers with mixed success/failure.""" + # Setup session mock for successful connections + mock_session = AsyncMock() + mock_session.initialize = AsyncMock() + mock_session.list_tools = AsyncMock() + mock_session.list_tools.return_value.tools = [] + + mock_session_context = AsyncMock() + mock_session_context.__aenter__ = AsyncMock(return_value=mock_session) + mock_session_context.__aexit__ = AsyncMock(return_value=None) + mock_session_class.return_value = mock_session_context + + with patch( + "marimo._server.ai.mcp.StdioTransportConnector.connect" + ) as mock_connector_connect: + # Simulate success for server1, failure for server2 + mock_connector_connect.side_effect = [ + (AsyncMock(), AsyncMock()), # server1 success + Exception("Connection failed"), # server2 failure + ] + + config = MCPConfig( + mcpServers={ + "server1": MCPServerStdioConfig( + command="python", args=["test1.py"] + ), + "server2": MCPServerStdioConfig( + command="python", args=["test2.py"] + ), + } + ) + client = MCPClient() + await client.configure(config) + + results = await client.connect_to_all_servers() + + # Verify mixed results + assert len(results) == 2 + assert results["server1"] is True + assert results["server2"] is False + + +@pytest.mark.skipif( + not DependencyManager.mcp.has(), reason="MCP SDK not available" +) +class TestMCPClientDisconnectionManagement: + """Test cases for MCPClient disconnection functionality.""" + + async def test_disconnect_from_server_success(self): + """Test successful disconnection from a connected server.""" + client = MCPClient() + + # Setup a connected server using existing patterns + connection = create_test_server_connection( + name="test_server", + status=MCPServerStatus.CONNECTED, + session=AsyncMock(), + ) + + # Create mock task that simulates running connection task + mock_task = AsyncMock() + mock_task.done.return_value = False # Task is still running + disconnect_event = asyncio.Event() + + connection.connection_task = mock_task + connection.disconnect_event = disconnect_event + client.connections["test_server"] = connection + + # Call actual disconnect method + result = await client.disconnect_from_server("test_server") + + # Verify successful disconnection + assert result is True + assert disconnect_event.is_set() # Event was signaled + # Note: mock_task should be awaited since done() returns False + + async def test_disconnect_from_server_already_disconnected(self): + """Test disconnection from server that's already disconnected.""" + client = MCPClient() + + # Call disconnect on non-existent server + result = await client.disconnect_from_server("nonexistent_server") + + # Should return True (idempotent operation) + assert result is True + + async def test_disconnect_from_server_with_exception(self): + """Test disconnection failure handling (validates our new comment).""" + client = MCPClient() + + # Setup connection with task that will raise exception when awaited + connection = create_test_server_connection( + name="test_server", status=MCPServerStatus.CONNECTED + ) + + # Create event to signal when task has started + task_started = asyncio.Event() + + # Create a long-running task that will fail when awaited + async def blocking_failing_task(): + task_started.set() # Signal task has started + await asyncio.sleep(0.1) # Simulate work + raise RuntimeError("Simulated disconnection failure") + + # Start the task + failing_task = asyncio.create_task(blocking_failing_task()) + # Wait for task to actually start (deterministic) + await asyncio.wait_for(task_started.wait(), timeout=1.0) + + connection.connection_task = failing_task + connection.disconnect_event = asyncio.Event() + client.connections["test_server"] = connection + + # Call disconnect - should handle exception gracefully + result = await client.disconnect_from_server("test_server") + + # Should return False but not raise exception (non-blocking behavior) + assert result is False + + async def test_disconnect_from_server_cleanup_verification(self): + """Test that disconnection properly cleans up server state.""" + client = MCPClient() + + # Setup connected server with tools and monitoring + connection = create_test_server_connection( + name="test_server", + status=MCPServerStatus.CONNECTED, + session=AsyncMock(), + ) + + # Add tools to verify they get cleaned up + mock_tools = [ + create_test_tool(name="tool1", server_name="test_server"), + create_test_tool(name="tool2", server_name="test_server"), + ] + + for i, tool in enumerate(mock_tools): + if tool: + namespaced_name = f"mcp_test_server_tool{i + 1}" + client.tool_registry[namespaced_name] = tool + connection.tools.append(tool) + + # Add health monitoring task + health_task = AsyncMock() + client.health_check_tasks["test_server"] = health_task + + # Setup connection task + connection.connection_task = AsyncMock() + connection.connection_task.done.return_value = True # Already done + connection.disconnect_event = asyncio.Event() + client.connections["test_server"] = connection + + # Disconnect + result = await client.disconnect_from_server("test_server") + + # Verify cleanup happens in _connection_lifecycle finally block + assert result is True + # Note: Tool cleanup happens in _connection_lifecycle finally block, + # not directly in disconnect_from_server + + @pytest.mark.parametrize( + "server_setups", + [ + pytest.param( + [ + {"name": "server1", "should_succeed": True}, + {"name": "server2", "should_succeed": True}, + ], + id="all_succeed", + ), + pytest.param( + [ + {"name": "server1", "should_succeed": True}, + {"name": "server2", "should_succeed": False}, + ], + id="mixed_results", + ), + pytest.param( + [ + {"name": "server1", "should_succeed": False}, + {"name": "server2", "should_succeed": False}, + ], + id="all_fail", + ), + ], + ) + async def test_disconnect_from_all_servers_scenarios(self, server_setups): + """Test disconnect_from_all_servers with various success/failure combinations.""" + client = MCPClient() + + # Setup connections based on test parameters + for setup in server_setups: + connection = create_test_server_connection( + name=setup["name"], status=MCPServerStatus.CONNECTED + ) + + # Setup task behavior based on should_succeed + if setup["should_succeed"]: + mock_task = AsyncMock() + mock_task.done.return_value = False + else: + mock_task = AsyncMock() + mock_task.done.return_value = False + mock_task.side_effect = Exception("Simulated failure") + + connection.connection_task = mock_task + connection.disconnect_event = asyncio.Event() + client.connections[setup["name"]] = connection + + # Call actual disconnect_from_all_servers method + await client.disconnect_from_all_servers() + + # Verify disconnect events were set (disconnect_from_all_servers doesn't return results) + for setup in server_setups: + connection = client.connections[setup["name"]] + # Event should be set regardless of success/failure (signal was sent) + assert connection.disconnect_event.is_set() + + async def test_disconnect_from_all_servers_with_health_monitoring(self): + """Test that disconnect_from_all_servers cancels health monitoring first.""" + client = MCPClient() + + # Setup connections with health monitoring tasks + server_names = ["server1", "server2"] + for name in server_names: + # Create connection + connection = create_test_server_connection( + name=name, status=MCPServerStatus.CONNECTED + ) + connection.connection_task = AsyncMock() + connection.connection_task.done.return_value = True + connection.disconnect_event = asyncio.Event() + client.connections[name] = connection + + # Create health monitoring task + health_task = AsyncMock() + health_task.cancel = AsyncMock() + client.health_check_tasks[name] = health_task + + # Mock _cancel_health_monitoring to verify it's called + with patch.object( + client, "_cancel_health_monitoring", new_callable=AsyncMock + ) as mock_cancel: + await client.disconnect_from_all_servers() + + # Verify health monitoring was cancelled first + mock_cancel.assert_called_once_with() + + async def test_disconnect_cross_task_scenario(self): + """Test disconnection in cross-task scenarios (like server shutdown).""" + client = MCPClient() + + # Setup connection that simulates cross-task issues + connection = create_test_server_connection( + name="test_server", status=MCPServerStatus.CONNECTED + ) + + # Create event to signal when task has started + task_started = asyncio.Event() + + # Create a task that simulates cross-task lifecycle issues + async def cross_task_error(): + task_started.set() # Signal task has started + await asyncio.sleep(0.1) # Simulate work + raise RuntimeError("Task was destroyed but it is pending!") + + # Start the task + cross_task = asyncio.create_task(cross_task_error()) + # Wait for task to actually start (deterministic) + await asyncio.wait_for(task_started.wait(), timeout=1.0) + + connection.connection_task = cross_task + connection.disconnect_event = asyncio.Event() + client.connections["test_server"] = connection + + # This should handle the cross-task error gracefully (non-blocking) + result = await client.disconnect_from_server("test_server") + + # Should return False (failure) but not raise exception + assert result is False + + # Event should still be signaled to attempt cleanup + assert connection.disconnect_event.is_set() + + +class TestMCPClientHealthMonitoring: + """Test cases for MCPClient health monitoring functionality.""" + + @pytest.mark.skipif( + not DependencyManager.mcp.has(), reason="MCP SDK not available" + ) + async def test_perform_health_check_success(self): + """Test successful health check.""" + client = MCPClient() + + # Create connection with mock session + server_def = MCPServerDefinitionFactory.from_config( + "test", MCPServerStdioConfig(command="test", args=[]) + ) + connection = MCPServerConnection(definition=server_def) + connection.session = AsyncMock() + connection.session.send_ping = AsyncMock() + client.connections["test"] = connection + + result = await client._perform_health_check("test") + + assert result is True + connection.session.send_ping.assert_called_once() + # Note: last_health_check is updated by the caller (_monitor_server_health), not _perform_health_check + assert connection.last_health_check == 0 # Should remain unchanged + + @pytest.mark.parametrize( + ("session_setup", "ping_behavior", "expected_result"), + [ + pytest.param( + None, # No session + None, + False, + id="no_session", + ), + pytest.param( + AsyncMock(), # Valid session + Exception("Ping failed"), # Exception during ping + False, + id="ping_exception", + ), + ], + ) + async def test_perform_health_check_failure_cases( + self, session_setup, ping_behavior, expected_result + ): + """Test health check failure scenarios.""" + client = MCPClient() + + # Create connection + server_def = MCPServerDefinitionFactory.from_config( + "test", MCPServerStdioConfig(command="test", args=[]) + ) + connection = MCPServerConnection(definition=server_def) + connection.session = session_setup + + if session_setup and ping_behavior: + connection.session.send_ping = AsyncMock(side_effect=ping_behavior) + + client.connections["test"] = connection + + result = await client._perform_health_check("test") + + assert result == expected_result + # Note: _perform_health_check doesn't update connection status directly + # Status updates happen in the calling code (_monitor_server_health) + + async def test_perform_health_check_timeout(self): + """Test health check timeout handling.""" + client = MCPClient() + client.health_check_timeout = 0.1 # Very short timeout + + # Create connection with session that hangs + server_def = MCPServerDefinitionFactory.from_config( + "test", MCPServerStdioConfig(command="test", args=[]) + ) + connection = MCPServerConnection(definition=server_def) + connection.session = AsyncMock() + + # Create a coroutine that sleeps longer than timeout + async def slow_ping(): + await asyncio.sleep(1) + + connection.session.send_ping = AsyncMock(side_effect=slow_ping) + client.connections["test"] = connection + + result = await client._perform_health_check("test") + + assert result is False + # Note: _perform_health_check doesn't update connection status directly + # Status updates happen in the calling code (_monitor_server_health) + + +class TestMCPServerConnection: + """Test cases for MCPServerConnection class.""" + + def test_server_connection_creation(self): + """Test creating a server connection with proper defaults.""" + server_def = MCPServerDefinitionFactory.from_config( + "test_server", + MCPServerStdioConfig( + command="python", args=["test.py"], env={"TEST": "value"} + ), + ) + + connection = MCPServerConnection(definition=server_def) + + assert connection.definition.name == "test_server" + assert connection.definition.config["command"] == "python" + assert connection.definition.config.get("args") == ["test.py"] + assert connection.definition.config.get("env") == {"TEST": "value"} + assert connection.status == MCPServerStatus.DISCONNECTED + assert connection.session is None + assert len(connection.tools) == 0 + assert connection.last_health_check == 0 + assert connection.error_message is None + + +class TestMCPUtilities: + """Test utility functions and configuration.""" + + @pytest.mark.skipif( + not DependencyManager.mcp.has(), reason="MCP SDK not available" + ) + def test_get_mcp_client_singleton(self): + """Test that get_mcp_client returns singleton instance.""" + client1 = get_mcp_client() + client2 = get_mcp_client() + + assert client1 is client2 + + @pytest.mark.skipif( + not DependencyManager.mcp.has(), reason="MCP SDK not available" + ) + async def test_get_mcp_client_with_custom_config(self): + """Test get_mcp_client with custom configuration.""" + # Reset global client for this test + import marimo._server.ai.mcp.client as client_module + + client_module._MCP_CLIENT = None + + custom_config = MCPConfig( + mcpServers={ + "custom_server": MCPServerStdioConfig( + command="custom", args=["--test"], env={} + ) + } + ) + + client = get_mcp_client() + await client.configure(custom_config) + assert "custom_server" in client.servers diff --git a/tests/_server/ai/test_prompts.py b/tests/_server/ai/test_prompts.py index 9eb841fcb58..ab974b2007d 100644 --- a/tests/_server/ai/test_prompts.py +++ b/tests/_server/ai/test_prompts.py @@ -1,15 +1,31 @@ -# Copyright 2024 Marimo. All rights reserved. +# Copyright 2026 Marimo. All rights reserved. from __future__ import annotations from typing import cast +import pytest + from marimo._ast.visitor import Language -from marimo._server.ai.prompts import Prompter +from marimo._config.config import CopilotMode +from marimo._server.ai.prompts import ( + FIM_SUFFIX_TAG, + _common_chat_sections, + _format_plain_text, + _format_schema_info, + _format_variables, + _get_mode_intro_message, + get_chat_system_prompt, + get_inline_system_prompt, + get_refactor_or_insert_notebook_cell_system_prompt, +) +from marimo._server.ai.skills.utils import load_skill from marimo._server.models.completion import ( AiCompletionContext, SchemaColumn, SchemaTable, + VariableContext, ) +from marimo._types.ids import SessionId from tests.mocks import snapshotter snapshot = snapshotter(__file__) @@ -23,16 +39,33 @@ def test_system_prompts(): result = "" for language in ("python", "markdown", "sql", "idk"): result += _header(language) - result += Prompter.get_system_prompt(language=cast(Language, language)) + result += get_refactor_or_insert_notebook_cell_system_prompt( + language=cast(Language, language), + is_insert=False, + support_multiple_cells=False, + custom_rules=None, + cell_code=None, + selected_text=None, + other_cell_codes=None, + context=None, + ) result += _header("with custom rules") - result += Prompter.get_system_prompt( - language="python", custom_rules="Always use type hints." + result += get_refactor_or_insert_notebook_cell_system_prompt( + language="python", + is_insert=False, + support_multiple_cells=False, + custom_rules="Always use type hints.", + cell_code=None, + selected_text=None, + other_cell_codes=None, + context=None, ) result += _header("with context") - result += Prompter.get_system_prompt( + result += get_refactor_or_insert_notebook_cell_system_prompt( language="python", + support_multiple_cells=False, context=AiCompletionContext( schema=[ SchemaTable( @@ -50,128 +83,389 @@ def test_system_prompts(): ) ] ), + is_insert=False, + custom_rules=None, + cell_code=None, + selected_text=None, + other_cell_codes=None, + ) + + # New test cases for get_refactor_or_insert_notebook_cell_system_prompt + result += _header("with is_insert=True") + result += get_refactor_or_insert_notebook_cell_system_prompt( + language="python", + is_insert=True, + support_multiple_cells=False, + custom_rules=None, + cell_code="def fib(n):\n ", + selected_text=None, + other_cell_codes=None, + context=None, + ) + + result += _header("with cell_code") + result += get_refactor_or_insert_notebook_cell_system_prompt( + language="python", + is_insert=False, + support_multiple_cells=False, + custom_rules=None, + cell_code="def hello():\n print('Hello, world!')", + selected_text="print('Hello, world!')", + other_cell_codes=None, + context=None, + ) + + result += _header("with selected_text") + result += get_refactor_or_insert_notebook_cell_system_prompt( + language="python", + is_insert=False, + support_multiple_cells=False, + custom_rules=None, + cell_code="def hello():\n print('Hello, world!')", + selected_text="print('Hello, world!')", + other_cell_codes=None, + context=None, + ) + + result += _header("with other_cell_codes") + result += get_refactor_or_insert_notebook_cell_system_prompt( + language="python", + is_insert=False, + support_multiple_cells=False, + custom_rules=None, + cell_code="pl.DataFrame()", + selected_text="pl.DataFrame()", + other_cell_codes="import pandas as pd\nimport numpy as np", + context=None, + ) + + result += _header("with VariableContext objects") + result += get_refactor_or_insert_notebook_cell_system_prompt( + language="python", + is_insert=False, + support_multiple_cells=False, + custom_rules=None, + cell_code=None, + selected_text=None, + other_cell_codes=None, + context=AiCompletionContext( + variables=[ + VariableContext( + name="df", + value_type="DataFrame", + preview_value="", + ), + VariableContext( + name="model", + value_type="Model", + preview_value="", + ), + ] + ), + ) + + result += _header("with support_multiple_cells=True") + result += get_refactor_or_insert_notebook_cell_system_prompt( + language="python", + is_insert=False, + support_multiple_cells=True, + custom_rules=None, + cell_code=None, + selected_text=None, + other_cell_codes=None, + context=AiCompletionContext( + variables=[ + VariableContext( + name="df", + value_type="DataFrame", + preview_value="", + ), + VariableContext( + name="model", + value_type="Model", + preview_value="", + ), + ] + ), ) snapshot("system_prompts.txt", result) def test_empty_rules(): - assert Prompter.get_system_prompt( - language="python" - ) == Prompter.get_system_prompt( + assert get_refactor_or_insert_notebook_cell_system_prompt( language="python", + is_insert=False, + support_multiple_cells=False, + custom_rules=None, + cell_code=None, + selected_text=None, + other_cell_codes=None, + context=None, + ) == get_refactor_or_insert_notebook_cell_system_prompt( + language="python", + is_insert=False, + support_multiple_cells=False, custom_rules=" ", + cell_code=None, + selected_text=None, + other_cell_codes=None, + context=None, ) -def test_user_prompts(): - prompt = "Create a pandas dataframe" +def test_edit_inline_prompts(): + result = get_inline_system_prompt(language="python") + snapshot("edit_inline_prompts.txt", result) + assert FIM_SUFFIX_TAG in result + +def test_chat_system_prompts(): result: str = "" - result += _header("no code") - result += Prompter(code="").get_prompt( - user_prompt=prompt, include_other_code="" + result += _header("no custom rules") + result += get_chat_system_prompt( + custom_rules=None, + include_other_code="", + mode="manual", + session_id=SessionId("s_test"), # stable fake session id for snapshot ) - result += _header("with code") - result += Prompter( - code="df = pd.DataFrame({'a': [1, 2, 3], 'b': [4, 5, 6]})", - ).get_prompt(user_prompt=prompt, include_other_code="") + result += _header("with custom rules") + result += get_chat_system_prompt( + custom_rules="Always be polite.", + include_other_code="", + mode="manual", + session_id=SessionId("s_test"), + ) - result += _header("with code and other code") - result += Prompter( - code="df = pd.DataFrame({'a': [1, 2, 3], 'b': [4, 5, 6]})", - ).get_prompt( - user_prompt=prompt, + result += _header("with other code") + result += get_chat_system_prompt( + custom_rules=None, include_other_code="import pandas as pd\nimport numpy as np\n", + mode="manual", + session_id=SessionId("s_test"), ) - result += _header("with just other code") - result += Prompter( - code="", - ).get_prompt( - user_prompt=prompt, + result += _header("with agent mode") + result += get_chat_system_prompt( + custom_rules=None, + include_other_code="", + mode="agent", + session_id=SessionId("s_test"), + ) + + result += _header("kitchen sink") + result += get_chat_system_prompt( + custom_rules="Always be polite.", include_other_code="import pandas as pd\nimport numpy as np\n", + mode="manual", + session_id=SessionId("s_test"), ) - result += _header("with context") - result += Prompter(code="import pandas as pd").get_prompt( - user_prompt=prompt, include_other_code="import marimo as mo" + snapshot("chat_system_prompts.txt", result) + + +def test_markdown_rules_include_latex(): + from marimo._server.ai.prompts import ( + get_chat_system_prompt, + get_refactor_or_insert_notebook_cell_system_prompt, ) - snapshot("user_prompts.txt", result) + # Test refactor prompt + refactor_prompt = get_refactor_or_insert_notebook_cell_system_prompt( + language="markdown", + is_insert=False, + support_multiple_cells=False, + custom_rules=None, + cell_code=None, + selected_text=None, + other_cell_codes=None, + context=None, + ) + assert "double dollar signs ($$)" in refactor_prompt + assert "$$E=mc^2$$" in refactor_prompt + assert "Do NOT use single dollar signs" in refactor_prompt + # Test chat prompt + chat_prompt = get_chat_system_prompt( + custom_rules=None, + include_other_code="", + mode="manual", + session_id=SessionId("test"), + ) + assert "double dollar signs ($$)" in chat_prompt + assert "$$E=mc^2$$" in chat_prompt + assert "Do NOT use single dollar signs" in chat_prompt -def test_chat_system_prompts(): - result: str = "" - result += _header("no custom rules") - result += Prompter.get_chat_system_prompt() - result += _header("with custom rules") - result += Prompter.get_chat_system_prompt(custom_rules="Always be polite.") +def test_format_variables(): + """Test the _format_variables function.""" + # Test empty variables + assert _format_variables(None) == "" + assert _format_variables([]) == "" - result += _header("with variables") - result += Prompter.get_chat_system_prompt(variables=["var1", "var2"]) + variables = [ + "var1", + VariableContext( + name="df", + value_type="DataFrame", + preview_value="", + ), + "var2", + ] + expected = ( + "\n\n## Available variables from other cells:\n" + "- variable: `var1`" + "- variable: `df`\n" + " - value_type: DataFrame\n" + " - value_preview: \n" + "- variable: `var2`" + ) + assert _format_variables(variables) == expected - result += _header("with context") - result += Prompter.get_chat_system_prompt( - context=AiCompletionContext( - schema=[ - SchemaTable( - name="df_1", - columns=[ - SchemaColumn( - "age", "int", sample_values=["1", "2", "3"] - ), - SchemaColumn( - "name", - "str", - sample_values=["Alice", "Bob", "Charlie"], - ), - ], - ), - SchemaTable( - name="d2_2", - columns=[ - SchemaColumn( - "a", "int", sample_values=["1", "2", "3"] - ), - SchemaColumn( - "b", "int", sample_values=["4", "5", "6"] - ), - ], - ), - ], - ) + # Test private variables + variables = [ + "var1", + "_private_var", + VariableContext( + name="df", + value_type="DataFrame", + preview_value="", + ), + VariableContext( + name="_private_df", + value_type="DataFrame", + preview_value="", + ), + ] + expected = ( + "\n\n## Available variables from other cells:\n" + "- variable: `var1`" + "- variable: `df`\n" + " - value_type: DataFrame\n" + " - value_preview: \n" ) + assert _format_variables(variables) == expected - result += _header("with other code") - result += Prompter.get_chat_system_prompt( - include_other_code="import pandas as pd\nimport numpy as np\n" + +def test_format_plain_text(): + assert _format_plain_text("") == "" + assert _format_plain_text(" ") == "" + assert ( + _format_plain_text("Hello, world!") + == "If the prompt mentions @kind://name, use the following context to help you answer the question:\n\nHello, world!" ) - result += _header("kitchen sink") - result += Prompter.get_chat_system_prompt( + +def test_format_schema_info(): + assert _format_schema_info(None) == "" + assert _format_schema_info([]) == "" + + result = _format_schema_info( + [ + SchemaTable( + name="df_1", + columns=[ + SchemaColumn("age", "int", sample_values=["1", "2"]), + SchemaColumn("name", "str", sample_values=[]), + ], + ) + ] + ) + assert "## Available schema:" in result + assert "- Table: df_1" in result + assert "- Column: age" in result + assert "- Type: int" in result + assert "- Sample values: 1, 2" in result + # Column without sample values omits the sample line + assert "- Column: name" in result + assert "Sample values: \n" not in result + + +@pytest.mark.parametrize("mode", ["manual", "ask", "agent", "code_mode"]) +def test_mode_intro_messages_share_base(mode: CopilotMode): + message = _get_mode_intro_message(mode) + assert "You are Marimo Copilot" in message + assert "reactive programming model" in message + + +def test_common_chat_sections_empty(): + assert ( + _common_chat_sections(custom_rules=None, include_other_code="") == "" + ) + # Whitespace-only custom rules are treated as empty. + assert ( + _common_chat_sections(custom_rules=" ", include_other_code="") == "" + ) + + +def test_common_chat_sections_full(): + result = _common_chat_sections( + custom_rules="Be concise.", + include_other_code="import polars as pl", + ) + assert "## Additional rules:\nBe concise." in result + assert "" in result + assert "import polars as pl" in result + + +def test_chat_system_prompt_code_mode(): + prompt = get_chat_system_prompt( + custom_rules=None, + include_other_code="", + mode="code_mode", + session_id=SessionId("s_test"), + ) + # Code mode uses its own intro and embeds the marimo-pair skill. + assert _get_mode_intro_message("code_mode") in prompt + assert "how to work with marimo" in prompt + assert load_skill("marimo-pair") in prompt + assert "load_capability" in prompt + assert "`gotchas`" in prompt + assert "references/gotchas.md" not in prompt + + +def test_chat_system_prompt_code_mode_includes_extras(): + prompt = get_chat_system_prompt( custom_rules="Always be polite.", - variables=["var1", "var2"], - include_other_code="import pandas as pd\nimport numpy as np\n", - context=AiCompletionContext( - schema=[ - SchemaTable( - name="df_1", - columns=[ - SchemaColumn( - "age", "int", sample_values=["1", "2", "3"] - ), - SchemaColumn( - "name", - "str", - sample_values=["Alice", "Bob", "Charlie"], - ), - ], - ), - ], - ), + include_other_code="import pandas as pd\n", + mode="code_mode", + session_id=SessionId("s_test"), ) + assert "## Additional rules:\nAlways be polite." in prompt + # Code mode inspects code via tools, so other-cell code is not inlined. + assert "" not in prompt + assert "import pandas as pd" not in prompt - snapshot("chat_system_prompts.txt", result) + +def test_chat_system_prompt_non_code_mode_includes_session_info(): + for mode in ("manual", "ask", "agent"): + prompt = get_chat_system_prompt( + custom_rules=None, + include_other_code="", + mode=cast(CopilotMode, mode), + session_id=SessionId("s_abc"), + ) + assert "Current notebook session ID: s_abc" in prompt + assert "Your goal is to do one of the following two things" in prompt + # The marimo-pair skill is only embedded in code mode. + assert "how to work with marimo notebooks" not in prompt + + +def test_chat_system_prompt_agent_mode_inserts_cell_rules(): + agent_prompt = get_chat_system_prompt( + custom_rules=None, + include_other_code="", + mode="agent", + session_id=SessionId("s_test"), + ) + manual_prompt = get_chat_system_prompt( + custom_rules=None, + include_other_code="", + mode="manual", + session_id=SessionId("s_test"), + ) + # Agent mode adds guidance for inserting cells; manual mode does not. + assert "## Rules for inserting cells:" in agent_prompt + assert 'mo.md(f"""{content}""")' in agent_prompt + assert "## Rules for inserting cells:" not in manual_prompt diff --git a/tests/_server/ai/test_providers.py b/tests/_server/ai/test_providers.py new file mode 100644 index 00000000000..ab41747ba98 --- /dev/null +++ b/tests/_server/ai/test_providers.py @@ -0,0 +1,1033 @@ +"""Tests for the LLM providers in marimo._server.ai.providers.""" + +import os +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from marimo._config.config import AiConfig +from marimo._dependencies.dependencies import Dependency, DependencyManager +from marimo._server.ai.config import AnyProviderConfig +from marimo._server.ai.ids import AiModelId +from marimo._server.ai.providers import ( + AnthropicProvider, + AzureOpenAIProvider, + BedrockProvider, + CustomProvider, + GoogleProvider, + OpenAIProvider, + StreamOptions, + _infer_provider_name_from_base_url, + _normalize_base_url, + get_completion_provider, +) +from marimo._server.ai.tracing import SpanInfo + + +@pytest.mark.parametrize( + ("model_name", "provider_name"), + [ + pytest.param("gpt-4", "openai", id="openai"), + pytest.param("claude-3-opus-20240229", "anthropic", id="anthropic"), + pytest.param("gemini-1.5-flash", "google", id="google"), + pytest.param( + "bedrock/anthropic.claude-3-sonnet-20240229", + "bedrock", + id="bedrock", + ), + pytest.param("openrouter/openai/gpt-4", "openrouter", id="openrouter"), + ], +) +def test_anyprovider_for_model(model_name: str, provider_name: str) -> None: + """Test that the correct config is returned for a given model.""" + ai_config = AiConfig( + open_ai={ + "model": model_name, + "api_key": "openai-key", + }, + anthropic={ + "api_key": "anthropic-key", + }, + google={ + "api_key": "google-key", + }, + bedrock={ + "profile_name": "aws-profile", + }, + openrouter={ + "api_key": "openrouter-key", + }, + ) + config = AnyProviderConfig.for_model(model_name, ai_config) + + if provider_name != "bedrock": + assert config.api_key == f"{provider_name}-key" + else: + assert config.api_key == "profile:aws-profile" + + +@pytest.mark.parametrize( + ("model_name", "provider_type", "dependency"), + [ + pytest.param("gpt-4", OpenAIProvider, None, id="openai"), + pytest.param( + "claude-3-opus-20240229", + AnthropicProvider, + DependencyManager.anthropic, + id="anthropic", + ), + pytest.param( + "gemini-1.5-flash", + GoogleProvider, + DependencyManager.google_ai, + id="google", + ), + pytest.param( + "bedrock/anthropic.claude-3-sonnet-20240229", + BedrockProvider, + DependencyManager.boto3, + id="bedrock", + ), + pytest.param( + "openrouter/openai/gpt-4", CustomProvider, None, id="openrouter" + ), + ], +) +def test_get_completion_provider( + model_name: str, provider_type: type, dependency: Dependency | None +) -> None: + """Test that the correct provider is returned for a given model.""" + + if not DependencyManager.pydantic_ai.has(): + pytest.skip("requires pydantic_ai") + + if dependency and not dependency.has(): + pytest.skip(f"{dependency.pkg} is not installed") + + if provider_type == BedrockProvider: + # For Bedrock, we pass bedrock-required details through the config + config = AnyProviderConfig( + api_key="aws_access_key_id:aws_secret_access_key", # credentials + base_url="us-east-1", # region name + ) + else: + config = AnyProviderConfig( + api_key="test-key", base_url="http://test-url" + ) + provider = get_completion_provider(config, model_name) + assert isinstance(provider, provider_type) + + +@pytest.mark.requires("pydantic_ai") +async def test_azure_openai_provider() -> None: + """Test that Azure OpenAI provider uses correct parameters.""" + config = AnyProviderConfig( + api_key="test-key", + base_url="https://test.openai.azure.com/openai/deployments/gpt-4-1?api-version=2023-05-15", + ) + provider = AzureOpenAIProvider("gpt-4", config) + + api_version, deployment_name, endpoint = provider._handle_azure_openai( + "https://test.openai.azure.com/openai/deployments/gpt-4-1?api-version=2023-05-15" + ) + assert api_version == "2023-05-15" + assert deployment_name == "gpt-4-1" + assert endpoint == "https://test.openai.azure.com" + + api_version, deployment_name, endpoint = provider._handle_azure_openai( + "https://unknown_domain.openai/openai/deployments/gpt-4-1?api-version=2023-05-15" + ) + assert api_version == "2023-05-15" + assert deployment_name == "gpt-4-1" + assert endpoint == "https://unknown_domain.openai" + + +@pytest.mark.skipif( + not DependencyManager.anthropic.has() + or not DependencyManager.pydantic_ai.has(), + reason="anthropic or pydantic_ai not installed", +) +def test_anthropic_process_part_text_file() -> None: + """Test Anthropic converts text file parts to text parts.""" + from pydantic_ai.ui.vercel_ai.request_types import FileUIPart, TextUIPart + + config = AnyProviderConfig(api_key="test-key", base_url="http://test") + provider = AnthropicProvider("claude-3-opus-20240229", config) + + # Test text file conversion - base64 encoded "Hello, World!" + text_file_part = FileUIPart( + type="file", + media_type="text/plain", + url="data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==", + filename="test.txt", + ) + result = provider.process_part(text_file_part) + assert isinstance(result, TextUIPart) + assert result.text == "Hello, World!" + + # Test image file is not converted + image_file_part = FileUIPart( + type="file", + media_type="image/png", + url="data:image/png;base64,iVBORw0KGgo=", + filename="test.png", + ) + result = provider.process_part(image_file_part) + assert isinstance(result, FileUIPart) + assert result.media_type == "image/png" + + +@pytest.mark.parametrize( + ("provider_kind", "model_name", "base_url", "expected_thinking"), + [ + # OpenAI: profile drives the decision via supports_thinking. + pytest.param("openai", "o1-mini", None, True, id="openai_o1_mini"), + pytest.param( + "openai", + "o1-preview", + "https://api.openai.com/v1", + True, + id="openai_o1_preview_official_url", + ), + pytest.param("openai", "o3", None, True, id="openai_o3"), + pytest.param("openai", "o3-mini", None, True, id="openai_o3_mini"), + pytest.param("openai", "gpt-5", None, True, id="openai_gpt5"), + pytest.param( + "openai", "gpt-4", None, False, id="openai_gpt4_no_thinking" + ), + pytest.param( + "openai", "gpt-4o", None, False, id="openai_gpt4o_no_thinking" + ), + # Custom base URL (litellm/vLLM/Together/etc.) suppresses thinking + # even when the model name looks like a reasoning model: third-party + # endpoints often don't accept `reasoning_effort`. + pytest.param( + "openai", + "o1-mini", + "https://custom.api.com/v1", + False, + id="openai_o1_custom_base_url", + ), + pytest.param( + "openai", + "gpt-5", + "https://litellm.proxy.com/api/v1", + False, + id="openai_gpt5_litellm_proxy", + ), + # Azure: thinking is always suppressed (only custom Azure deployments + # support reasoning_effort, which we don't expose yet). + pytest.param( + "azure", + "o1-mini", + "https://my.openai.azure.com/openai/deployments/o1-mini?api-version=2024-12-01-preview", + False, + id="azure_o1_mini", + ), + pytest.param( + "azure", + "gpt-5", + "https://my.openai.azure.com/openai/deployments/gpt-5?api-version=2024-12-01-preview", + False, + id="azure_gpt5", + ), + ], +) +@pytest.mark.requires("pydantic_ai") +def test_openai_default_thinking( + provider_kind: str, + model_name: str, + base_url: str | None, + expected_thinking: bool, +) -> None: + """The base url heuristic + pydantic-ai's profile drive the on/off decision. + + `openai_reasoning_summary` rides on the same profile-driven path: it is + set iff `thinking` is, so we never send it to non-reasoning models or to + custom OpenAI-compatible endpoints that wouldn't accept it. + """ + config = AnyProviderConfig(api_key="test-key", base_url=base_url) + provider: OpenAIProvider = ( + AzureOpenAIProvider(model_name, config) + if provider_kind == "azure" + else OpenAIProvider(model_name, config) + ) + model = provider.create_model() + settings = provider._build_model_settings(model, max_tokens=512) + + has_thinking = settings is not None and settings.get("thinking") is True + has_summary = ( + settings is not None and "openai_reasoning_summary" in settings + ) + assert has_thinking == expected_thinking + assert has_summary == expected_thinking + + +@pytest.mark.parametrize( + ( + "model_name", + "expected_model_settings", + "expected_agent_thinking", + ), + [ + pytest.param( + "claude-opus-4-7", + # Opus 4.7 disallows sampling settings, so no temperature. + {"max_tokens": 1024, "anthropic_cache": True, "thinking": True}, + True, + id="opus_4_7_adaptive_no_sampling", + ), + pytest.param( + "claude-opus-4-6", + {"max_tokens": 1024, "anthropic_cache": True, "thinking": True}, + True, + id="opus_4_6", + ), + pytest.param( + "claude-sonnet-4-6", + {"max_tokens": 1024, "anthropic_cache": True, "thinking": True}, + True, + id="sonnet_4_6", + ), + pytest.param( + "claude-opus-4-5-20251101", + {"max_tokens": 1024, "anthropic_cache": True, "thinking": True}, + True, + id="opus_4_5", + ), + pytest.param( + "claude-3-7-sonnet-20250219", + {"max_tokens": 1024, "anthropic_cache": True, "thinking": True}, + True, + id="sonnet_3_7", + ), + # NOTE: pydantic-ai's profile reports `supports_thinking=True` for all + # Anthropic models — even 3.5 — so by trusting it we end up enabling + # thinking on 3.5 too. The Anthropic API will reject thinking for 3.5 + # at request time. We accept that trade-off in exchange for not + # maintaining our own per-model gate; if pydantic-ai's profile gets + # corrected upstream, behavior here will follow automatically. + pytest.param( + "claude-3-5-sonnet-20241022", + {"max_tokens": 1024, "anthropic_cache": True, "thinking": True}, + True, + id="sonnet_3_5_trusts_profile", + ), + ], +) +@pytest.mark.skipif( + not DependencyManager.anthropic.has() + or not DependencyManager.pydantic_ai.has(), + reason="anthropic or pydantic_ai not installed", +) +def test_anthropic_settings_split( + model_name: str, + expected_model_settings: dict[str, Any], + expected_agent_thinking: bool, +) -> None: + """Verify request settings carry Anthropic max tokens/cache/thinking.""" + config = AnyProviderConfig(api_key="test-key", base_url=None) + provider = AnthropicProvider(model_name, config) + model = provider.create_model() + model_settings = provider._build_model_settings(model, max_tokens=1024) + assert dict(model_settings) == expected_model_settings + + actual_thinking = ( + model_settings is not None and model_settings.get("thinking") is True + ) + assert actual_thinking == expected_agent_thinking + + +@pytest.mark.parametrize( + ("model_name", "expected_payload_kind"), + [ + # Adaptive-only / adaptive-supported models route to {'type': 'adaptive'}. + pytest.param("claude-opus-4-7", "adaptive", id="opus_4_7_adaptive"), + pytest.param("claude-opus-4-6", "adaptive", id="opus_4_6_adaptive"), + pytest.param( + "claude-sonnet-4-6", "adaptive", id="sonnet_4_6_adaptive" + ), + # Older models route to {'type': 'enabled', 'budget_tokens': N}. + pytest.param( + "claude-opus-4-5-20251101", "enabled", id="opus_4_5_manual" + ), + pytest.param( + "claude-3-7-sonnet-20250219", "enabled", id="sonnet_3_7_manual" + ), + ], +) +@pytest.mark.skipif( + not DependencyManager.anthropic.has() + or not DependencyManager.pydantic_ai.has(), + reason="anthropic or pydantic_ai not installed", +) +def test_anthropic_thinking_payload_translation( + model_name: str, expected_payload_kind: str +) -> None: + """End-to-end: per-model Anthropic API payload via pydantic-ai's profile. + + Opus 4.7 is the critical case here: it only accepts `{"type": "adaptive"}` + and rejects `{"type": "enabled", "budget_tokens": ...}` with HTTP 400. + """ + from pydantic_ai.models import ModelRequestParameters + from pydantic_ai.models.anthropic import ( + AnthropicModel, + AnthropicModelSettings, + ) + + config = AnyProviderConfig(api_key="test-key", base_url=None) + provider = AnthropicProvider(model_name, config) + model = provider.create_model() + assert isinstance(model, AnthropicModel) + + settings = provider._build_model_settings(model, max_tokens=1024) + prepared_settings, prepared_params = model.prepare_request( + settings, ModelRequestParameters() + ) + payload = model._translate_thinking( # type: ignore[attr-defined] + cast("AnthropicModelSettings", prepared_settings or {}), + prepared_params, + ) + if expected_payload_kind == "adaptive": + assert payload == {"type": "adaptive"} + else: + assert payload["type"] == "enabled" + assert payload["budget_tokens"] > 0 + + +@pytest.mark.parametrize( + ( + "api_key", + "environment", + "expected_provider", + "expected_kwargs", + ), + [ + pytest.param( + "test-key", + {"GOOGLE_GENAI_USE_VERTEXAI": "true"}, + "google", + {"api_key": "test-key"}, + id="api_key", + ), + pytest.param( + "", + {"GOOGLE_API_KEY": "environment-key"}, + "google", + {}, + id="default_google", + ), + pytest.param( + "", + { + "GOOGLE_GENAI_USE_VERTEXAI": "true", + "GOOGLE_CLOUD_PROJECT": "test-project", + "GOOGLE_CLOUD_LOCATION": "europe-west1", + }, + "google-cloud", + { + "project": "test-project", + "location": "europe-west1", + }, + id="vertex", + ), + pytest.param( + "", + { + "GOOGLE_GENAI_USE_VERTEXAI": "true", + "GOOGLE_CLOUD_PROJECT": "test-project", + }, + "google-cloud", + { + "project": "test-project", + "location": None, + }, + id="vertex_default_location", + ), + ], +) +@pytest.mark.skipif( + not DependencyManager.google_ai.has() + or not DependencyManager.pydantic_ai.has(), + reason="google or pydantic_ai not installed", +) +def test_google_provider_selection( + api_key: str, + environment: dict[str, str], + expected_provider: str, + expected_kwargs: dict[str, str | None], +) -> None: + """Route API-key and Vertex configs to their matching providers.""" + with ( + patch.dict(os.environ, environment, clear=True), + patch("pydantic_ai.providers.google.GoogleProvider") as mock_google, + patch( + "pydantic_ai.providers.google_cloud.GoogleCloudProvider" + ) as mock_google_cloud, + ): + provider = GoogleProvider( + "gemini-2.5-flash", + AnyProviderConfig(api_key=api_key, base_url=None), + ) + + if expected_provider == "google": + mock_google.assert_called_once_with(**expected_kwargs) + mock_google_cloud.assert_not_called() + assert provider.provider is mock_google.return_value + else: + mock_google.assert_not_called() + mock_google_cloud.assert_called_once_with(**expected_kwargs) + assert provider.provider is mock_google_cloud.return_value + + +@pytest.mark.parametrize( + ("model_name", "expected_thinking"), + [ + pytest.param("gemini-3-pro-preview", True, id="gemini_3_pro"), + pytest.param("gemini-2.5-pro", True, id="gemini_2_5_pro"), + pytest.param( + "gemini-2.0-flash", False, id="gemini_2_0_flash_not_thinking" + ), + ], +) +@pytest.mark.skipif( + not DependencyManager.google_ai.has() + or not DependencyManager.pydantic_ai.has(), + reason="google or pydantic_ai not installed", +) +def test_google_default_thinking( + model_name: str, expected_thinking: bool +) -> None: + """Google's profile correctly distinguishes thinking vs non-thinking models.""" + config = AnyProviderConfig(api_key="test-key", base_url=None) + provider = GoogleProvider(model_name, config) + model = provider.create_model() + settings = provider._build_model_settings(model, max_tokens=512) + actual = settings is not None and settings.get("thinking") is True + assert actual == expected_thinking + + +@pytest.mark.requires("pydantic_ai") +async def test_completion_does_not_pass_redundant_instructions() -> None: + from pydantic_ai.messages import ModelResponse, TextPart + from pydantic_ai.models.openai import OpenAIResponsesModel + + config = AnyProviderConfig(api_key="test-key", base_url="http://test-url") + provider = OpenAIProvider("gpt-4", config) + + with ( + patch("marimo._server.ai.providers.get_tool_manager") as mock_get_tm, + patch.object( + OpenAIResponsesModel, "request", new_callable=AsyncMock + ) as mock_request, + ): + mock_get_tm.return_value = MagicMock() + mock_request.return_value = ModelResponse( + parts=[TextPart(content="test")] + ) + + await provider.completion( + messages=[], + system_prompt="Test prompt", + max_tokens=100, + additional_tools=[], + span_info=SpanInfo(endpoint="completion", model="openai/gpt-4"), + ) + + mock_request.assert_called_once() + request_messages = mock_request.call_args.args[0] + + assert len(request_messages) == 1 + # The bug caused instructions to be "Test prompt\nTest prompt" + instructions = request_messages[0].instructions + + # This asserts the duplication is gone + assert instructions == "Test prompt" + + +@pytest.mark.requires("pydantic_ai") +async def test_completion_tool_count_includes_capabilities() -> None: + """`completion` reports tools plus the agent's native capabilities, so its + telemetry matches the streaming paths.""" + config = AnyProviderConfig(api_key="test-key", base_url="http://test-url") + provider = OpenAIProvider("gpt-4", config) + + agent = MagicMock(name="agent") + agent.root_capability.capabilities = [MagicMock(), MagicMock()] + result = MagicMock() + result.output = "hi" + agent.run = AsyncMock(return_value=result) + + span_info = SpanInfo(endpoint="completion", model="openai/gpt-4") + + with patch.object(provider, "create_agent", return_value=agent): + await provider.completion( + messages=[], + system_prompt="x", + max_tokens=100, + additional_tools=[MagicMock(name="tool")], + span_info=span_info, + ) + + # 1 additional tool + 2 capabilities. + assert span_info.tool_count == 3 + + +@pytest.mark.skipif( + not DependencyManager.anthropic.has() + or not DependencyManager.pydantic_ai.has(), + reason="anthropic or pydantic_ai not installed", +) +def test_anthropic_applies_default_floor_when_max_tokens_none() -> None: + """When no max_tokens is configured, Anthropic still receives 32768.""" + from marimo._server.ai.constants import ANTHROPIC_DEFAULT_MAX_TOKENS + + config = AnyProviderConfig(api_key="test-key", base_url=None) + provider = AnthropicProvider("claude-sonnet-4-5", config) + model = provider.create_model() + settings = provider._build_model_settings(model, max_tokens=None) + assert dict(settings).get("max_tokens") == ANTHROPIC_DEFAULT_MAX_TOKENS + + +@pytest.mark.skipif( + not DependencyManager.anthropic.has() + or not DependencyManager.pydantic_ai.has(), + reason="anthropic or pydantic_ai not installed", +) +def test_anthropic_override_wins_over_default_floor() -> None: + """An explicit max_tokens overrides the Anthropic default floor.""" + config = AnyProviderConfig(api_key="test-key", base_url=None) + provider = AnthropicProvider("claude-sonnet-4-5", config) + model = provider.create_model() + settings = provider._build_model_settings(model, max_tokens=12345) + assert dict(settings).get("max_tokens") == 12345 + + +@pytest.mark.requires("pydantic_ai") +def test_openai_chat_omits_max_tokens_when_none() -> None: + """Non-Anthropic providers omit max_tokens entirely when not set, so + pydantic-ai falls through to the upstream provider's default.""" + config = AnyProviderConfig(api_key="test-key", base_url="http://test-url") + provider = OpenAIProvider("gpt-4", config) + model = provider.create_model() + settings = provider._build_model_settings(model, max_tokens=None) + assert "max_tokens" not in dict(settings or {}) + + +@pytest.mark.requires("pydantic_ai") +def test_openai_chat_passes_explicit_max_tokens() -> None: + """Non-Anthropic providers pass through an explicit max_tokens.""" + config = AnyProviderConfig(api_key="test-key", base_url="http://test-url") + provider = OpenAIProvider("gpt-4", config) + model = provider.create_model() + settings = provider._build_model_settings(model, max_tokens=12345) + assert dict(settings or {}).get("max_tokens") == 12345 + + +@pytest.mark.requires("pydantic_ai") +def test_custom_provider_agent_passes_explicit_max_tokens() -> None: + """Custom providers pass explicit max_tokens through agent settings.""" + config = AnyProviderConfig( + api_key="test-key", base_url="https://my.internal.llm/v1" + ) + provider = CustomProvider( + AiModelId.from_model("my_provider/my-model"), config + ) + model = provider.create_model() + settings = provider._build_model_settings(model, max_tokens=12345) + assert dict(settings).get("max_tokens") == 12345 + + +@pytest.mark.requires("pydantic_ai") +def test_custom_provider_agent_omits_max_tokens_when_none() -> None: + """The chat path omits max_tokens from agent model_settings when unset.""" + config = AnyProviderConfig(api_key="test-key", base_url="http://test-url") + provider = get_completion_provider(config, "openrouter/openai/gpt-4") + with patch("marimo._server.ai.providers.get_tool_manager") as mock_get_tm: + mock_get_tm.return_value = MagicMock() + agent = provider.create_agent( + name="test", max_tokens=None, tools=[], system_prompt="x" + ) + settings = cast("dict[str, Any]", agent.model_settings or {}) + assert "max_tokens" not in settings + + +@pytest.mark.parametrize( + ("base_url", "expected"), + [ + pytest.param(None, None, id="none"), + pytest.param("", None, id="empty"), + pytest.param( + "https://api.deepseek.com", "api.deepseek.com", id="https" + ), + pytest.param( + "http://api.deepseek.com/", "api.deepseek.com", id="http_trailing" + ), + pytest.param( + "https://api.deepseek.com/v1/", + "api.deepseek.com", + id="strip_v1", + ), + pytest.param( + " https://API.DeepSeek.com/v1 ", + "api.deepseek.com", + id="whitespace_and_case", + ), + pytest.param( + "https://openrouter.ai/api/v1", + "openrouter.ai/api", + id="path_before_v1", + ), + pytest.param( + "https://models.github.ai/inference", + "models.github.ai/inference", + id="path_without_v1", + ), + pytest.param( + "https://api.x.ai/V1", + "api.x.ai", + id="uppercase_v1_suffix", + ), + pytest.param( + "https://generativelanguage.googleapis.com/v1beta", + "generativelanguage.googleapis.com/v1beta", + id="v1beta_not_stripped", + ), + ], +) +def test_normalize_base_url( + base_url: str | None, expected: str | None +) -> None: + assert _normalize_base_url(base_url) == expected + + +@pytest.mark.requires("pydantic_ai") +@pytest.mark.parametrize( + ("base_url", "expected"), + [ + pytest.param("https://api.deepseek.com", "deepseek", id="deepseek"), + pytest.param( + "https://api.deepseek.com/v1/", "deepseek", id="deepseek_v1" + ), + pytest.param( + "https://api.moonshot.ai/v1", "moonshotai", id="moonshot" + ), + pytest.param( + "https://openrouter.ai/api/v1/", "openrouter", id="openrouter" + ), + # Hosts not discovered from pydantic-ai's providers -> no match, so we + # fall back to the generic OpenAI provider (preserving prior behavior). + # `api.openai.com` is LiteLLM's client-derived default, which we skip. + pytest.param("https://my.internal.llm/v1", None, id="unknown_host"), + pytest.param("https://api.openai.com/v1", None, id="openai_host"), + pytest.param(None, None, id="no_base_url"), + ], +) +def test_infer_provider_name_from_base_url( + base_url: str | None, expected: str | None +) -> None: + assert _infer_provider_name_from_base_url(base_url) == expected + + +@pytest.mark.requires("pydantic_ai") +def test_custom_provider_inherits_profile_from_base_url() -> None: + """A custom provider whose name we don't recognize, but whose base URL + points at DeepSeek, inherits DeepSeek's profile so `reasoning_content` + round-trips. Regression test for #9786.""" + config = AnyProviderConfig( + api_key="test-key", base_url="https://api.deepseek.com" + ) + provider = CustomProvider( + AiModelId.from_model("deepseek_official/deepseek-v4-flash"), config + ) + + # The unknown name was resolved to the known `deepseek` provider. + assert provider._provider_name == "deepseek" + assert provider.provider.name == "deepseek" + + model = provider.create_model() + profile = model.profile + if isinstance(profile, dict): + assert profile.get("openai_chat_thinking_field") == "reasoning_content" + assert profile.get("openai_chat_send_back_thinking_parts") == "field" + else: + assert profile.openai_chat_thinking_field == "reasoning_content" + assert profile.openai_chat_send_back_thinking_parts == "field" + + +@pytest.mark.requires("pydantic_ai") +def test_custom_provider_unknown_base_url_stays_generic() -> None: + """An unknown name with an unrecognized base URL falls back to the generic + OpenAI provider (no thinking field), preserving prior behavior.""" + config = AnyProviderConfig( + api_key="test-key", base_url="https://my.internal.llm/v1" + ) + provider = CustomProvider( + AiModelId.from_model("my_provider/my-model"), config + ) + + assert provider._provider_name == "my_provider" + assert provider.provider.name == "openai" + + model = provider.create_model() + profile = model.profile + if isinstance(profile, dict): + assert profile.get("openai_chat_thinking_field") is None + else: + assert profile.openai_chat_thinking_field is None + + +@pytest.mark.requires("pydantic_ai") +def test_custom_provider_create_model_infers_from_registry() -> None: + """`create_model` resolves the model through pydantic-ai's registry using + the `provider:model` id so we get the provider-tuned model class.""" + config = AnyProviderConfig(api_key="test-key", base_url="http://test-url") + provider = get_completion_provider(config, "openrouter/openai/gpt-4") + assert isinstance(provider, CustomProvider) + + sentinel = MagicMock(name="inferred-model") + with patch( + "pydantic_ai.models.infer_model", return_value=sentinel + ) as mock_infer: + model = provider.create_model() + + assert model is sentinel + assert mock_infer.call_args.args[0] == "openrouter:openai/gpt-4" + + +@pytest.mark.requires("pydantic_ai") +def test_custom_provider_known_name_not_overridden_by_base_url() -> None: + """A recognized provider name is used as-is; the base URL never overrides + it (so e.g. an OpenRouter config pointed at DeepSeek keeps OpenRouter).""" + config = AnyProviderConfig( + api_key="test-key", base_url="https://api.deepseek.com" + ) + provider = CustomProvider( + AiModelId.from_model("openrouter/some-model"), config + ) + assert provider._provider_name == "openrouter" + + +@pytest.mark.requires("pydantic_ai") +async def test_stream_completion_harness_wires_execute_code_toolset() -> None: + """The code-mode harness builds an agent with the execute_code toolset, + passes the system prompt as instructions, and returns the adapter's + streaming response.""" + config = AnyProviderConfig(api_key="test-key", base_url="http://test-url") + provider = OpenAIProvider("gpt-4", config) + + session = MagicMock(name="session") + request = MagicMock(name="request") + toolset = MagicMock(name="toolset") + streaming_response = MagicMock(name="streaming_response") + adapter: MagicMock = MagicMock(name="adapter") + adapter.streaming_response = MagicMock(return_value=streaming_response) + stream_options = StreamOptions( + span_info=SpanInfo(endpoint="chat", model="openai/gpt-4"), + ) + + def build_mock_agent(*_args: Any, **kwargs: Any) -> MagicMock: + # `tool_count` reads back the agent's aggregated capabilities, so the + # mock must expose the capabilities it was constructed with. + agent = MagicMock(name="agent") + agent.root_capability.capabilities = kwargs.get("capabilities", []) + return agent + + with ( + patch.object(provider, "create_model", return_value=MagicMock()), + patch.object(provider, "_build_model_settings", return_value={}), + # Isolate from provider-adaptive web tools (covered separately) so the + # only capabilities are the three references capabilities. + patch.object(provider, "_build_agent_capabilities", return_value=[]), + patch.object(provider, "convert_messages", return_value=[]), + patch( + "marimo._server.ai.tools.code_mode.build_execute_code_toolset", + return_value=toolset, + ) as mock_build_toolset, + patch("pydantic_ai.Agent", side_effect=build_mock_agent) as mock_agent, + patch( + "pydantic_ai.ui.vercel_ai.VercelAIAdapter", + return_value=adapter, + ), + ): + result = await provider.stream_completion_harness( + messages=[], + system_prompt="SYSTEM PROMPT WITH SKILL", + session=session, + request=request, + max_tokens=1234, + stream_options=stream_options, + ) + + assert result is streaming_response + assert stream_options.span_info.tool_count == 4 + # The toolset is bound to the caller's session and request. + mock_build_toolset.assert_called_once_with(session, request) + + # The agent is constructed with that toolset and the system prompt as + # instructions (which now carries the marimo-pair skill). + agent_kwargs = mock_agent.call_args.kwargs + assert agent_kwargs["toolsets"] == [toolset] + assert agent_kwargs["instructions"] == "SYSTEM PROMPT WITH SKILL" + capabilities = agent_kwargs["capabilities"] + assert len(capabilities) == 3 + assert {capability.id for capability in capabilities} == { + "gotchas", + "notebook-improvements", + "rich-representations", + } + assert all(capability.defer_loading for capability in capabilities) + + +@pytest.mark.requires("pydantic_ai") +def test_create_custom_provider_passes_supported_credentials() -> None: + """`_create_custom_provider` constructs the resolved provider class, + passing only the credentials its constructor accepts.""" + config = AnyProviderConfig(api_key="test-key", base_url="https://x/v1") + provider = get_completion_provider(config, "openrouter/openai/gpt-4") + assert isinstance(provider, CustomProvider) + + captured: dict[str, Any] = {} + + class FakeProvider: + def __init__( + self, *, api_key: str | None = None, base_url: str | None = None + ) -> None: + captured["api_key"] = api_key + captured["base_url"] = base_url + + result = provider._create_custom_provider(FakeProvider, config) # type: ignore[arg-type] + assert isinstance(result, FakeProvider) + assert captured == {"api_key": "test-key", "base_url": "https://x/v1"} + + +@pytest.mark.requires("pydantic_ai") +def test_create_custom_provider_omits_unsupported_base_url() -> None: + """A provider whose constructor has no `base_url` parameter is built with + just the api key, even when the config carries a base URL.""" + config = AnyProviderConfig(api_key="test-key", base_url="https://x/v1") + provider = get_completion_provider(config, "openrouter/openai/gpt-4") + assert isinstance(provider, CustomProvider) + + captured: dict[str, Any] = {} + + class ApiKeyOnlyProvider: + def __init__(self, *, api_key: str | None = None) -> None: + captured["api_key"] = api_key + + result = provider._create_custom_provider(ApiKeyOnlyProvider, config) # type: ignore[arg-type] + assert isinstance(result, ApiKeyOnlyProvider) + assert captured == {"api_key": "test-key"} + + +@pytest.mark.requires("pydantic_ai") +def test_create_custom_provider_falls_back_to_openai_on_error() -> None: + """If constructing the provider raises, we fall back to a generic + OpenAI-compatible provider rather than propagating the error.""" + from pydantic_ai.providers.openai import ( + OpenAIProvider as PydanticOpenAI, + ) + + config = AnyProviderConfig(api_key="test-key", base_url="https://x/v1") + provider = get_completion_provider(config, "openrouter/openai/gpt-4") + assert isinstance(provider, CustomProvider) + + class BrokenProvider: + def __init__(self, *, api_key: str | None = None) -> None: + del api_key + raise RuntimeError("boom") + + result = provider._create_custom_provider(BrokenProvider, config) # type: ignore[arg-type] + assert isinstance(result, PydanticOpenAI) + + +@pytest.mark.requires("pydantic_ai") +def test_custom_provider_applies_openrouter_cache_settings() -> None: + """OpenRouter agents opt into prompt caching at the agent-settings level.""" + config = AnyProviderConfig(api_key="test-key", base_url="http://test-url") + provider = get_completion_provider(config, "openrouter/openai/gpt-4") + assert isinstance(provider, CustomProvider) + + model = provider.create_model() + settings = cast( + "dict[str, Any]", + provider._build_model_settings(model, max_tokens=100), + ) + + assert settings["openrouter_cache_instructions"] is True + assert settings["openrouter_cache_messages"] is True + assert settings["openrouter_cache_tool_definitions"] == "1h" + + +@pytest.mark.requires("pydantic_ai") +def test_custom_provider_non_openrouter_omits_cache_settings() -> None: + """Non-OpenRouter custom providers don't get OpenRouter-specific settings.""" + config = AnyProviderConfig( + api_key="test-key", base_url="https://my.internal.llm/v1" + ) + provider = CustomProvider( + AiModelId.from_model("my_provider/my-model"), config + ) + + model = provider.create_model() + settings = provider._build_model_settings(model, max_tokens=None) + + assert "openrouter_cache_instructions" not in settings + assert "max_tokens" not in settings + + +@pytest.mark.requires("pydantic_ai") +def test_build_agent_capabilities_from_native_tool_support() -> None: + """Provider-adaptive tools are enabled based on the model profile's + supported native tools when no local search/fetch deps are installed.""" + from pydantic_ai.native_tools import ( + WebFetchTool, + WebSearchTool, + XSearchTool, + ) + + config = AnyProviderConfig(api_key="test-key", base_url="http://test-url") + provider = OpenAIProvider("gpt-4", config) + + model = MagicMock(name="model") + model.profile.supported_native_tools = { + WebSearchTool, + WebFetchTool, + XSearchTool, + } + + with ( + patch.object( + DependencyManager.duckduckgo_search, "has", return_value=False + ), + patch.object(DependencyManager.markdownify, "has", return_value=False), + ): + capabilities = provider._build_agent_capabilities(model) + + assert sorted(type(c).__name__ for c in capabilities) == [ + "WebFetch", + "WebSearch", + "XSearch", + ] + + +@pytest.mark.requires("pydantic_ai") +def test_build_agent_capabilities_empty_without_support_or_deps() -> None: + """No capabilities are added when the model supports no native tools and + no local search/fetch deps are installed.""" + config = AnyProviderConfig(api_key="test-key", base_url="http://test-url") + provider = OpenAIProvider("gpt-4", config) + + model = MagicMock(name="model") + model.profile.supported_native_tools = set() + + with ( + patch.object( + DependencyManager.duckduckgo_search, "has", return_value=False + ), + patch.object(DependencyManager.markdownify, "has", return_value=False), + ): + capabilities = provider._build_agent_capabilities(model) + + assert capabilities == [] diff --git a/tests/_server/ai/test_tracing.py b/tests/_server/ai/test_tracing.py new file mode 100644 index 00000000000..f0b8d5c2aa3 --- /dev/null +++ b/tests/_server/ai/test_tracing.py @@ -0,0 +1,251 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from typing import TYPE_CHECKING, cast +from unittest.mock import patch + +import pytest + +from marimo._server.ai.tracing import ( + SpanInfo, + build_attributes, + trace_completion, + trace_stream, +) + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Sequence + + from opentelemetry.sdk.trace import ReadableSpan + from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + from opentelemetry.trace import Tracer + + +class _CollectingExporter: + """Minimal span exporter that collects spans in a list.""" + + def __init__(self) -> None: + self.spans: list[ReadableSpan] = [] + + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: + from opentelemetry.sdk.trace.export import SpanExportResult + + self.spans.extend(spans) + return SpanExportResult.SUCCESS + + def shutdown(self) -> None: + pass + + def force_flush(self, _timeout_millis: int = 0) -> bool: + return True + + +def _setup_tracing() -> tuple[Tracer, _CollectingExporter]: + """Build an isolated tracer backed by a collecting exporter.""" + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + + exporter = _CollectingExporter() + provider = TracerProvider() + # `cast` through `object` since the duck-typed exporter can't subclass the + # `SpanExporter` ABC (opentelemetry is an optional dependency). + provider.add_span_processor( + SimpleSpanProcessor(cast("SpanExporter", cast(object, exporter))) + ) + # Use the provider directly rather than the global tracer provider so each + # test is isolated and we don't mutate global OTel state. + return provider.get_tracer("marimo.server"), exporter + + +def _attributes(span: ReadableSpan) -> dict[str, object]: + return dict(span.attributes or {}) + + +async def _gen(*items: str) -> AsyncIterator[str]: + for item in items: + yield item + + +async def _failing_gen(*items: str) -> AsyncIterator[str]: + for item in items: + yield item + raise RuntimeError("boom") + + +class TestBuildAttributes: + def test_qualified_model_with_mode(self) -> None: + attrs = build_attributes( + SpanInfo(endpoint="chat", model="openai/gpt-4o", mode="manual") + ) + assert attrs == { + "marimo.ai.endpoint": "chat", + "marimo.ai.provider": "openai", + "marimo.ai.model": "gpt-4o", + "marimo.ai.mode": "manual", + } + + def test_without_mode_omits_mode_key(self) -> None: + attrs = build_attributes( + SpanInfo(endpoint="inline_completion", model="anthropic/claude-3") + ) + assert attrs == { + "marimo.ai.endpoint": "inline_completion", + "marimo.ai.provider": "anthropic", + "marimo.ai.model": "claude-3", + } + + def test_includes_optional_fields_when_set(self) -> None: + attrs = build_attributes( + SpanInfo( + endpoint="inline_completion", + model="openai/gpt-4o", + language="python", + session_id="session-123", + tool_count=3, + ) + ) + assert attrs == { + "marimo.ai.endpoint": "inline_completion", + "marimo.ai.provider": "openai", + "marimo.ai.model": "gpt-4o", + "marimo.ai.language": "python", + "marimo.ai.session_id": "session-123", + "marimo.ai.tool_count": 3, + } + + def test_tool_count_zero_is_recorded(self) -> None: + attrs = build_attributes( + SpanInfo(endpoint="chat", model="openai/gpt-4o", tool_count=0) + ) + assert attrs["marimo.ai.tool_count"] == 0 + + +@pytest.mark.requires("opentelemetry") +class TestTraceStream: + async def test_passthrough_when_tracing_disabled(self) -> None: + tracer, exporter = _setup_tracing() + span_info = SpanInfo(endpoint="chat", model="openai/gpt-4o") + + with ( + patch("marimo._config.settings.GLOBAL_SETTINGS.TRACING", False), + patch("marimo._server.ai.tracing.server_tracer", tracer), + ): + events = [e async for e in trace_stream(_gen("a", "b"), span_info)] + + assert events == ["a", "b"] + assert exporter.spans == [] + + async def test_passthrough_when_span_info_none(self) -> None: + tracer, exporter = _setup_tracing() + + with ( + patch("marimo._config.settings.GLOBAL_SETTINGS.TRACING", True), + patch("marimo._server.ai.tracing.server_tracer", tracer), + ): + events = [e async for e in trace_stream(_gen("a", "b"), None)] + + assert events == ["a", "b"] + assert exporter.spans == [] + + async def test_creates_span_with_attributes(self) -> None: + tracer, exporter = _setup_tracing() + span_info = SpanInfo( + endpoint="chat", model="openai/gpt-4o", mode="manual" + ) + + with ( + patch("marimo._config.settings.GLOBAL_SETTINGS.TRACING", True), + patch("marimo._server.ai.tracing.server_tracer", tracer), + ): + events = [e async for e in trace_stream(_gen("a", "b"), span_info)] + + assert events == ["a", "b"] + assert len(exporter.spans) == 1 + span = exporter.spans[0] + assert span.name == "marimo.ai.stream" + assert _attributes(span) == { + "marimo.ai.endpoint": "chat", + "marimo.ai.provider": "openai", + "marimo.ai.model": "gpt-4o", + "marimo.ai.mode": "manual", + } + + async def test_records_error_and_reraises(self) -> None: + from opentelemetry.trace import StatusCode + + tracer, exporter = _setup_tracing() + span_info = SpanInfo(endpoint="chat", model="openai/gpt-4o") + + with ( + patch("marimo._config.settings.GLOBAL_SETTINGS.TRACING", True), + patch("marimo._server.ai.tracing.server_tracer", tracer), + ): + with pytest.raises(RuntimeError, match="boom"): + async for _ in trace_stream(_failing_gen("a"), span_info): + pass + + assert len(exporter.spans) == 1 + span = exporter.spans[0] + assert span.status.status_code == StatusCode.ERROR + assert any(e.name == "exception" for e in span.events) + + +@pytest.mark.requires("opentelemetry") +class TestTraceCompletion: + def test_noop_when_tracing_disabled(self) -> None: + tracer, exporter = _setup_tracing() + span_info = SpanInfo( + endpoint="inline_completion", model="openai/gpt-4o" + ) + + with ( + patch("marimo._config.settings.GLOBAL_SETTINGS.TRACING", False), + patch("marimo._server.ai.tracing.server_tracer", tracer), + ): + with trace_completion(span_info): + pass + + assert exporter.spans == [] + + def test_creates_span_with_attributes(self) -> None: + tracer, exporter = _setup_tracing() + span_info = SpanInfo( + endpoint="inline_completion", model="openai/gpt-4o" + ) + + with ( + patch("marimo._config.settings.GLOBAL_SETTINGS.TRACING", True), + patch("marimo._server.ai.tracing.server_tracer", tracer), + ): + with trace_completion(span_info): + pass + + assert len(exporter.spans) == 1 + span = exporter.spans[0] + assert span.name == "marimo.ai.completion" + assert _attributes(span) == { + "marimo.ai.endpoint": "inline_completion", + "marimo.ai.provider": "openai", + "marimo.ai.model": "gpt-4o", + } + + def test_records_error_and_reraises(self) -> None: + from opentelemetry.trace import StatusCode + + tracer, exporter = _setup_tracing() + span_info = SpanInfo( + endpoint="inline_completion", model="openai/gpt-4o" + ) + + with ( + patch("marimo._config.settings.GLOBAL_SETTINGS.TRACING", True), + patch("marimo._server.ai.tracing.server_tracer", tracer), + ): + with pytest.raises(RuntimeError, match="kaboom"): + with trace_completion(span_info): + raise RuntimeError("kaboom") + + assert len(exporter.spans) == 1 + span = exporter.spans[0] + assert span.status.status_code == StatusCode.ERROR + assert any(e.name == "exception" for e in span.events) diff --git a/tests/_server/ai/tools/test_code_mode.py b/tests/_server/ai/tools/test_code_mode.py new file mode 100644 index 00000000000..82b68c6adf5 --- /dev/null +++ b/tests/_server/ai/tools/test_code_mode.py @@ -0,0 +1,88 @@ +# Copyright 2026 Marimo. All rights reserved. +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import cast +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +@pytest.mark.requires("pydantic_ai") +def test_build_execute_code_toolset_exposes_single_execute_code_tool() -> None: + from marimo._server.ai.tools.code_mode import build_execute_code_toolset + + toolset = build_execute_code_toolset(MagicMock(), MagicMock()) + + assert list(toolset.tools.keys()) == ["execute_code"] + tool = toolset.tools["execute_code"] + # The model is told how to use the tool via its description. + assert tool.description + assert "scratchpad" in tool.description + + +@pytest.mark.requires("pydantic_ai") +async def test_execute_code_tool_routes_to_scratchpad_with_credentials() -> ( + None +): + from marimo._server.ai.tools.code_mode import build_execute_code_toolset + + session = MagicMock() + request = MagicMock() + sentinel_result = MagicMock(name="CodeExecutionResult") + + with ( + patch( + "marimo._server.ai.tools.code_mode.get_code_mode_credentials", + return_value=("http://localhost:2718", "secret-token"), + ) as mock_creds, + patch( + "marimo._server.ai.tools.code_mode.run_scratchpad_code", + new_callable=AsyncMock, + return_value=sentinel_result, + ) as mock_run, + patch("marimo._server.ai.tools.code_mode.AppState") as mock_app_state, + ): + app_state_instance = cast(MagicMock, mock_app_state.return_value) + toolset = build_execute_code_toolset(session, request) + execute_code = cast( + Callable[[str], Awaitable[object]], + toolset.tools["execute_code"].function, + ) + + result = await execute_code("print('hi')") + + assert result is sentinel_result + # Credentials are derived from the bound request, not model input. + mock_creds.assert_called_once_with(app_state_instance, request) + mock_run.assert_awaited_once_with( + session, + request, + code="print('hi')", + server_url="http://localhost:2718", + auth_token="secret-token", + ) + + +@pytest.mark.requires("pydantic_ai") +def test_references_capability_exposes_deferred_reference_bundles() -> None: + from marimo._server.ai.skills.utils import load_reference + from marimo._server.ai.tools.code_mode import references_capability + + capabilities = references_capability() + by_id = {capability.id: capability for capability in capabilities} + + assert set(by_id) == { + "gotchas", + "notebook-improvements", + "rich-representations", + } + for capability in capabilities: + assert capability.defer_loading is True + assert capability.get_instructions() == [load_reference(capability.id)] + + assert "Name redefinition" in by_id["gotchas"].description + assert ( + "Improving, optimizing" in by_id["notebook-improvements"].description + ) + assert "Custom widgets" in by_id["rich-representations"].description diff --git a/tests/_server/ai/tools/test_tool_manager.py b/tests/_server/ai/tools/test_tool_manager.py new file mode 100644 index 00000000000..150e4e7b874 --- /dev/null +++ b/tests/_server/ai/tools/test_tool_manager.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import pytest +from starlette.applications import Starlette + +from marimo._ai._tools.tools_registry import SUPPORTED_BACKEND_AND_MCP_TOOLS +from marimo._server.ai.tools.tool_manager import ToolManager +from marimo._server.ai.tools.types import ToolCallResult +from tests._server.mocks import get_starlette_server_state_init + + +@pytest.fixture +def manager(): + app = Starlette() + get_starlette_server_state_init().apply(app.state) + manager = ToolManager(app) + assert len(manager._tools) == 0 # lazy init + return manager + + +def test_get_tools_for_mode(manager: ToolManager): + """Test getting tools filtered by mode.""" + + # Mock the config to disable MCP + tools = manager.get_tools_for_mode("ask") + + # Should have backend tools + assert len(tools) == len(SUPPORTED_BACKEND_AND_MCP_TOOLS) + + # All should be backend tools for ask mode + for tool in tools: + assert tool.source == "backend" + assert "ask" in tool.mode + + +async def test_invoke_tool_backend_success(manager: ToolManager): + """Test successful backend tool invocation.""" + + # Mock the config to disable MCP + result = await manager.invoke_tool("get_active_notebooks", {}) + + assert isinstance(result, ToolCallResult) + assert result.tool_name == "get_active_notebooks" + assert result.error is None + assert result.result is not None + + +async def test_invoke_tool_not_found(manager: ToolManager): + """Test invoking non-existent tool.""" + + result = await manager.invoke_tool("nonexistent_tool", {}) + + assert result.tool_name == "nonexistent_tool" + assert result.result is None + assert "not found" in result.error or result.error is None + + +async def test_invoke_tool_invalid_arguments(manager: ToolManager): + """Test invoking tool with invalid arguments.""" + + # Try to invoke with missing required arguments + result = await manager.invoke_tool("get_cell_runtime_data", {}) + + assert result.tool_name == "get_cell_runtime_data" + assert result.result is None + assert "Invalid arguments" in result.error or result.error is None + + +def test_validate_backend_tool_arguments(manager: ToolManager): + """Test argument validation for backend tools.""" + + # Test valid arguments + is_valid, error = manager._validate_backend_tool_arguments( + "get_cell_runtime_data", + {"session_id": "test", "cell_ids": ["cell1"]}, + ) + assert is_valid is True + assert error == "" + + # Test invalid argument (unknown key) + is_valid, error = manager._validate_backend_tool_arguments( + "get_cell_runtime_data", + {"session_id": "test", "bad_key": "value"}, + ) + assert is_valid is False + assert "Invalid arguments" in error + + +def test_get_tool(manager: ToolManager): + """Test getting tool by name.""" + + manager._init_backend_tools() + tools = manager.get_tools_for_mode("ask") + assert len(tools) > 0 + + # Get backend tool + tool = manager._get_tool("get_active_notebooks", source="backend") + assert tool is not None + assert tool.name == "get_active_notebooks" + assert tool.source == "backend" + + # Get non-existent tool + tool = manager._get_tool("nonexistent", source="backend") + assert tool is None + + +def test_backend_tools_validation(manager: ToolManager): + """Test validation for backend tools.""" + tools = manager._get_all_tools() + assert len(tools) > 0 + + backend_tools = [tool for tool in tools if tool.source == "backend"] + assert len(backend_tools) > 0 + + for tool in backend_tools: + assert tool.name + assert tool.description + assert tool.parameters + assert tool.source + assert tool.mode + + # Validation of none + is_valid, error = manager._validation_functions[tool.name]( + {"invalid": "argument"} + ) + assert is_valid is False, error diff --git a/tests/_server/api/endpoints/test_ai.py b/tests/_server/api/endpoints/test_ai.py index b82ed7f4ef5..d6c787a1531 100644 --- a/tests/_server/api/endpoints/test_ai.py +++ b/tests/_server/api/endpoints/test_ai.py @@ -1,18 +1,21 @@ -# Copyright 2024 Marimo. All rights reserved. +# Copyright 2026 Marimo. All rights reserved. from __future__ import annotations -import unittest -from contextlib import contextmanager from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, List -from unittest.mock import MagicMock, patch +from typing import TYPE_CHECKING, Any +from unittest.mock import AsyncMock, MagicMock, patch import pytest -from marimo._config.manager import UserConfigManager -from marimo._dependencies.dependencies import DependencyManager -from marimo._server.api.endpoints.ai import make_stream_response -from tests._server.conftest import get_user_config_manager +from marimo._server.ai.prompts import ( + FIM_MIDDLE_TAG, + FIM_PREFIX_TAG, + FIM_SUFFIX_TAG, +) +from marimo._server.ai.tools.types import ToolCallResult +from marimo._server.api.endpoints.ai import resolve_completion_messages +from marimo._server.models.completion import AiCompletionRequest +from tests._server.conftest import get_session_config_manager from tests._server.mocks import token_header, with_session if TYPE_CHECKING: @@ -24,10 +27,6 @@ **token_header("fake-token"), } -HAS_OPEN_AI_DEPS = DependencyManager.openai.has() -HAS_ANTHROPIC_DEPS = DependencyManager.anthropic.has() -HAS_GOOGLE_AI_DEPS = DependencyManager.google_ai.has() - # Anthropic @dataclass @@ -51,584 +50,1130 @@ class Delta: @dataclass class Choice: delta: Delta + finish_reason: str | None = None # OpenAI @dataclass class FakeChoices: - choices: List[Choice] + choices: list[Choice] + + +def _create_messages(prompt: str) -> list[dict[str, Any]]: + return [ + { + "role": "user", + "content": prompt, + "parts": [ + {"type": "text", "text": prompt}, + { + "type": "file", + "mediaType": "text/csv", + "url": "data:text/csv;base64,R29vZGJ5ZQ==", + }, + ], + }, + ] -@pytest.mark.skipif( - not HAS_OPEN_AI_DEPS, reason="optional dependencies not installed" -) +def _mock_stream_completion_response() -> Any: + from starlette.responses import StreamingResponse + + async def mock_stream() -> Any: + yield b"import pandas as pd" + + return StreamingResponse( + content=mock_stream(), + media_type="text/event-stream", + ) + + +def _assert_completion_prompt_in_messages( + mock_stream_completion: Any, prompt: str +) -> None: + mock_stream_completion.assert_called_once() + messages = mock_stream_completion.call_args.kwargs["messages"] + assert len(messages) == 1 + assert messages[0]["role"] == "user" + assert messages[0]["parts"][0]["text"] == prompt + + +@pytest.mark.requires("openai", "pydantic_ai") class TestOpenAiEndpoints: @staticmethod @with_session(SESSION_ID) - @patch("openai.OpenAI") def test_completion_without_token( - client: TestClient, openai_mock: Any + client: TestClient, ) -> None: - del openai_mock - user_config_manager = get_user_config_manager(client) + user_config_manager = get_session_config_manager(client) - with no_openai_config(user_config_manager): + with patch.object( + user_config_manager, + "get_config", + return_value=_no_openai_config(), + ): response = client.post( "/api/ai/completion", headers=HEADERS, json={ "prompt": "Help me create a dataframe", - "include_other_code": "", + "includeOtherCode": "", "code": "", }, ) assert response.status_code == 400, response.text - assert response.json() == {"detail": "OpenAI API key not configured"} + assert response.json() == { + "detail": "OpenAI API key not configured. Go to Settings > AI to configure." + } @staticmethod @with_session(SESSION_ID) - @patch("openai.OpenAI") + @patch("marimo._server.ai.providers.OpenAIProvider.stream_completion") def test_completion_without_code( - client: TestClient, openai_mock: Any + client: TestClient, mock_stream_completion: Any ) -> None: - user_config_manager = get_user_config_manager(client) + user_config_manager = get_session_config_manager(client) - oaiclient = MagicMock() - openai_mock.return_value = oaiclient - - oaiclient.chat.completions.create.return_value = [ - FakeChoices( - choices=[Choice(delta=Delta(content="import pandas as pd"))] - ) - ] + mock_stream_completion.return_value = ( + _mock_stream_completion_response() + ) - with openai_config(user_config_manager): + with patch.object( + user_config_manager, + "get_config", + return_value=_openai_config(), + ): response = client.post( "/api/ai/completion", headers=HEADERS, json={ "prompt": "Help me create a dataframe", - "include_other_code": "", + "includeOtherCode": "", "code": "", }, ) - assert response.status_code == 200, "nope" - # Assert the prompt it was called with - prompt = oaiclient.chat.completions.create.call_args.kwargs[ - "messages" - ][1]["content"] - assert prompt == ("Help me create a dataframe") + assert response.status_code == 200, response.text + _assert_completion_prompt_in_messages( + mock_stream_completion, "Help me create a dataframe" + ) @staticmethod @with_session(SESSION_ID) - @patch("openai.OpenAI") + @patch("marimo._server.ai.providers.OpenAIProvider.stream_completion") def test_completion_with_code( - client: TestClient, openai_mock: Any + client: TestClient, mock_stream_completion: Any ) -> None: - user_config_manager = get_user_config_manager(client) + user_config_manager = get_session_config_manager(client) - oaiclient = MagicMock() - openai_mock.return_value = oaiclient - - oaiclient.chat.completions.create.return_value = [ - FakeChoices( - choices=[Choice(delta=Delta(content="import pandas as pd"))] - ) - ] + mock_stream_completion.return_value = ( + _mock_stream_completion_response() + ) - with openai_config(user_config_manager): + with patch.object( + user_config_manager, + "get_config", + return_value=_openai_config(), + ): response = client.post( "/api/ai/completion", headers=HEADERS, json={ "prompt": "Help me create a dataframe", - "code": "import pandas as pd", - "include_other_code": "", + "code": "import pandas as pd", + "includeOtherCode": "", }, ) assert response.status_code == 200, response.text - # Assert the prompt it was called with - prompt = oaiclient.chat.completions.create.call_args.kwargs[ - "messages" - ][1]["content"] - assert prompt == ( - "Help me create a dataframe\n\n\nimport pandas as pd\n" + _assert_completion_prompt_in_messages( + mock_stream_completion, "Help me create a dataframe" ) @staticmethod @with_session(SESSION_ID) - @patch("openai.OpenAI") + @patch("marimo._server.ai.providers.OpenAIProvider.stream_completion") def test_completion_with_custom_model( - client: TestClient, openai_mock: Any + client: TestClient, mock_stream_completion: Any ) -> None: - user_config_manager = get_user_config_manager(client) + user_config_manager = get_session_config_manager(client) - oaiclient = MagicMock() - openai_mock.return_value = oaiclient + mock_stream_completion.return_value = ( + _mock_stream_completion_response() + ) - oaiclient.chat.completions.create.return_value = [ - FakeChoices( - choices=[Choice(delta=Delta(content="import pandas as pd"))] + with patch.object( + user_config_manager, + "get_config", + return_value=_openai_config_custom_model(), + ): + response = client.post( + "/api/ai/completion", + headers=HEADERS, + json={ + "prompt": "Help me create a dataframe", + "code": "import pandas as pd", + "includeOtherCode": "", + }, ) - ] + assert response.status_code == 200, response.text + mock_stream_completion.assert_called_once() + + @staticmethod + @with_session(SESSION_ID) + @patch("marimo._server.ai.providers.OpenAIProvider.stream_completion") + def test_completion_with_custom_base_url( + client: TestClient, mock_stream_completion: Any + ) -> None: + user_config_manager = get_session_config_manager(client) + + mock_stream_completion.return_value = ( + _mock_stream_completion_response() + ) - with openai_config_custom_model(user_config_manager): + with patch.object( + user_config_manager, + "get_config", + return_value=_openai_config_custom_base_url(), + ): response = client.post( "/api/ai/completion", headers=HEADERS, json={ "prompt": "Help me create a dataframe", - "code": "import pandas as pd", - "include_other_code": "", + "code": "import pandas as pd", + "includeOtherCode": "", }, ) assert response.status_code == 200, response.text - # Assert the model it was called with - model = oaiclient.chat.completions.create.call_args.kwargs["model"] - assert model == "gpt-marimo" + mock_stream_completion.assert_called_once() @staticmethod @with_session(SESSION_ID) - @patch("openai.OpenAI") - def test_completion_with_custom_base_url( - client: TestClient, openai_mock: Any + @patch("marimo._server.ai.providers.OpenAIProvider.completion") + def test_inline_completion( + client: TestClient, mock_completion: Any ) -> None: - user_config_manager = get_user_config_manager(client) + user_config_manager = get_session_config_manager(client) - oaiclient = MagicMock() - openai_mock.return_value = oaiclient + # Mock completion to return a string + mock_completion.return_value = "df = pd.DataFrame()" - oaiclient.chat.completions.create.return_value = [ - FakeChoices( - choices=[Choice(delta=Delta(content="import pandas as pd"))] + with patch.object( + user_config_manager, "get_config", return_value=_openai_config() + ): + response = client.post( + "/api/ai/inline_completion", + headers=HEADERS, + json={ + "prefix": "import pandas as pd\n", + "suffix": "\ndf.head()", + "language": "python", + }, ) - ] + assert response.status_code == 200, response.text + # Verify completion was called + mock_completion.assert_called_once() + # Assert the messages contain FIM format + call_kwargs = mock_completion.call_args.kwargs + messages = call_kwargs["messages"] + assert len(messages) == 1 + # Verify FIM format is used + assert messages[0].parts[0].text == ( + f"{FIM_PREFIX_TAG}import pandas as pd\n" + f"{FIM_SUFFIX_TAG}\ndf.head()" + f"{FIM_MIDDLE_TAG}" + ) + + @staticmethod + @with_session(SESSION_ID) + def test_inline_completion_without_token( + client: TestClient, + ) -> None: + user_config_manager = get_session_config_manager(client) - with openai_config_custom_base_url(user_config_manager): + with patch.object( + user_config_manager, "get_config", return_value=_no_openai_config() + ): response = client.post( - "/api/ai/completion", + "/api/ai/inline_completion", headers=HEADERS, json={ - "prompt": "Help me create a dataframe", - "code": "import pandas as pd", - "include_other_code": "", + "prefix": "import pandas as pd\n", + "suffix": "\ndf.head()", + "language": "python", + }, + ) + assert response.status_code == 400, response.text + assert response.json() == { + "detail": "OpenAI API key not configured. Go to Settings > AI to configure." + } + + @staticmethod + @with_session(SESSION_ID) + @patch("marimo._server.ai.providers.OpenAIProvider.completion") + def test_inline_completion_different_language( + client: TestClient, mock_completion: Any + ) -> None: + user_config_manager = get_session_config_manager(client) + + # Mock completion to return a string + mock_completion.return_value = "SELECT 1;" + + with patch.object( + user_config_manager, "get_config", return_value=_openai_config() + ): + response = client.post( + "/api/ai/inline_completion", + headers=HEADERS, + json={ + "prefix": "SELECT 1;", + "suffix": "\nSELECT 2;", + "language": "sql", }, ) assert response.status_code == 200, response.text - # Assert the base_url it was called with - base_url = openai_mock.call_args.kwargs["base_url"] - assert base_url == "https://my-openai-instance.com" + # Verify completion was called + mock_completion.assert_called_once() + # Assert the system prompt mentions SQL + call_kwargs = mock_completion.call_args.kwargs + assert "sql" in call_kwargs["system_prompt"].lower() -@pytest.mark.skipif( - not HAS_ANTHROPIC_DEPS, reason="optional dependencies not installed" -) +@pytest.mark.requires("anthropic", "pydantic_ai") class TestAnthropicAiEndpoints: @staticmethod @with_session(SESSION_ID) - @patch("anthropic.Client") def test_anthropic_completion_without_token( - client: TestClient, anthropic_mock: Any + client: TestClient, ) -> None: - del anthropic_mock - user_config_manager = get_user_config_manager(client) + user_config_manager = get_session_config_manager(client) - with no_anthropic_config(user_config_manager): + with patch.object( + user_config_manager, + "get_config", + return_value=_no_anthropic_config(), + ): response = client.post( "/api/ai/completion", headers=HEADERS, json={ "prompt": "Help me create a dataframe", - "include_other_code": "", + "includeOtherCode": "", "code": "", }, ) assert response.status_code == 400, response.text assert response.json() == { - "detail": "Anthropic API key not configured" + "detail": "Anthropic API key not configured. Go to Settings > AI to configure." } @staticmethod @with_session(SESSION_ID) - @patch("anthropic.Client") + @patch("marimo._server.ai.providers.AnthropicProvider.stream_completion") def test_anthropic_completion_with_code( - client: TestClient, anthropic_mock: Any + client: TestClient, stream_completion_mock: Any ) -> None: - user_config_manager = get_user_config_manager(client) - - anthropic_client = MagicMock() - anthropic_mock.return_value = anthropic_client + user_config_manager = get_session_config_manager(client) - anthropic_client.messages.create.return_value = [ - RawContentBlockDeltaEvent(TextDelta("import pandas as pd")) - ] + stream_completion_mock.return_value = ( + _mock_stream_completion_response() + ) - with anthropic_config(user_config_manager): + with patch.object( + user_config_manager, "get_config", return_value=_anthropic_config() + ): response = client.post( "/api/ai/completion", headers=HEADERS, json={ "prompt": "Help me create a dataframe", - "code": "import pandas as pd", - "include_other_code": "", + "code": "import pandas as pd", + "includeOtherCode": "", }, ) assert response.status_code == 200, response.text - # Assert the prompt it was called with - prompt: str = anthropic_client.messages.create.call_args.kwargs[ - "messages" - ][0]["content"] - assert prompt == ( - "Help me create a dataframe\n\n\nimport pandas as pd\n" + _assert_completion_prompt_in_messages( + stream_completion_mock, "Help me create a dataframe" ) + @staticmethod + @with_session(SESSION_ID) + @patch( + "marimo._server.ai.providers.AnthropicProvider.completion", + return_value=AsyncMock(), + ) + def test_anthropic_inline_completion( + client: TestClient, completion_mock: Any + ) -> None: + user_config_manager = get_session_config_manager(client) -@pytest.mark.skipif( - not HAS_GOOGLE_AI_DEPS, reason="optional dependencies not installed" -) + # Mock completion to return a string + completion_mock.return_value = "df = pd.DataFrame()" + + with patch.object( + user_config_manager, "get_config", return_value=_anthropic_config() + ): + response = client.post( + "/api/ai/inline_completion", + headers=HEADERS, + json={ + "prefix": "import pandas as pd\n", + "suffix": "\ndf.head()", + "language": "python", + }, + ) + assert response.status_code == 200, response.text + completion_mock.assert_called_once() + call_kwargs = completion_mock.call_args.kwargs + messages = call_kwargs["messages"] + assert len(messages) == 1 + assert messages[0].parts[0].text == ( + f"{FIM_PREFIX_TAG}import pandas as pd\n" + f"{FIM_SUFFIX_TAG}\ndf.head()" + f"{FIM_MIDDLE_TAG}" + ) + + +@pytest.mark.requires("google_ai", "pydantic_ai") class TestGoogleAiEndpoints: @staticmethod @with_session(SESSION_ID) - @patch("google.generativeai.GenerativeModel") + @patch("marimo._server.ai.providers.GoogleProvider.stream_completion") def test_google_ai_completion_with_code( - client: TestClient, google_ai_mock: Any + client: TestClient, stream_completion_mock: Any ) -> None: - user_config_manager = get_user_config_manager(client) - - google_client = MagicMock() - google_ai_mock.return_value = google_client + user_config_manager = get_session_config_manager(client) - google_client.predict.return_value = MagicMock( - text="import pandas as pd" + stream_completion_mock.return_value = ( + _mock_stream_completion_response() ) - with google_ai_config(user_config_manager): + config = { + "ai": { + "open_ai": {"model": "gemini-1.5-pro"}, + "google": {"api_key": "fake-key"}, + "models": { + "autocomplete_model": "google/gemini-1.5-pro-for-inline-completion", + }, + }, + } + + with patch.object( + user_config_manager, "get_config", return_value=config + ): response = client.post( "/api/ai/completion", headers=HEADERS, json={ "prompt": "Help me create a dataframe", - "code": "import pandas as pd", - "include_other_code": "", + "code": "import pandas as pd", + "includeOtherCode": "", }, ) assert response.status_code == 200, response.text - # Assert the prompt it was called with - prompt = google_client.generate_content.call_args.kwargs[ - "contents" - ] - assert prompt == ( - "Help me create a dataframe\n\n\nimport pandas as pd\n" + _assert_completion_prompt_in_messages( + stream_completion_mock, "Help me create a dataframe" ) @staticmethod @with_session(SESSION_ID) - @patch("google.generativeai.GenerativeModel") def test_google_ai_completion_without_token( - client: TestClient, google_ai_mock: Any + client: TestClient, ) -> None: - del google_ai_mock - user_config_manager = get_user_config_manager(client) + from marimo._server.ai.providers import PydanticProvider + + user_config_manager = get_session_config_manager(client) + + mock_provider = AsyncMock(spec=PydanticProvider) + mock_provider.stream_completion.return_value = ( + _mock_stream_completion_response() + ) - with no_google_ai_config(user_config_manager): + config = { + "ai": { + "open_ai": {"model": "gemini-1.5-pro"}, + "google": {"api_key": ""}, + }, + } + + with ( + patch.object( + user_config_manager, "get_config", return_value=config + ), + patch( + "marimo._server.api.endpoints.ai.get_completion_provider", + return_value=mock_provider, + ), + ): response = client.post( "/api/ai/completion", headers=HEADERS, json={ "prompt": "Help me create a dataframe", - "include_other_code": "", + "includeOtherCode": "", "code": "", }, ) - assert response.status_code == 400, response.text - assert response.json() == { - "detail": "Google AI API key not configured" - } + assert response.status_code == 200, response.text + _assert_completion_prompt_in_messages( + mock_provider.stream_completion, "Help me create a dataframe" + ) + + @staticmethod + @with_session(SESSION_ID) + @patch("marimo._server.ai.providers.GoogleProvider.completion") + def test_google_ai_inline_completion( + client: TestClient, completion_mock: Any + ) -> None: + user_config_manager = get_session_config_manager(client) + + # Mock completion to return a string + completion_mock.return_value = "df = pd.DataFrame()" + + with patch.object( + user_config_manager, "get_config", return_value=_google_ai_config() + ): + response = client.post( + "/api/ai/inline_completion", + headers=HEADERS, + json={ + "prefix": "import pandas as pd\n", + "suffix": "\ndf.head()", + "language": "python", + }, + ) + assert response.status_code == 200, response.text + # Assert the prompt it was called with + completion_mock.assert_called_once() + call_kwargs = completion_mock.call_args.kwargs + messages = call_kwargs["messages"] + assert len(messages) == 1 + assert messages[0].parts[0].text == ( + f"{FIM_PREFIX_TAG}import pandas as pd\n" + f"{FIM_SUFFIX_TAG}\ndf.head()" + f"{FIM_MIDDLE_TAG}" + ) -@contextmanager -def openai_config(config: UserConfigManager): - prev_config = config.get_config() - try: - config.save_config( - {"ai": {"open_ai": {"api_key": "fake-api", "model": ""}}} + +def _openai_config() -> dict[str, Any]: + return { + "ai": { + "open_ai": { + "api_key": "fake-api", + "model": "openai/some-openai-model", + }, + "models": { + "autocomplete_model": "gpt-marimo-for-inline-completion", + }, + }, + } + + +def _openai_config_custom_model(): + return { + "ai": { + "open_ai": { + "api_key": "fake-api", + "model": "gpt-marimo", + }, + "models": { + "autocomplete_model": "gpt-marimo-for-inline-completion", + }, + }, + } + + +def _openai_config_custom_base_url(): + return { + "ai": { + "open_ai": { + "api_key": "fake-api", + "base_url": "https://my-openai-instance.com", + "model": "openai/some-openai-model-with-base-url", + }, + "models": { + "autocomplete_model": "gpt-marimo-for-inline-completion", + }, + }, + } + + +def _no_openai_config(): + return { + "ai": { + "open_ai": {"api_key": "", "model": ""}, + "models": { + "autocomplete_model": "gpt-marimo-for-inline-completion", + }, + }, + } + + +def _no_anthropic_config(): + return { + "ai": { + "open_ai": {"model": "claude-3.5"}, + "anthropic": {"api_key": ""}, + "models": { + "autocomplete_model": "claude-3.5-for-inline-completion", + }, + }, + } + + +def _anthropic_config(): + return { + "ai": { + "open_ai": {"model": "claude-3.5"}, + "anthropic": {"api_key": "fake-key"}, + "models": { + "autocomplete_model": "anthropic/claude-3.5-for-inline-completion", + }, + }, + } + + +def _google_ai_config(): + return { + "ai": { + "open_ai": {"model": "gemini-1.5-pro"}, + "google": {"api_key": "fake-key"}, + "models": { + "autocomplete_model": "google/gemini-1.5-pro-for-inline-completion", + }, + }, + } + + +@pytest.mark.requires("openai", "pydantic_ai") +@with_session(SESSION_ID) +@patch("marimo._server.ai.providers.OpenAIProvider.stream_completion") +def test_chat_without_code( + client: TestClient, mock_stream_completion: Any +) -> None: + user_config_manager = get_session_config_manager(client) + + # Create a mock StreamingResponse + from starlette.responses import StreamingResponse + + async def mock_stream(): + yield b"Hello, how can I help you?" + + mock_response = StreamingResponse( + content=mock_stream(), + media_type="text/event-stream", + ) + mock_stream_completion.return_value = mock_response + + with patch.object( + user_config_manager, "get_config", return_value=_openai_config() + ): + response = client.post( + "/api/ai/chat", + headers=HEADERS, + json={ + "messages": _create_messages("Hello"), + "uiMessages": _create_messages("Hello"), + "model": "gpt-4-turbo", + "variables": [], + "includeOtherCode": "", + "context": {}, + "id": "123", + }, ) - yield - finally: - config.save_config(prev_config) - - -@contextmanager -def openai_config_custom_model(config: UserConfigManager): - prev_config = config.get_config() - try: - config.save_config( - { - "ai": { - "open_ai": { - "api_key": "fake-api", - "model": "gpt-marimo", - } - } - } + assert response.status_code == 200, response.text + # Verify stream_completion was called + mock_stream_completion.assert_called_once() + + +@pytest.mark.requires("openai", "pydantic_ai") +@with_session(SESSION_ID) +@patch("marimo._server.ai.providers.OpenAIProvider.stream_completion") +def test_chat_with_code( + client: TestClient, mock_stream_completion: Any +) -> None: + user_config_manager = get_session_config_manager(client) + + # Create a mock StreamingResponse + from starlette.responses import StreamingResponse + + async def mock_stream(): + yield b"import pandas as pd" + + mock_response = StreamingResponse( + content=mock_stream(), + media_type="text/event-stream", + ) + mock_stream_completion.return_value = mock_response + + with patch.object( + user_config_manager, "get_config", return_value=_openai_config() + ): + response = client.post( + "/api/ai/chat", + headers=HEADERS, + json={ + "messages": _create_messages("Help me create a dataframe"), + "uiMessages": _create_messages("Help me create a dataframe"), + "model": "gpt-4-turbo", + "variables": [], + "includeOtherCode": "import pandas as pd", + "context": {}, + "id": "123", + }, ) - yield - finally: - config.save_config(prev_config) - - -@contextmanager -def openai_config_custom_base_url(config: UserConfigManager): - prev_config = config.get_config() - try: - config.save_config( - { - "ai": { - "open_ai": { - "api_key": "fake-api", - "base_url": "https://my-openai-instance.com", - "model": "", - } - } - } + assert response.status_code == 200, response.text + # Verify stream_completion was called + mock_stream_completion.assert_called_once() + + +def _openai_config_code_mode() -> dict[str, Any]: + config: dict[str, Any] = _openai_config() + config["ai"]["mode"] = "code_mode" + return config + + +@pytest.mark.requires("openai", "pydantic_ai") +@with_session(SESSION_ID) +@patch("marimo._server.ai.providers.OpenAIProvider.stream_completion") +@patch("marimo._server.ai.providers.OpenAIProvider.stream_completion_harness") +def test_chat_code_mode_routes_to_harness( + client: TestClient, + mock_harness: MagicMock, + mock_stream_completion: MagicMock, +) -> None: + """In code mode the chat endpoint routes to the code-mode harness + (which can execute code) instead of the plain completion stream.""" + user_config_manager = get_session_config_manager(client) + + from starlette.responses import StreamingResponse + + async def mock_stream(): + yield b"done" + + mock_harness.return_value = StreamingResponse( + content=mock_stream(), + media_type="text/event-stream", + ) + + with patch.object( + user_config_manager, + "get_config", + return_value=_openai_config_code_mode(), + ): + response = client.post( + "/api/ai/chat", + headers=HEADERS, + json={ + "messages": _create_messages("Add a cell"), + "uiMessages": _create_messages("Add a cell"), + "model": "gpt-4-turbo", + "variables": [], + "includeOtherCode": "", + "context": {}, + "id": "123", + }, ) - yield - finally: - config.save_config(prev_config) - - -@contextmanager -def no_openai_config(config: UserConfigManager): - prev_config = config.get_config() - try: - config.save_config({"ai": {"open_ai": {"api_key": "", "model": ""}}}) - yield - finally: - config.save_config(prev_config) - - -@contextmanager -def no_anthropic_config(config: UserConfigManager): - prev_config = config.get_config() - try: - config.save_config( - { - "ai": { - "open_ai": {"model": "claude-3.5"}, - "anthropic": {"api_key": ""}, - } - } + + assert response.status_code == 200, response.text + mock_harness.assert_called_once() + mock_stream_completion.assert_not_called() + + # The harness receives the live session and the assembled system prompt + # (which embeds the marimo-pair skill; building it also exercises the + # skill-loading path). + kwargs = mock_harness.call_args.kwargs + assert kwargs["session"] is not None + assert "how to work with marimo" in kwargs["system_prompt"] + + +# Tool invocation tests (provider-agnostic) +class TestInvokeToolEndpoint: + """Tests for the /invoke_tool endpoint.""" + + @staticmethod + @with_session(SESSION_ID) + @patch("marimo._server.api.endpoints.ai.get_tool_manager") + def test_invoke_tool_success( + client: TestClient, mock_get_tool_manager: Any + ) -> None: + """Test successful tool invocation.""" + + # Mock the tool manager and its response + mock_tool_manager = MagicMock() + mock_get_tool_manager.return_value = mock_tool_manager + + # Mock successful tool result as a coroutine + async def mock_invoke_tool( + _tool_name: str, _arguments: dict + ) -> ToolCallResult: + return ToolCallResult( + tool_name="test_tool", + result={ + "message": "Tool executed successfully", + "data": [1, 2, 3], + }, + error=None, + ) + + mock_tool_manager.invoke_tool = mock_invoke_tool + + response = client.post( + "/api/ai/invoke_tool", + headers=HEADERS, + json={ + "toolName": "test_tool", + "arguments": {"param1": "value1", "param2": 42}, + }, ) - yield - finally: - config.save_config(prev_config) - - -@contextmanager -def anthropic_config(config: UserConfigManager): - prev_config = config.get_config() - try: - config.save_config( - { - "ai": { - "open_ai": {"model": "claude-3.5"}, - "anthropic": {"api_key": "fake-key"}, - } - } + + assert response.status_code == 200, response.text + response_data = response.json() + + # Verify response structure + assert response_data["success"] is True + assert response_data["toolName"] == "test_tool" + assert response_data["result"] == { + "message": "Tool executed successfully", + "data": [1, 2, 3], + } + assert response_data["error"] is None + + @staticmethod + @with_session(SESSION_ID) + @patch("marimo._server.api.endpoints.ai.get_tool_manager") + def test_invoke_tool_with_error( + client: TestClient, mock_get_tool_manager: Any + ) -> None: + """Test tool invocation with error.""" + + # Mock the tool manager and its response + mock_tool_manager = MagicMock() + mock_get_tool_manager.return_value = mock_tool_manager + + # Mock tool result with error as a coroutine + async def mock_invoke_tool( + _tool_name: str, _arguments: dict + ) -> ToolCallResult: + return ToolCallResult( + tool_name="failing_tool", + result=None, + error="Tool execution failed: Invalid parameter", + ) + + mock_tool_manager.invoke_tool = mock_invoke_tool + + response = client.post( + "/api/ai/invoke_tool", + headers=HEADERS, + json={ + "toolName": "failing_tool", + "arguments": {"invalid_param": "bad_value"}, + }, ) - yield - finally: - config.save_config(prev_config) - - -@contextmanager -def google_ai_config(config: UserConfigManager): - prev_config = config.get_config() - try: - config.save_config( - { - "ai": { - "open_ai": {"model": "gemini-1.5-pro"}, - "google": {"api_key": "fake-key"}, - } - } + + assert response.status_code == 200, response.text + response_data = response.json() + + # Verify response structure for error case + assert response_data["success"] is False + assert response_data["toolName"] == "failing_tool" + assert response_data["result"] is None + assert ( + response_data["error"] + == "Tool execution failed: Invalid parameter" ) - yield - finally: - config.save_config(prev_config) - - -@contextmanager -def no_google_ai_config(config: UserConfigManager): - prev_config = config.get_config() - try: - config.save_config( - { - "ai": { - "open_ai": {"model": "gemini-1.5-pro"}, - "google": {"api_key": ""}, - } - } + + @staticmethod + @with_session(SESSION_ID) + @patch("marimo._server.api.endpoints.ai.get_tool_manager") + def test_invoke_tool_not_found( + client: TestClient, mock_get_tool_manager: Any + ) -> None: + """Test tool invocation when tool doesn't exist.""" + + # Mock the tool manager and its response + mock_tool_manager = MagicMock() + mock_get_tool_manager.return_value = mock_tool_manager + + # Mock tool result for non-existent tool as a coroutine + async def mock_invoke_tool( + _tool_name: str, _arguments: dict + ) -> ToolCallResult: + return ToolCallResult( + tool_name="nonexistent_tool", + result=None, + error="Tool 'nonexistent_tool' not found. Available tools: get_server_debug_info", + ) + + mock_tool_manager.invoke_tool = mock_invoke_tool + + response = client.post( + "/api/ai/invoke_tool", + headers=HEADERS, + json={"toolName": "nonexistent_tool", "arguments": {}}, ) - yield - finally: - config.save_config(prev_config) + assert response.status_code == 200, response.text + response_data = response.json() -class TestStreamResponse(unittest.TestCase): - def simulate_stream(self, contents: List[str]) -> Any: - class MockContent: - def __init__(self, content: str) -> None: - self.content = content + # Verify response structure for not found case + assert response_data["success"] is False + assert response_data["toolName"] == "nonexistent_tool" + assert response_data["result"] is None + assert "not found" in response_data["error"] - class MockDelta: - def __init__(self, content: str) -> None: - self.delta = MockContent(content) + @staticmethod + @with_session(SESSION_ID) + @patch("marimo._server.api.endpoints.ai.get_tool_manager") + def test_invoke_tool_validation_error( + client: TestClient, mock_get_tool_manager: Any + ) -> None: + """Test tool invocation with validation error.""" + + # Mock the tool manager and its response + mock_tool_manager = MagicMock() + mock_get_tool_manager.return_value = mock_tool_manager + + # Mock tool result with validation error as a coroutine + async def mock_invoke_tool( + _tool_name: str, _arguments: dict + ) -> ToolCallResult: + return ToolCallResult( + tool_name="test_tool", + result=None, + error="Invalid arguments for tool 'test_tool': Missing required parameter 'required_param'", + ) + + mock_tool_manager.invoke_tool = mock_invoke_tool + + response = client.post( + "/api/ai/invoke_tool", + headers=HEADERS, + json={ + "toolName": "test_tool", + "arguments": {"optional_param": "value"}, + }, + ) - class MockChunk: - def __init__(self, content: str) -> None: - self.choices = [MockDelta(content)] + assert response.status_code == 200, response.text + response_data = response.json() - for content in contents: - yield MockChunk(content) + # Verify response structure for validation error + assert response_data["success"] is False + assert response_data["toolName"] == "test_tool" + assert response_data["result"] is None + assert "Invalid arguments" in response_data["error"] + assert "required_param" in response_data["error"] + + @staticmethod + @with_session(SESSION_ID) + @patch("marimo._server.api.endpoints.ai.get_tool_manager") + def test_invoke_tool_complex_arguments( + client: TestClient, mock_get_tool_manager: Any + ) -> None: + """Test tool invocation with complex argument types.""" + + # Mock the tool manager and its response + mock_tool_manager = MagicMock() + mock_get_tool_manager.return_value = mock_tool_manager + + # Mock successful tool result with complex data as a coroutine + async def mock_invoke_tool( + _tool_name: str, _arguments: dict + ) -> ToolCallResult: + return ToolCallResult( + tool_name="complex_tool", + result={ + "processed_data": [ + {"id": 1, "value": "a"}, + {"id": 2, "value": "b"}, + ], + "summary": {"total": 2, "success": True}, + "metadata": {"timestamp": "2024-01-01T00:00:00Z"}, + }, + error=None, + ) - def test_no_code_fence(self): - response = self.simulate_stream(["Hello, world!"]) - result = list(make_stream_response(response)) - assert result == ["Hello, world!"] + mock_tool_manager.invoke_tool = mock_invoke_tool + + complex_args = { + "string_param": "test string", + "number_param": 42, + "boolean_param": True, + "array_param": [1, 2, 3, "four"], + "object_param": { + "nested_string": "nested value", + "nested_number": 3.14, + "nested_array": ["a", "b", "c"], + }, + } - def test_single_complete_code_fence(self): - response = self.simulate_stream( - ["```python\nprint('Hello, world!')\n```"] + response = client.post( + "/api/ai/invoke_tool", + headers=HEADERS, + json={"toolName": "complex_tool", "arguments": complex_args}, ) - result = list(make_stream_response(response)) - assert result == ["print('Hello, world!')\n"] - - def test_code_fence_across_chunks(self): - response = self.simulate_stream( - [ - "```python\nprint('Hello,", - " world!')\n```", - ] + + assert response.status_code == 200, response.text + response_data = response.json() + + # Verify response structure + assert response_data["success"] is True + assert response_data["toolName"] == "complex_tool" + assert "processed_data" in response_data["result"] + assert "summary" in response_data["result"] + assert "metadata" in response_data["result"] + assert response_data["error"] is None + + @staticmethod + @with_session(SESSION_ID) + def test_invoke_tool_without_session(client: TestClient) -> None: + """Test tool invocation without valid session.""" + response = client.post( + "/api/ai/invoke_tool", + headers={ + "Authorization": "Bearer fake-token" + }, # No session header + json={"toolName": "test_tool", "arguments": {}}, ) - result = list(make_stream_response(response)) - assert result == [ - "print('Hello,", - " world!')\n", - ] - - def test_code_fence_across_more_chunks(self): - response = self.simulate_stream( - [ - "```", - "python", - "\nprint('Hello,", - " world!')\n", - "```", - ] + + # Should fail without proper session + assert response.status_code in [400, 401, 403], response.text + + +class TestMCPEndpoints: + """Tests for MCP status and refresh endpoints.""" + + @staticmethod + @with_session(SESSION_ID) + def test_mcp_status(client: TestClient) -> None: + """Test MCP status endpoint returns error when dependencies not installed.""" + response = client.get( + "/api/ai/mcp/status", + headers=HEADERS, ) - result = list(make_stream_response(response)) - assert result == ["print('Hello,", " world!')\n", ""] - - def test_multiple_code_fences(self): - response = self.simulate_stream( - [ - "```python\nprint('Hello',", - " 'world!')\n```", - "No code here", - "```sql\nSELECT * FROM users;\n```", - ] + + assert response.status_code == 200, response.text + data = response.json() + + # Should have required fields + assert "status" in data + assert "servers" in data + # Will likely error due to missing dependencies or no config + assert data["status"] in ["ok", "partial", "error"] + + @staticmethod + @with_session(SESSION_ID) + def test_mcp_refresh(client: TestClient) -> None: + """Test MCP refresh endpoint returns error when dependencies not installed.""" + response = client.post( + "/api/ai/mcp/refresh", + headers=HEADERS, ) - result = list(make_stream_response(response)) - assert result == [ - "print('Hello',", - " 'world!')\n", - "No code here", - "SELECT * FROM users;\n", - ] - - def test_nested_code_fences(self): - response = self.simulate_stream( - ["```python\nprint('```nested```')\n```"] + + assert response.status_code == 200, response.text + data = response.json() + + # Should have required fields + assert "success" in data + assert "servers" in data + # Will likely fail due to missing dependencies or no config + assert isinstance(data["success"], bool) + + @staticmethod + @with_session(SESSION_ID) + @patch("marimo._server.api.endpoints.ai.get_tool_manager") + def test_invoke_tool_empty_arguments( + client: TestClient, mock_get_tool_manager: Any + ) -> None: + """Test tool invocation with empty arguments.""" + + # Mock the tool manager and its response + mock_tool_manager = MagicMock() + mock_get_tool_manager.return_value = mock_tool_manager + + # Mock successful tool result with empty arguments + async def mock_invoke_tool( + tool_name: str, _arguments: dict + ) -> ToolCallResult: + return ToolCallResult( + tool_name=tool_name, + result={"message": "Tool executed with empty args"}, + error=None, + ) + + mock_tool_manager.invoke_tool = mock_invoke_tool + + response = client.post( + "/api/ai/invoke_tool", + headers=HEADERS, + json={ + "toolName": "test_tool", + "arguments": {}, # Empty arguments + }, ) - result = list(make_stream_response(response)) - assert result == ["print('```nested```')\n"] - - @staticmethod - @with_session(SESSION_ID) - @patch("openai.OpenAI") - def test_chat_without_code( - client: TestClient, openai_mock: Any - ) -> None: - user_config_manager = get_user_config_manager(client) - - oaiclient = MagicMock() - openai_mock.return_value = oaiclient - - oaiclient.chat.completions.create.return_value = [ - FakeChoices( - choices=[ - Choice( - delta=Delta(content="Hello, how can I help you?") - ) - ] - ) - ] - - with openai_config(user_config_manager): - response = client.post( - "/api/ai/chat", - headers=HEADERS, - json={ - "messages": [{"role": "user", "content": "Hello"}], - "model": "gpt-4-turbo", - "variables": [], - "include_other_code": "", - }, - ) - assert response.status_code == 200, response.text - # Assert the prompt it was called with - prompt = oaiclient.chat.completions.create.call_args.kwargs[ - "messages" - ][1]["content"] - assert prompt == "Hello" - - @staticmethod - @with_session(SESSION_ID) - @patch("openai.OpenAI") - def test_chat_with_code(client: TestClient, openai_mock: Any) -> None: - user_config_manager = get_user_config_manager(client) - - oaiclient = MagicMock() - openai_mock.return_value = oaiclient - - oaiclient.chat.completions.create.return_value = [ - FakeChoices( - choices=[ - Choice(delta=Delta(content="import pandas as pd")) - ] - ) - ] - - with openai_config(user_config_manager): - response = client.post( - "/api/ai/chat", - headers=HEADERS, - json={ - "messages": [ - { - "role": "user", - "content": "Help me create a dataframe", - } - ], - "model": "gpt-4-turbo", - "variables": [], - "include_other_code": "import pandas as pd", - }, - ) - assert response.status_code == 200, response.text - # Assert the prompt it was called with - prompt = oaiclient.chat.completions.create.call_args.kwargs[ - "messages" - ][1]["content"] - assert prompt == "Help me create a dataframe" + + assert response.status_code == 200, response.text + data = response.json() + assert data["toolName"] == "test_tool" + assert data["result"]["message"] == "Tool executed with empty args" + assert data["error"] is None + + @staticmethod + @with_session(SESSION_ID) + @patch("marimo._server.api.endpoints.ai.get_mcp_client") + def test_mcp_status_states( + client: TestClient, mock_get_client: Any + ) -> None: + """Test MCP status returns correct state based on server statuses.""" + from marimo._server.ai.mcp import MCPServerStatus + + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + # Test partial: some connected, some failed + mock_client.get_all_server_statuses.return_value = { + "server1": MCPServerStatus.CONNECTED, + "server2": MCPServerStatus.ERROR, + } + response = client.get("/api/ai/mcp/status", headers=HEADERS) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "partial" + assert "server2" in data["error"] + + # Test error: all failed + mock_client.get_all_server_statuses.return_value = { + "server1": MCPServerStatus.ERROR, + "server2": MCPServerStatus.DISCONNECTED, + } + response = client.get("/api/ai/mcp/status", headers=HEADERS) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "error" + + +def test_resolve_completion_messages_from_prompt() -> None: + body = AiCompletionRequest( + prompt="edit this", + include_other_code="", + code="x = 1", + ) + messages, support_multiple_cells = resolve_completion_messages(body) + assert support_multiple_cells is False + assert len(messages) == 1 + assert messages[0]["parts"][0]["text"] == "edit this" + + +def test_resolve_completion_messages_from_ui_messages() -> None: + ui_messages = _create_messages("hello") + body = AiCompletionRequest( + prompt="ignored", + include_other_code="", + code="", + ui_messages=ui_messages, + ) + messages, support_multiple_cells = resolve_completion_messages(body) + assert support_multiple_cells is True + assert messages == ui_messages diff --git a/tests/_server/api/endpoints/test_assets.py b/tests/_server/api/endpoints/test_assets.py index 1fbdd805cdf..389ef16c237 100644 --- a/tests/_server/api/endpoints/test_assets.py +++ b/tests/_server/api/endpoints/test_assets.py @@ -1,13 +1,36 @@ -# Copyright 2024 Marimo. All rights reserved. +# Copyright 2026 Marimo. All rights reserved. from __future__ import annotations -from tempfile import TemporaryDirectory +import json +import os +import shutil +import textwrap +from pathlib import Path from typing import TYPE_CHECKING, Any, cast +from unittest.mock import Mock, patch +import pytest + +from marimo._convert.common.filename import parse_title from marimo._server.api.deps import AppState -from marimo._server.api.utils import parse_title -from marimo._server.file_router import AppFileRouter -from tests._server.mocks import token_header, with_file_router +from marimo._server.api.endpoints.assets import ( + DEFAULT_NOTEBOOK_NAME, + _inject_service_worker, +) +from marimo._server.workspace import ( + DirectoryWorkspace, + EmptyWorkspace, + FixedFilesWorkspace, + SingleFileWorkspace, +) +from marimo._session.model import SessionMode +from marimo._utils.http import HTTPException +from marimo._utils.marimo_path import MarimoPath +from tests._server.mocks import ( + token_header, + with_workspace, + workspace_scope, +) if TYPE_CHECKING: from starlette.testclient import TestClient @@ -25,16 +48,19 @@ def test_index(client: TestClient) -> None: response = client.get("/", headers=token_header()) assert response.status_code == 200, response.text content = response.text - filename = session_manager.file_router.get_unique_file_key() + filename = session_manager.workspace.get_unique_file_key() title = parse_title(filename) assert f"" in content assert filename is not None assert filename in content - assert "{title}" in content + # Check for /public file service worker + assert "public-files-sw.js" in content + -@with_file_router(AppFileRouter.from_files([])) +@with_workspace(FixedFilesWorkspace([])) def test_index_when_empty(client: TestClient) -> None: # Login page response = client.get("/") # no header @@ -46,11 +72,11 @@ def test_index_when_empty(client: TestClient) -> None: assert response.status_code == 200, response.text content = response.text assert "" in content - assert "marimo" in content -@with_file_router(AppFileRouter.new_file()) +@with_workspace(EmptyWorkspace()) def test_index_when_new_file(client: TestClient) -> None: # Login page response = client.get("/") # no header @@ -62,21 +88,244 @@ def test_index_when_new_file(client: TestClient) -> None: assert response.status_code == 200, response.text content = response.text assert "" in content - assert "marimo" in content -TEMP_DIR = TemporaryDirectory() +def test_index_missing_assets_in_source_checkout_shows_build_hint( + client: TestClient, tmp_path: Path +) -> None: + source_root = tmp_path / "repo" + source_root.mkdir() + (source_root / "frontend").mkdir() + (source_root / "pyproject.toml").write_text("") + + missing_static_root = tmp_path / "missing_static" + missing_static_root.mkdir() + + with ( + patch("marimo._server.api.endpoints.assets.root", missing_static_root), + patch( + "marimo._server.api.endpoints.assets.marimo_package_path", + return_value=source_root / "marimo", + ), + ): + response = client.get("/", headers=token_header()) + + assert response.status_code == 500 + detail = response.json()["detail"] + assert "Did you run `make fe`?" in detail + assert "Restart marimo after building." in detail + + +def test_index_strips_access_token_query_param(client: TestClient) -> None: + # A valid `?access_token=` in the URL should 303 to the same path with + # the token removed, carrying a session cookie so the follow-up request + # is already authenticated. This prevents pre-execution XSS, Referer, + # or browser history from capturing the plaintext token. + response = client.get("/?access_token=fake-token", follow_redirects=False) + assert response.status_code == 303, response.text + assert response.headers["location"] == "/" + assert response.headers.get("referrer-policy") == "same-origin" + assert response.headers.get("x-content-type-options") == "nosniff" + # The session cookie must be set so the redirect target is authenticated + # without the query param. + set_cookie = response.headers.get("set-cookie", "") + assert "session" in set_cookie + + +def test_index_strips_access_token_preserves_other_params( + client: TestClient, +) -> None: + response = client.get( + "/?file=foo.py&access_token=fake-token&view-as=present", + follow_redirects=False, + ) + assert response.status_code == 303, response.text + location = response.headers["location"] + assert location.startswith("/") + assert "access_token" not in location + assert "file=foo.py" in location + assert "view-as=present" in location + + +def test_index_invalid_access_token_redirects_to_login( + client: TestClient, +) -> None: + # An invalid token must NOT trigger the token-strip redirect (which + # would imply the token was accepted). Instead, the auth guard should + # redirect the unauthenticated request to the login page. Following + # the redirect lands on the login HTML. + response = client.get("/?access_token=wrong-token", follow_redirects=False) + assert response.status_code in (302, 303), response.text + assert "login" in response.headers["location"].lower() + # Following the redirect lands on the login page. + followed = client.get("/?access_token=wrong-token") + assert followed.status_code == 200 + assert "Login" in followed.text + + +def test_index_unauthenticated_redirect_is_relative( + client: TestClient, +) -> None: + # Regression test for https://github.com/marimo-team/marimo/issues/9249. + # When a reverse proxy forwards an internal `Host` header, an absolute + # Location would send the browser to an unreachable internal address. + # The Location must be relative so the browser resolves it against the + # public URL it originally used. + response = client.get( + "/", + headers={"Host": "10.0.0.5:60830"}, + follow_redirects=False, + ) + assert response.status_code == 303, response.text + location = response.headers["location"] + # Must be relative — no scheme, no host. + assert location.startswith("/auth/login?"), location + assert "://" not in location + assert "10.0.0.5" not in location + + +def test_index_unauthenticated_redirect_preserves_next( + client: TestClient, +) -> None: + # The original path (and query) must round-trip through the redirect so + # the user lands where they were trying to go after logging in. + response = client.get( + "/?file=foo.py&view-as=present", + follow_redirects=False, + ) + assert response.status_code == 303, response.text + location = response.headers["location"] + assert location.startswith("/auth/login?"), location + # next= is percent-encoded; decoding it should yield the original path + # with its query string. + from urllib.parse import parse_qs, urlparse + parsed = urlparse(location) + next_value = parse_qs(parsed.query)["next"][0] + assert next_value == "/?file=foo.py&view-as=present" -@with_file_router(AppFileRouter.from_directory(TEMP_DIR.name)) -def test_index_with_directory(client: TestClient) -> None: + +def test_index_response_has_security_headers(client: TestClient) -> None: response = client.get("/", headers=token_header()) assert response.status_code == 200, response.text - content = response.text - assert "marimo" in content + assert response.headers.get("referrer-policy") == "same-origin" + assert response.headers.get("x-content-type-options") == "nosniff" + + +def test_index_with_directory(client: TestClient, tmp_path: Path) -> None: + with workspace_scope( + client, DirectoryWorkspace(str(tmp_path), include_markdown=False) + ): + response = client.get("/", headers=token_header()) + assert response.status_code == 200, response.text + content = response.text + assert "marimo" in content + + +def test_index_with_directory_run_mode( + client: TestClient, tmp_path: Path +) -> None: + app_state = AppState.from_app(cast(Any, client.app)) + app_state.session_manager.mode = SessionMode.RUN + + with workspace_scope( + client, DirectoryWorkspace(str(tmp_path), include_markdown=False) + ): + response = client.get("/", headers=token_header()) + assert response.status_code == 200, response.text + content = response.text + assert "marimo" in content + + +def test_index_with_directory_respects_inline_theme( + client: TestClient, tmp_path: Path +) -> None: + # Regression test for #10056: a directory workspace sends a relative file + # key, and its inline theme must still be applied. + notebook = tmp_path / "notebook.py" + notebook.write_text( + textwrap.dedent( + """ + # /// script + # [tool.marimo.display] + # theme = "dark" + # /// + + import marimo + + app = marimo.App() + + + @app.cell + def _(): + import marimo as mo + return + + + if __name__ == "__main__": + app.run() + """ + ).lstrip() + ) + + app_state = AppState.from_app(cast(Any, client.app)) + app_state.session_manager.mode = SessionMode.RUN + + with workspace_scope( + client, DirectoryWorkspace(str(tmp_path), include_markdown=False) + ): + response = client.get("/?file=notebook.py", headers=token_header()) + assert response.status_code == 200, response.text + assert '"theme": "dark"' in response.text + + +def test_config_manager_at_file_directory_keys( + client: TestClient, tmp_path: Path +) -> None: + # `config_manager_at_file` resolves relative directory-workspace keys + # before reading inline metadata, and must not swallow the workspace's + # path validation. + notebook = tmp_path / "notebook.py" + notebook.write_text( + textwrap.dedent( + """ + # /// script + # [tool.marimo.display] + # theme = "dark" + # /// + + import marimo + + app = marimo.App() + """ + ).lstrip() + ) + + app_state = AppState(cast(Any, Mock(app=client.app))) + + with workspace_scope( + client, DirectoryWorkspace(str(tmp_path), include_markdown=False) + ): + # Relative key resolves against the workspace and applies inline config. + resolved = app_state.config_manager_at_file("notebook.py").get_config() + assert resolved["display"]["theme"] == "dark" + + # A missing/unsaved file (404) yields base config, no inline overrides. + missing = app_state.config_manager_at_file( + "does_not_exist.py" + ).get_config() + assert missing["display"]["theme"] != "dark" + + # A rejected path (non-404, e.g. traversal) propagates rather than + # falling back to reading an unvalidated path. + with pytest.raises(HTTPException): + app_state.config_manager_at_file("../../../../etc/passwd") def test_favicon(client: TestClient) -> None: @@ -109,3 +358,566 @@ def test_vfile(client: TestClient) -> None: assert response.status_code == 404, response.text assert response.headers["content-type"] == "application/json" assert response.json() == {"detail": "Invalid virtual file request"} + + +@patch( + "marimo._server.api.endpoints.assets.GLOBAL_SETTINGS.DISABLE_AUTH_ON_VIRTUAL_FILES", + True, +) +def test_vfile_auth_disabled_allows_unauthenticated( + client: TestClient, +) -> None: + # Unauthenticated requests normally return 401, but the env flag + # lets them through. + response = client.get("/@file/empty.txt") + assert response.status_code == 200, response.text + assert response.content == b"" + + response = client.get("/@file/bad.txt") + assert response.status_code == 404, response.text + assert response.json() == {"detail": "Invalid virtual file request"} + + +@patch( + "marimo._server.api.endpoints.assets.GLOBAL_SETTINGS.DISABLE_AUTH_ON_VIRTUAL_FILES", + False, +) +def test_vfile_auth_enabled_rejects_unauthenticated( + client: TestClient, +) -> None: + # With the flag off (the default), unauthenticated requests are rejected. + response = client.get("/@file/empty.txt") + assert response.status_code == 401, response.text + + # Authenticated requests still work. + response = client.get("/@file/empty.txt", headers=token_header()) + assert response.status_code == 200, response.text + + +def test_vfile_large_streaming(client: TestClient) -> None: + """Regression test: large virtual files must stream without + Content-Length mismatch (h11 LocalProtocolError). + + See https://github.com/marimo-team/marimo/issues/8917 + """ + from marimo._runtime.virtual_file.storage import ( + InMemoryStorage, + VirtualFileStorageManager, + ) + + manager = VirtualFileStorageManager() + original_storage = manager.storage + storage = InMemoryStorage() + manager.storage = storage + + try: + # ~2 MB file, similar to a large anywidget ESM bundle + data = b"x" * (2 * 1024 * 1024) + filename = "test-large.js" + storage.store(filename, data) + byte_length = len(data) + + response = client.get( + f"/@file/{byte_length}-{filename}", + headers=token_header(), + ) + assert response.status_code == 200 + assert response.content == data + assert ( + response.headers.get("content-type") == "text/javascript" + or response.headers.get("content-type") == "application/javascript" + ) + # StreamingResponse must NOT set Content-Length to avoid h11 + # LocalProtocolError with large files + assert "content-length" not in response.headers + finally: + manager.storage = original_storage + + +def test_vfile_range_requests(client: TestClient) -> None: + """Virtual files must support HTTP Range requests so that Safari can + play media (audio/video) — Safari's