Repository: gradio-app/daggr Branch: main Commit: 73cca141ccda Files: 113 Total size: 657.4 KB Directory structure: gitextract_z_vlte2q/ ├── .agents/ │ └── skills/ │ └── daggr/ │ └── SKILL.md ├── .changeset/ │ ├── README.md │ ├── changeset.cjs │ ├── config.json │ └── fix_changelogs.cjs ├── .github/ │ ├── assets/ │ │ └── run-mode-dropdown-demo.webm │ ├── pull_request_template.md │ └── workflows/ │ ├── comment-queue.yml │ ├── format.yml │ ├── generate-changeset.yml │ ├── publish.yml │ ├── test.yml │ └── trigger-changeset.yml ├── .gitignore ├── CHANGELOG.md ├── CLAUDE.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── RELEASE.md ├── build_pypi.sh ├── daggr/ │ ├── CHANGELOG.md │ ├── __init__.py │ ├── _client_cache.py │ ├── _utils.py │ ├── cli.py │ ├── edge.py │ ├── executor.py │ ├── frontend/ │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── App.svelte │ │ │ ├── components/ │ │ │ │ ├── Audio.svelte │ │ │ │ ├── AudioPlayer.svelte │ │ │ │ ├── Button.svelte │ │ │ │ ├── Checkbox.svelte │ │ │ │ ├── CheckboxGroup.svelte │ │ │ │ ├── Code.svelte │ │ │ │ ├── ColorPicker.svelte │ │ │ │ ├── Dataframe.svelte │ │ │ │ ├── Dialogue.svelte │ │ │ │ ├── Dropdown.svelte │ │ │ │ ├── EmbeddedComponent.svelte │ │ │ │ ├── File.svelte │ │ │ │ ├── Gallery.svelte │ │ │ │ ├── HighlightedText.svelte │ │ │ │ ├── Html.svelte │ │ │ │ ├── Image.svelte │ │ │ │ ├── ImageSlider.svelte │ │ │ │ ├── ItemListSection.svelte │ │ │ │ ├── Json.svelte │ │ │ │ ├── Label.svelte │ │ │ │ ├── MapItemsSection.svelte │ │ │ │ ├── Markdown.svelte │ │ │ │ ├── Model3D.svelte │ │ │ │ ├── Number.svelte │ │ │ │ ├── Radio.svelte │ │ │ │ ├── Slider.svelte │ │ │ │ ├── Textbox.svelte │ │ │ │ ├── Video.svelte │ │ │ │ └── index.ts │ │ │ ├── main.ts │ │ │ └── types.ts │ │ ├── svelte.config.js │ │ └── vite.config.ts │ ├── graph.py │ ├── local_space.py │ ├── node.py │ ├── ops.py │ ├── package.json │ ├── port.py │ ├── py.typed │ ├── server.py │ ├── session.py │ └── state.py ├── examples/ │ ├── 01_quickstart.py │ ├── 02_voice_design_comparator_app.py │ ├── 03_mock_podcast_app.py │ ├── 04_complete_podcast_app.py │ ├── 05_local_translation_app.py │ ├── 06_pig_latin_voice_app.py │ ├── 07_image_to_3d_app.py │ ├── 08_text_to_3d_app.py │ ├── 09_slideshow_app.py │ ├── 10_real_podcast_app.py │ ├── 11_viral_content_generator_app.py │ ├── 12_ecommerce_product_generator_app.py │ ├── 13_accessible_image_description_app.py │ ├── 14_food_nutrition_analyzer_app.py │ └── 15_background_removal_with_input_node.py ├── package.json ├── pnpm-workspace.yaml ├── pyproject.toml └── tests/ ├── README.md ├── conftest.py ├── test_api.py ├── test_basic.py ├── test_cache.py ├── test_executor.py ├── test_nodes.py ├── test_persistence.py ├── test_server.py └── ui/ ├── __init__.py ├── conftest.py ├── helpers.py ├── test_basic.py ├── test_cancel.py ├── test_dependency_hash.py ├── test_image_fix.py ├── test_images.py ├── test_run_mode.py ├── test_sheets.py └── test_theme.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .agents/skills/daggr/SKILL.md ================================================ --- name: daggr description: | Build DAG-based AI pipelines connecting Gradio Spaces, HuggingFace models, and Python functions into visual workflows. Use when asked to create a workflow, build a pipeline, connect AI models, chain Gradio Spaces, create a daggr app, build multi-step AI applications, or orchestrate ML models. Triggers on: "build a workflow", "create a pipeline", "connect models", "daggr", "chain Spaces", "AI pipeline". license: MIT metadata: author: gradio-app version: "1.1" --- # daggr Build visual DAG pipelines connecting Gradio Spaces, HF Inference Providers, and Python functions. Full docs: https://raw.githubusercontent.com/gradio-app/daggr/refs/heads/main/README.md ## Quick Start ```python from daggr import GradioNode, FnNode, InferenceNode, Graph, ItemList import gradio as gr graph = Graph(name="My Workflow", nodes=[node1, node2, ...]) graph.launch() # Starts web server with visual DAG UI ``` ## Node Types ### GradioNode - Gradio Spaces ```python node = GradioNode( space_or_url="owner/space-name", api_name="/endpoint", inputs={ "param": gr.Textbox(label="Input"), # UI input "other": other_node.output_port, # Port connection "fixed": "constant_value", # Fixed value }, postprocess=lambda *returns: returns[0], # Transform response outputs={"result": gr.Image(label="Output")}, ) # Example: image generation img = GradioNode("Tongyi-MAI/Z-Image-Turbo", api_name="/generate", inputs={"prompt": gr.Textbox(), "resolution": "1024x1024 ( 1:1 )"}, postprocess=lambda imgs, *_: imgs[0]["image"], outputs={"image": gr.Image()}) ``` Find Spaces with semantic queries (describe what you need): `https://huggingface.co/api/spaces/semantic-search?q=generate+music+for+a+video&sdk=gradio&includeNonRunning=false` Or by category: `https://huggingface.co/api/spaces/semantic-search?category=image-generation&sdk=gradio&includeNonRunning=false` (categories: image-generation | video-generation | text-generation | speech-synthesis | music-generation | voice-cloning | image-editing | background-removal | image-upscaling | ocr | style-transfer | image-captioning) ### FnNode - Python Functions ```python def process(input1: str, input2: int) -> str: return f"{input1}: {input2}" node = FnNode( fn=process, inputs={"input1": gr.Textbox(), "input2": other_node.port}, outputs={"result": gr.Textbox()}, ) ``` ### InferenceNode - [HF Inference Providers](https://huggingface.co/docs/inference-providers) Find models: `https://huggingface.co/api/models?inference_provider=all&pipeline_tag=text-to-image` (swap pipeline_tag: text-to-image | image-to-image | image-to-text | image-to-video | text-to-video | text-to-speech | automatic-speech-recognition) VLM/LLM models: https://router.huggingface.co/v1/models ```python node = InferenceNode( model="org/model:provider", # model:provider (fal-ai, replicate, together, etc.) inputs={"image": other_node.image, "prompt": gr.Textbox()}, outputs={"image": gr.Image()}, ) ``` **Auth:** InferenceNode and ZeroGPU Spaces require a HF token. If not in env, ask user to create one: `https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained` Out of quota? Pro gives 8x ZeroGPU + 10x inference: `https://huggingface.co/subscribe/pro` ## Port Connections Pass ports via `inputs={...}`: ```python inputs={"param": previous_node.output_port} # Basic connection inputs={"item": items_node.items.field_name} # Scattered (per-item) inputs={"all": scattered_node.output.all()} # Gathered (collect list) ``` ## ItemList - Dynamic Lists ```python def gen_items(n: int) -> list: return [{"text": f"Item {i}"} for i in range(n)] items = FnNode(fn=gen_items, outputs={"items": ItemList(text=gr.Textbox())}) # Runs once per item process = FnNode(fn=process_item, inputs={"text": items.items.text}, outputs={"result": gr.Textbox()}) # Collect all results final = FnNode(fn=combine, inputs={"all": process.result.all()}, outputs={"out": gr.Textbox()}) ``` ## Checklist 1. **Check API** before using a Space: ```bash curl -s "https://.hf.space/gradio_api/openapi.json" ``` Replace `` with the Space's subdomain (e.g., `Tongyi-MAI/Z-Image-Turbo` → `tongyi-mai-z-image-turbo`). (Spaces also have "Use via API" link in footer with endpoints and code snippets) 2. **Handle files** (Gradio returns dicts): ```python path = file.get("path") if isinstance(file, dict) else file ``` 3. **Use postprocess** for multi-return APIs: ```python postprocess=lambda imgs, seed, num: imgs[0]["image"] ``` 4. **Debug with `.test()`** to validate a node in isolation: ```python node.test(param="value") ``` ## Common Patterns ```python # Image Generation GradioNode("Tongyi-MAI/Z-Image-Turbo", api_name="/generate", inputs={"prompt": gr.Textbox(), "resolution": "1024x1024 ( 1:1 )"}, postprocess=lambda imgs, *_: imgs[0]["image"], outputs={"image": gr.Image()}) # Text-to-Speech GradioNode("Qwen/Qwen3-TTS", api_name="/generate_voice_design", inputs={"text": gr.Textbox(), "language": "English", "voice_description": "..."}, postprocess=lambda audio, status: audio, outputs={"audio": gr.Audio()}) # Image-to-Video GradioNode("alexnasa/ltx-2-TURBO", api_name="/generate_video", inputs={"input_image": img.image, "prompt": gr.Textbox(), "duration": 5}, postprocess=lambda video, seed: video, outputs={"video": gr.Video()}) # ffmpeg composition (import tempfile, subprocess) def combine(video: str|dict, audio: str|dict) -> str: v = video.get("path") if isinstance(video, dict) else video a = audio.get("path") if isinstance(audio, dict) else audio out = tempfile.mktemp(suffix=".mp4") subprocess.run(["ffmpeg","-y","-i",v,"-i",a,"-shortest",out]) return out ``` ## Run ```bash uvx --python 3.12 daggr workflow.py & # Launch in background, hot reloads on file changes ``` ## Authentication **Local development:** Use `hf auth login` or set `HF_TOKEN` env var. This enables ZeroGPU quota tracking, private Spaces access, and gated models. **Deployed Spaces:** Users can click "Login" in the UI and paste their HF token. This enables persistence (sheets) so they can save outputs and resume work later. The token is stored in browser localStorage. **When deploying:** Pass secrets via `--secret HF_TOKEN=xxx` if your workflow needs server-side auth (e.g., for gated models in FnNode). Warning: this uses the deployer's token for all users. ## Deploy to Hugging Face Spaces Only deploy if the user has explicitly asked to publish/deploy their workflow. ```bash daggr deploy workflow.py ``` This extracts the Graph, creates a Space named after it, and uploads everything. **Options:** ```bash daggr deploy workflow.py --name my-space # Custom Space name daggr deploy workflow.py --org huggingface # Deploy to an organization daggr deploy workflow.py --private # Private Space daggr deploy workflow.py --hardware t4-small # GPU (t4-small, t4-medium, a10g-small, etc.) daggr deploy workflow.py --secret KEY=value # Add secrets (repeatable) daggr deploy workflow.py --dry-run # Preview without deploying ``` ================================================ FILE: .changeset/README.md ================================================ # Changesets Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works with multi-package repos, or single-package repos to help you version and publish your code. You can find the full documentation for it [in our repository](https://github.com/changesets/changesets) We have a quick list of common questions to get you started engaging with this project in [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) ================================================ FILE: .changeset/changeset.cjs ================================================ const { getPackagesSync } = require("@manypkg/get-packages"); const dependents_graph = require("@changesets/get-dependents-graph"); const gh = require("@changesets/get-github-info"); const { existsSync, readFileSync, writeFileSync } = require("fs"); const { join } = require("path"); const { getInfo, getInfoFromPullRequest } = gh; const pkg_data = getPackagesSync(process.cwd()); const { packages, rootDir } = pkg_data; const dependents = dependents_graph.getDependentsGraph({ packages, root: pkg_data.rootPackage }); /** * @typedef {{packageJson: {name: string, python?: boolean}, dir: string}} Package */ /** * @typedef {{summary: string, id: string, commit: string, releases: {name: string}}} Changeset */ /** * * @param {string} package_name The name of the package to find the directories for * @returns {string[]} The directories for the package */ function find_packages_dirs(package_name) { /** @type {string[]} */ let package_dirs = []; /** @type {Package | undefined} */ const _package = packages.find((p) => p.packageJson.name === package_name); if (!_package) throw new Error(`Package ${package_name} not found`); package_dirs.push(_package.dir); if (_package.packageJson.python) { package_dirs.push(join(_package.dir, "..")); } return package_dirs; } let lines = { _handled: [] }; const changelogFunctions = { /** * * @param {Changeset[]} changesets The changesets that have been created * @param {any} dependenciesUpdated The dependencies that have been updated * @param {any} options The options passed to the changelog generator * @returns {Promise} The release line for the dependencies */ getDependencyReleaseLine: async ( changesets, dependenciesUpdated, options ) => { if (!options.repo) { throw new Error( 'Please provide a repo to this changelog generator like this:\n"changelog": ["@changesets/changelog-github", { "repo": "org/repo" }]' ); } if (dependenciesUpdated.length === 0) return ""; const changesetLink = `- Updated dependencies [${( await Promise.all( changesets.map(async (cs) => { if (cs.commit) { let { links } = await getInfo({ repo: options.repo, commit: cs.commit }); return links.commit; } }) ) ) .filter((_) => _) .join(", ")}]:`; const updatedDepenenciesList = dependenciesUpdated.map( /** * * @param {any} dependency The dependency that has been updated * @returns {string} The formatted dependency */ (dependency) => { const updates = dependents.get(dependency.name); if (updates && updates.length > 0) { updates.forEach((update) => { if (!lines[update]) { lines[update] = { dirs: find_packages_dirs(update), current_changelog: "", feat: [], fix: [], highlight: [], previous_version: packages.find( (p) => p.packageJson.name === update ).packageJson.version, dependencies: [] }; const changelog_path = join( //@ts-ignore lines[update].dirs[1] || lines[update].dirs[0], "CHANGELOG.md" ); if (existsSync(changelog_path)) { //@ts-ignore lines[update].current_changelog = readFileSync( changelog_path, "utf-8" ) .replace(`# ${update}`, "") .trim(); } } lines[update].dependencies.push( ` - ${dependency.name}@${dependency.newVersion}` ); }); } return ` - ${dependency.name}@${dependency.newVersion}`; } ); writeFileSync( join(rootDir, ".changeset", "_changelog.json"), JSON.stringify(lines, null, 2) ); return [changesetLink, ...updatedDepenenciesList].join("\n"); }, /** * * @param {{summary: string, id: string, commit: string, releases: {name: string}[]}} changeset The changeset that has been created * @param {any} type The type of changeset * @param {any} options The options passed to the changelog generator * @returns {Promise} The release line for the changeset */ getReleaseLine: async (changeset, type, options) => { if (!options || !options.repo) { throw new Error( 'Please provide a repo to this changelog generator like this:\n"changelog": ["@changesets/changelog-github", { "repo": "org/repo" }]' ); } let prFromSummary; let commitFromSummary; /** * @type {string[]} */ let usersFromSummary = []; const replacedChangelog = changeset.summary .replace(/^\s*(?:pr|pull|pull\s+request):\s*#?(\d+)/im, (_, pr) => { let num = Number(pr); if (!isNaN(num)) prFromSummary = num; return ""; }) .replace(/^\s*commit:\s*([^\s]+)/im, (_, commit) => { commitFromSummary = commit; return ""; }) .replace(/^\s*(?:author|user):\s*@?([^\s]+)/gim, (_, user) => { usersFromSummary.push(user); return ""; }) .trim(); const [firstLine, ...futureLines] = replacedChangelog .split("\n") .map((l) => l.trimRight()); const links = await (async () => { if (prFromSummary !== undefined) { let { links } = await getInfoFromPullRequest({ repo: options.repo, pull: prFromSummary }); if (commitFromSummary) { links = { ...links, commit: `[\`${commitFromSummary}\`](https://github.com/${options.repo}/commit/${commitFromSummary})` }; } return links; } const commitToFetchFrom = commitFromSummary || changeset.commit; if (commitToFetchFrom) { let { links } = await getInfo({ repo: options.repo, commit: commitToFetchFrom }); return links; } return { commit: null, pull: null, user: null }; })(); const user_link = /\[(@[^]+)\]/.exec(links.user); const users = usersFromSummary && usersFromSummary.length ? usersFromSummary .map((userFromSummary) => `@${userFromSummary}`) .join(", ") : user_link ? user_link[1] : links.user; const prefix = [ links.pull === null ? "" : `${links.pull}`, links.commit === null ? "" : `${links.commit}` ] .join(" ") .trim(); const suffix = users === null ? "" : ` Thanks ${users}!`; /** * @typedef {{[key: string]: string[] | {dirs: string[], current_changelog: string, feat: {summary: string}[], fix: {summary: string}[], highlight: {summary: string}[]}}} ChangesetMeta */ /** * @type { ChangesetMeta & { _handled: string[] } }} */ if (lines._handled.includes(changeset.id)) { return "done"; } lines._handled.push(changeset.id); changeset.releases.forEach((release) => { if (!lines[release.name]) { lines[release.name] = { dirs: find_packages_dirs(release.name), current_changelog: "", feat: [], fix: [], highlight: [], previous_version: packages.find( (p) => p.packageJson.name === release.name ).packageJson.version, dependencies: [] }; } const changelog_path = join( //@ts-ignore lines[release.name].dirs[1] || lines[release.name].dirs[0], "CHANGELOG.md" ); if (existsSync(changelog_path)) { //@ts-ignore lines[release.name].current_changelog = readFileSync( changelog_path, "utf-8" ) .replace(`# ${release.name}`, "") .trim(); } const [, _type, summary] = changeset.summary .trim() .match(/^(feat|fix|highlight)\s*:\s*([^]*)/im) || [ , "feat", changeset.summary ]; let formatted_summary = ""; if (_type === "highlight") { const [heading, ...rest] = summary.trim().split("\n"); const _heading = `${heading} ${prefix ? `(${prefix})` : ""}`; const _rest = rest.concat(["", suffix]); formatted_summary = `${_heading}\n${_rest.join("\n")}`; } else { formatted_summary = handle_line(summary, prefix, suffix); } //@ts-ignore lines[release.name][_type].push({ summary: formatted_summary }); }); writeFileSync( join(rootDir, ".changeset", "_changelog.json"), JSON.stringify(lines, null, 2) ); return `\n\n-${prefix ? `${prefix} -` : ""} ${firstLine}\n${futureLines .map((l) => ` ${l}`) .join("\n")}`; } }; /** * @param {string} str The changelog entry * @param {string} prefix The prefix to add to the first line * @param {string} suffix The suffix to add to the last line * @returns {string} The formatted changelog entry */ function handle_line(str, prefix, suffix) { const [_s, ...lines] = str.split("\n").filter(Boolean); const desc = `${prefix ? `${prefix} -` : ""} ${_s.replace( /[\s\.]$/, "" )}. ${suffix}`; if (_s.length === 1) { return desc; } return [desc, ...lines.map((l) => ` ${l}`)].join("/n"); } module.exports = changelogFunctions; ================================================ FILE: .changeset/config.json ================================================ { "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", "changelog": ["./changeset.cjs", { "repo": "abidlabs/daggr" }], "commit": false, "fixed": [], "linked": [], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch" } ================================================ FILE: .changeset/fix_changelogs.cjs ================================================ const { join } = require("path"); const { readFileSync, existsSync, writeFileSync, unlinkSync } = require("fs"); const { getPackagesSync } = require("@manypkg/get-packages"); const RE_PKG_NAME = /^[\w-]+\b/; const pkg_meta = getPackagesSync(process.cwd()); /** * @typedef {{dirs: string[], highlight: {summary: string}[], feat: {summary: string}[], fix: {summary: string}[], current_changelog: string}} ChangesetMeta */ /** * @typedef {{[key: string]: ChangesetMeta}} ChangesetMetaCollection */ function run() { if (!existsSync(join(pkg_meta.rootDir, ".changeset", "_changelog.json"))) { console.warn("No changesets to process"); return; } /** * @type { ChangesetMetaCollection & { _handled: string[] } }} */ const { _handled, ...packages } = JSON.parse( readFileSync( join(pkg_meta.rootDir, ".changeset", "_changelog.json"), "utf-8" ) ); /** * @typedef { {packageJson: {name: string, version: string, python: boolean}, dir: string} } PackageMeta */ /** * @type { {[key:string]: PackageMeta} } */ const all_packages = pkg_meta.packages.reduce((acc, pkg) => { acc[pkg.packageJson.name] = /**@type {PackageMeta} */ ( /** @type {unknown} */ (pkg) ); return acc; }, /** @type {{[key:string] : PackageMeta}} */ ({})); for (const pkg_name in packages) { const { dirs, highlight, feat, fix, current_changelog, dependencies } = /**@type {ChangesetMeta} */ (packages[pkg_name]); const { version, python } = all_packages[pkg_name].packageJson; const highlights = highlight?.map((h) => `${h.summary}`) || []; const features = feat?.map((f) => `- ${f.summary}`) || []; const fixes = fix?.map((f) => `- ${f.summary}`) || []; const deps = Array.from(new Set(dependencies?.map((d) => d.trim()))) || []; const release_notes = /** @type {[string[], string][]} */ ([ [highlights, "### Highlights"], [features, "### Features"], [fixes, "### Fixes"], [deps, "### Dependency updates"], ]) .filter(([s], i) => s.length > 0) .map(([lines, title]) => { if (title === "### Highlights") { return `${title}\n\n${lines.join("\n\n")}`; } return `${title}\n\n${lines.join("\n")}`; }) .join("\n\n"); const new_changelog = `# ${pkg_name} ## ${version} ${release_notes} ${current_changelog.replace(`# ${pkg_name}`, "").trim()} `.trim(); dirs.forEach((dir) => { writeFileSync(join(dir, "CHANGELOG.md"), new_changelog); }); if (python) { bump_local_dependents(pkg_name, version); } } unlinkSync(join(pkg_meta.rootDir, ".changeset", "_changelog.json")); /** * @param {string} pkg_to_bump The name of the package to bump * @param {string} version The version to bump to * @returns {void} * */ function bump_local_dependents(pkg_to_bump, version) { for (const pkg_name in all_packages) { const { dir, packageJson: { python }, } = all_packages[pkg_name]; if (!python) continue; const requirements_path = join(dir, "..", "requirements.txt"); if (!existsSync(requirements_path)) continue; const requirements = readFileSync(requirements_path, "utf-8").split("\n"); const pkg_index = requirements.findIndex((line) => { const m = line.trim().match(RE_PKG_NAME); if (!m) return false; return m[0] === pkg_to_bump; }); if (pkg_index !== -1) { requirements[pkg_index] = `${pkg_to_bump}==${version}`; writeFileSync(requirements_path, requirements.join("\n")); } } } } run(); ================================================ FILE: .github/pull_request_template.md ================================================ Thank you for your contribution! All PRs should include the following sections. PRs missing these sections may be closed immediately. ## Short description This PR... *[fill here]* ## AI Disclosure We encourage the use of AI tooling in creating PRs, but the any non-trivial use of AI needs be disclosed. E.g. if you used Claude to write a first draft, you should mention that. Trivial tab-completion doesn't need to be disclosed. **You should self-review all PRs, especially if they were generated with AI**. ----- - [ ] I used AI to... *[fill here]* - [ ] I did not use AI ---- ## Type of Change - [ ] Bug fix - [ ] New feature (non-breaking) - [ ] New feature (breaking change) - [ ] Documentation update - [ ] Test improvements ## Related Issues If this PR closes an issue, please link it below: Closes: ## Testing and linting Please run tests before submitting changes: ```bash python -m pytest ``` and format your code using Ruff: ```bash ruff check --fix --select I && ruff format ``` ================================================ FILE: .github/workflows/comment-queue.yml ================================================ name: Comment on pull request without race conditions on: workflow_call: inputs: pr_number: type: string message: required: true type: string tag: required: false type: string default: "previews" additional_text: required: false type: string default: "" secrets: gh_token: required: true jobs: comment: environment: comment_pr concurrency: group: ${{inputs.pr_number || inputs.tag}} runs-on: ubuntu-latest steps: - name: comment on pr uses: "gradio-app/github/actions/comment-pr@main" with: gh_token: ${{ secrets.gh_token }} tag: ${{ inputs.tag }} pr_number: ${{ inputs.pr_number}} message: ${{ inputs.message }} additional_text: ${{ inputs.additional_text }} ================================================ FILE: .github/workflows/format.yml ================================================ name: Format on: pull_request: branches: [ main ] push: branches: [ main ] jobs: format: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.10' - name: Install dependencies run: | python -m pip install --upgrade pip pip install ruff - name: Check formatting with ruff run: | ruff format --check . - name: Check code with ruff run: | ruff check . ================================================ FILE: .github/workflows/generate-changeset.yml ================================================ name: Generate changeset on: workflow_run: workflows: ["trigger-changeset"] types: - completed env: CI: true concurrency: group: "${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}" cancel-in-progress: false permissions: {} jobs: get-pr: runs-on: ubuntu-latest permissions: contents: read pull-requests: read if: github.event.workflow_run.conclusion == 'success' outputs: found_pr: ${{ steps.pr_details.outputs.found_pr }} pr_number: ${{ steps.pr_details.outputs.pr_number }} source_repo: ${{ steps.pr_details.outputs.source_repo }} source_branch: ${{ steps.pr_details.outputs.source_branch }} actor: ${{ steps.pr_details.outputs.actor }} sha: ${{ steps.pr_details.outputs.sha }} steps: - name: get pr details id: pr_details uses: gradio-app/github/actions/find-pr@main with: github_token: ${{ secrets.GITHUB_TOKEN }} comment-changes-start: uses: "./.github/workflows/comment-queue.yml" needs: get-pr secrets: gh_token: ${{ secrets.GRADIO_PAT }} with: pr_number: ${{ needs.get-pr.outputs.pr_number }} message: changes~pending~null version: permissions: contents: read name: version needs: get-pr runs-on: ubuntu-latest if: needs.get-pr.outputs.found_pr == 'true' outputs: skipped: ${{ steps.version.outputs.skipped }} comment_url: ${{ steps.version.outputs.comment_url }} approved: ${{ steps.version.outputs.approved }} steps: - uses: actions/checkout@v4 with: repository: ${{ needs.get-pr.outputs.source_repo }} ref: ${{ needs.get-pr.outputs.source_branch }} fetch-depth: 0 token: ${{ secrets.GRADIO_PAT }} - name: generate changeset id: version uses: "gradio-app/github/actions/generate-changeset@main" with: github_token: ${{ secrets.GRADIO_PAT }} main_pkg: daggr pr_number: ${{ needs.get-pr.outputs.pr_number }} branch_name: ${{ needs.get-pr.outputs.source_branch }} actor: ${{ needs.get-pr.outputs.actor }} update-status: permissions: actions: read statuses: write runs-on: ubuntu-latest needs: [version, get-pr] steps: - name: update status uses: gradio-app/github/actions/commit-status@main with: sha: ${{ needs.get-pr.outputs.sha }} token: ${{ secrets.COMMIT_STATUS }} name: "Changeset Results" pr: ${{ needs.get-pr.outputs.pr_number }} result: 'success' type: all comment-changes-skipped: uses: "./.github/workflows/comment-queue.yml" needs: [get-pr, version] if: needs.version.result == 'success' && needs.version.outputs.skipped == 'true' secrets: gh_token: ${{ secrets.GRADIO_PAT }} with: pr_number: ${{ needs.get-pr.outputs.pr_number }} message: changes~warning~https://github.com/abidlabs/daggr/actions/runs/${{github.run_id}}/ comment-changes-success: uses: "./.github/workflows/comment-queue.yml" needs: [get-pr, version] if: needs.version.result == 'success' && needs.version.outputs.skipped == 'false' secrets: gh_token: ${{ secrets.GRADIO_PAT }} with: pr_number: ${{ needs.get-pr.outputs.pr_number }} message: changes~success~${{ needs.version.outputs.comment_url }} comment-changes-failure: uses: "./.github/workflows/comment-queue.yml" needs: [get-pr, version] if: always() && needs.version.result == 'failure' secrets: gh_token: ${{ secrets.GRADIO_PAT }} with: pr_number: ${{ needs.get-pr.outputs.pr_number }} message: changes~failure~https://github.com/abidlabs/daggr/actions/runs/${{github.run_id}}/ ================================================ FILE: .github/workflows/publish.yml ================================================ # safe runs from main name: publish on: push: branches: - main jobs: version_or_publish: runs-on: ubuntu-22.04 environment: publish permissions: contents: read id-token: write steps: - name: checkout repo uses: actions/checkout@v4 with: fetch-depth: 0 persist-credentials: false - name: Install pnpm uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # @v4 with: version: 9.1.x - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm cache-dependency-path: pnpm-lock.yaml - name: Install changesets run: pnpm i --frozen-lockfile - name: create and publish versions id: changesets uses: changesets/action@aba318e9165b45b7948c60273e0b72fce0a64eb9 # @v1 with: version: pnpm ci:version commit: "chore: update versions" title: "chore: update versions" publish: pnpm ci:tag env: GITHUB_TOKEN: ${{ secrets.GRADIO_PAT }} - name: Build frontend if: steps.changesets.outputs.hasChangesets != 'true' run: | cd daggr/frontend npm ci npm run build - name: publish to pypi if: steps.changesets.outputs.hasChangesets != 'true' uses: "gradio-app/github/actions/publish-pypi@main" with: use-oidc: true ================================================ FILE: .github/workflows/test.yml ================================================ name: Unit Tests on: pull_request: branches: [ main ] push: branches: [ main ] jobs: test: strategy: matrix: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.10' - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Install uv uses: astral-sh/setup-uv@v3 with: version: "latest" - name: Cache uv packages uses: actions/cache@v3 with: path: ~/.cache/uv key: ${{ runner.os }}-uv-${{ hashFiles('pyproject.toml') }} restore-keys: | ${{ runner.os }}-uv- - name: Build frontend run: | cd daggr/frontend npm ci npm run build - name: Install Python dependencies run: | uv pip install --system -e .[dev] --prerelease=allow uv pip install --system pytest --prerelease=allow - name: Install Playwright browsers (Ubuntu only) if: runner.os == 'Linux' run: | playwright install chromium --with-deps - name: Run all tests (Ubuntu) if: runner.os == 'Linux' run: | pytest - name: Run tests without UI (Windows) if: runner.os == 'Windows' run: | pytest --ignore=tests/ui/ ================================================ FILE: .github/workflows/trigger-changeset.yml ================================================ name: trigger-changeset on: pull_request: types: [opened, synchronize, reopened, edited, labeled, unlabeled] branches: - main issue_comment: types: [edited] permissions: {} jobs: changeset: runs-on: ubuntu-22.04 if: github.event.sender.login != 'gradio-pr-bot' steps: - run: echo "Requesting changeset" ================================================ FILE: .gitignore ================================================ trackio/__pycache__/ tests/__pycache__/ test-results/ .trackio/ .gradio/ trackio.db *.pyc *.pyi *.claude/ .venv/ **/.DS_Store *.daggr_sessions.db node_modules/ /dist/ examples/test.py daggr/frontend/dist/ build/ tf_test_run/ venv .vscode/ ================================================ FILE: CHANGELOG.md ================================================ # daggr ## 0.8.0 ### Features - [#79](https://github.com/gradio-app/daggr/pull/79) [`d32cec2`](https://github.com/gradio-app/daggr/commit/d32cec21e93532ce7a7e66815922985c0bd5cb75) - feat: Introduce `InputNode` for grouping UI controls. Thanks @elismasilva! - [#81](https://github.com/gradio-app/daggr/pull/81) [`34267c9`](https://github.com/gradio-app/daggr/commit/34267c9f93729cc4fa4839b15feb44a239897ec9) - stylized thin scrollbars. Thanks @elismasilva! - [#77](https://github.com/gradio-app/daggr/pull/77) [`9002cca`](https://github.com/gradio-app/daggr/commit/9002cca2b312731572d4d80d7efe3cd9483e19b3) - Feature: Toggle Dark/Light Mode (based on @elismasilva's changes). Thanks @abidlabs! ## 0.7.0 ### Features - [#69](https://github.com/gradio-app/daggr/pull/69) [`297d104`](https://github.com/gradio-app/daggr/commit/297d104d0e3fbd6b59dfcd1f69c9a478de81bc3d) - Add stop button to cancel running nodes. Thanks @abidlabs! - [#64](https://github.com/gradio-app/daggr/pull/64) [`a9e53c6`](https://github.com/gradio-app/daggr/commit/a9e53c64db6b3beede0b19b3876a3e50ab572233) - Fix ChoiceNode: public .name property and respect explicit names. Thanks @abidlabs! - [#62](https://github.com/gradio-app/daggr/pull/62) [`695411c`](https://github.com/gradio-app/daggr/commit/695411ce94bc8fedd3320ac941ab41233bf8f887) - Standardize file handling: all files are path strings. Thanks @abidlabs! - [#66](https://github.com/gradio-app/daggr/pull/66) [`1ed16f8`](https://github.com/gradio-app/daggr/commit/1ed16f806d535413c3718fc47d81d79d93d73ee0) - Fix gr.JSON rendering (use @render snippet syntax). Thanks @abidlabs! - [#63](https://github.com/gradio-app/daggr/pull/63) [`00b05ac`](https://github.com/gradio-app/daggr/commit/00b05ac526642c91560cbebb258626a4511082c2) - Fix file downloads from private HF Spaces. Thanks @abidlabs! - [#70](https://github.com/gradio-app/daggr/pull/70) [`33ccb74`](https://github.com/gradio-app/daggr/commit/33ccb7470a482ee1b09fddf1d51de81b1e2c4a40) - Fix gr.Image not rendering with initial value or None input. Thanks @abidlabs! - [#68](https://github.com/gradio-app/daggr/pull/68) [`4b76dca`](https://github.com/gradio-app/daggr/commit/4b76dca815e2802b70ac03bb95fb03f21d81a8fa) - Add dependency hash tracking for upstream Spaces and models. Thanks @abidlabs! ## 0.6.0 ### Features - [#54](https://github.com/gradio-app/daggr/pull/54) [`c1abb26`](https://github.com/gradio-app/daggr/commit/c1abb260b254af6ca2060292232049ea89f0f944) - Fix cache. Thanks @abidlabs! - [#56](https://github.com/gradio-app/daggr/pull/56) [`6e3dfc0`](https://github.com/gradio-app/daggr/commit/6e3dfc0a585b673adb77bb11ab1dcfd80d01da5a) - Add paste from clipboard button to Image component. Thanks @abidlabs! - [#57](https://github.com/gradio-app/daggr/pull/57) [`76855ba`](https://github.com/gradio-app/daggr/commit/76855ba967e3f3132e8ec0590ae037d3151af310) - Fix dropdown options being clipped inside node. Thanks @abidlabs! - [#58](https://github.com/gradio-app/daggr/pull/58) [`eb52b72`](https://github.com/gradio-app/daggr/commit/eb52b725b17d277e85f6eac1cc9d07f8068b011b) - Add theme support to daggr. Thanks @abidlabs! - [#59](https://github.com/gradio-app/daggr/pull/59) [`78189a4`](https://github.com/gradio-app/daggr/commit/78189a4163b4041c814e52110b65754dc4dbf863) - Add run mode dropdown to control node execution scope. Thanks @abidlabs! - [#39](https://github.com/gradio-app/daggr/pull/39) [`e8792ad`](https://github.com/gradio-app/daggr/commit/e8792ad1b5818ff8d13660b0b156f329bbc1c33a) - feat: add --state-db-path CLI arg and DAGGR_DB_PATH env var support. Thanks @leith-bartrich! ## 0.5.4 ### Features - [#27](https://github.com/gradio-app/daggr/pull/27) [`3952b2c`](https://github.com/gradio-app/daggr/commit/3952b2ccf30e7d18994f23049c2a2e84b323cfd6) - changes. Thanks @abidlabs! ## 0.5.3 ### Features - [#19](https://github.com/gradio-app/daggr/pull/19) [`cd956fe`](https://github.com/gradio-app/daggr/commit/cd956fe29945bdfd31bbe76fcb80d3f9c97cc301) - Add daggr tag to deployed Spaces. Thanks @gary149! ## 0.5.2 ### Features - [#14](https://github.com/gradio-app/daggr/pull/14) [`3fa412d`](https://github.com/gradio-app/daggr/commit/3fa412d678988608d49d46d99d193a05469892d2) - Fixes. Thanks @abidlabs! ## 0.5.1 ### Features - [#11](https://github.com/gradio-app/daggr/pull/11) [`ce1d5f4`](https://github.com/gradio-app/daggr/commit/ce1d5f4deaac60d95d9a021b0aa057bc2941b018) - Fixes. Thanks @abidlabs! - [#13](https://github.com/gradio-app/daggr/pull/13) [`3246921`](https://github.com/gradio-app/daggr/commit/32469213dad5fd29a7ac85938dffbd976e2c6643) - fixes. Thanks @abidlabs! ## 0.5.0 ### Features - [#8](https://github.com/gradio-app/daggr/pull/8) [`e480065`](https://github.com/gradio-app/daggr/commit/e480065dd058dbf19053a80956dbfc90cf3e3caf) - Improving security around executor and various bug fixes. Thanks @abidlabs! ## 0.4.0 ### Features - [#1](https://github.com/gradio-app/daggr/pull/1) [`23538c8`](https://github.com/gradio-app/daggr/commit/23538c884fb3f2d84bbe4bf14f475dc85fa17c79) - Refactor files, add Dialogue component, and implement fully working podcast example. Thanks @abidlabs! ## 0.1.0 Initial release ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md ## Code Style - AVOID inline comments ## Commands ```bash pip install -e .[dev] # Install dev dependencies pytest # Run tests ruff check --fix --select I && ruff format # Lint and format ``` ## Architecture DAG-based workflow library connecting Gradio Spaces, ML models, and Python functions. ### Core Files - **`graph.py`**: `Graph` class - holds nodes, edges, launches server - **`node.py`**: `GradioNode`, `FnNode`, `InferenceNode`, `InteractionNode` - **`port.py`**: `Port`, `ScatteredPort`, `GatheredPort` for parallel execution - **`edge.py`**: `Edge` class for node connections - **`executor.py`**: Executes workflow nodes - **`server.py`**: FastAPI server with WebSocket for real-time UI updates - **`state.py`**: Session state persistence with SQLite - **`frontend/`**: Svelte frontend for workflow visualization ### Node Definition Pattern Nodes use `inputs` and `outputs` dicts: ```python node = GradioNode( space_or_url="owner/space", api_name="/endpoint", inputs={ "param": gr.Textbox(label="Label"), # UI input "other": other_node.output_port, # Port connection "fixed": "constant_value", # Fixed value }, outputs={ "result": gr.Audio(label="Result"), "hidden": gr.Text(visible=False), # Hidden output }, ) ``` ### Key Patterns 1. **Port access**: `node.port_name` returns a `Port` for connections 2. **Graph creation**: `Graph(nodes=[...])` auto-wires from port connections in inputs 3. **Internal attrs**: Use `_` prefix (`_name`, `_fn`) to avoid port name conflicts ## Issue Workflow 1. Create branch: `git checkout -b fix-issue-NUMBER` 2. Implement fix 3. Run: `pytest && ruff check --fix --select I && ruff format` 4. Do not commit - leave for user ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing Thank you for your interest in contributing! This document provides guidelines and information for contributing to the project. ## Contribution Guidelines We welcome contributions that: - Improve or enhance core functionality - Fix bugs in existing features - Add essential features that align with the project's goals ## Development Setup 1. Fork and clone the repository 2. Install the package with development dependencies ```bash pip install -e ".[dev]" ``` 3. Run tests before submitting changes: ```bash python -m pytest ``` 4. Build the frontend: The project includes a Svelte-based frontend that must be built for the app to function correctly (requires Node 24+ and npm 11+). ```bash cd daggr/frontend npm install npm run build cd ../.. ``` 5. Format your code using Ruff: ```bash ruff check --fix --select I && ruff format ``` ## Pull Request Process 1. Ensure your code passes all tests 2. Format your code using Ruff 3. Update documentation if necessary 4. Submit a pull request with a clear description of your changes Thank you for contributing! ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 Your Name Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: MANIFEST.in ================================================ include LICENSE include README.md include daggr/package.json recursive-include daggr *.py *.pyi recursive-include daggr/frontend/dist * recursive-include daggr/assets * prune daggr/canvas-component prune daggr/frontend/node_modules prune daggr/frontend/src ================================================ FILE: README.md ================================================

daggr Logo

DAG-based Gradio workflows!

`daggr` is a Python library for building AI workflows that connect [Gradio](https://github.com/gradio-app/gradio) apps, ML models (through [Hugging Face Inference Providers](https://huggingface.co/docs/inference-providers/en/index)), and custom Python functions. It automatically generates a visual canvas for your workflow allowing you to inspect intermediate outputs, rerun any step any number of times, and preserves state for complex or long-running workflows. Daggr also tracks provenance: when you browse through previous results, it automatically restores the exact inputs that produced each output, and visually indicates which parts of your workflow are stale. Screenshot 2026-01-26 at 1 01 58 PM ## Installation ```bash pip install daggr ``` (requires Python 3.10 or higher). ## Quick Start After installing `daggr`, create a new Python file, say `app.py`, and paste this code: ```python import random import gradio as gr from daggr import GradioNode, Graph glm_image = GradioNode( "hf-applications/Z-Image-Turbo", api_name="/generate_image", inputs={ "prompt": gr.Textbox( # An input node is created for the prompt label="Prompt", value="A cheetah in the grassy savanna.", lines=3, ), "height": 1024, # Fixed value (does not appear in the canvas) "width": 1024, # Fixed value (does not appear in the canvas) "seed": random.random, # Functions are rerun every time the workflow is run (not shown in the canvas) }, outputs={ "image": gr.Image( label="Image" # Display original image ), }, ) background_remover = GradioNode( "hf-applications/background-removal", api_name="/image", inputs={ "image": glm_image.image, }, postprocess=lambda _, final: final, outputs={ "image": gr.Image(label="Final Image"), # Display only final image }, ) graph = Graph( name="Transparent Background Image Generator", nodes=[glm_image, background_remover] ) graph.launch() ``` Run `daggr app.py` to start the app with hot reloading (or `python app.py` for standard execution). You should see a Daggr app like the one shown above that you can use to generate images with a transparent background! ## When to (Not) Use Daggr Use Daggr when: * You want to define an AI workflow in Python involving Gradio Spaces, inference providers, or custom functions * The workflow is complex enough that inspecting intermediate outputs or rerunning individual steps is useful * You need a fixed pipeline that you or others can run with different inputs * You want to explore variations: generate multiple outputs, compare them, and always know exactly what inputs produced each result **Why not... ComfyUI?** ComfyUI is a visual node editor where you build workflows by dragging and connecting nodes. Daggr takes a code-first approach: you define workflows in Python and the visual canvas is generated automatically. If you prefer writing code over visual editing, Daggr may be a better fit. In addition, Daggr works with Gradio Spaces and Hugging Face models directly, no need for specialized nodes. **Why not... Airflow/Prefect?** Daggr was inspired by Airflow/Prefect, but whereas the focus of these orchestration platforms is scheduling, monitoring, and managing pipelines at scale, Daggr is built for interactive AI/ML workflows with real-time visual feedback and immediate execution, making it ideal for prototyping, demos, and workflows where you want to inspect intermediate outputs and rerun individual steps on the fly. **Why not... Gradio?** Gradio creates web UIs for individual ML models and demos. While complex workflows can be built in Gradio, they often fail in ways that are hard to debug when using the Gradio app. Daggr tries to provide a transparent, easily-inspectable way to chain multiple Gradio apps, custom Python functions, and inference providers through a visual canvas. Don't use Daggr when: * You need a simple UI for a single model or function - consider using Gradio directly * You want a node-based editor for building workflows visually - consider using ComfyUI instead ## How It Works A Daggr workflow consists of **nodes** connected in a directed graph. Each node represents a computation: a Gradio Space API call, an inference call to a model, or a Python function. Each node has **input ports** and **output ports**, which correspond to the node's parameters and return values. Ports are how data flows between nodes. **Input ports** can be connected to: - A previous node's output port → creates an edge, data flows automatically - A Gradio component → creates a standalone input in the UI - A fixed value → passed directly, doesn't appear in UI - A `Callable` → called each time the node runs (useful for random seeds) **Output ports** can be: - A Gradio component → displays the output in the node's card - `None` → output not displayed in the node's card but port can still connect to downstream nodes ### Node Types #### `GradioNode` Calls a Gradio Space API endpoint. Use this to connect to any Gradio app on Hugging Face Spaces or running locally. ```python from daggr import GradioNode import gradio as gr image_gen = GradioNode( space_or_url="black-forest-labs/FLUX.1-schnell", # HF Space ID or URL api_name="/infer", # API endpoint name inputs={ "prompt": gr.Textbox(label="Prompt"), # Creates UI input "seed": 42, # Fixed value "width": 1024, "height": 1024, }, outputs={ "image": gr.Image(label="Generated Image"), # Display in node card }, ) ``` **Finding the right inputs:** To find what parameters a GradioNode expects, go to the Gradio Space and click "Use via API" at the bottom of the page. This shows you the API endpoints and their parameters. For example, if the API page shows: ```python from gradio_client import Client client = Client("black-forest-labs/FLUX.1-schnell") result = client.predict( prompt="Hello!!", seed=0, randomize_seed=True, width=1024, height=1024, num_inference_steps=4, api_name="/infer" ) ``` Then your GradioNode inputs should use the same parameter names: `prompt`, `seed`, `randomize_seed`, `width`, `height`, `num_inference_steps`. **Outputs:** Output port names can be anything you choose—they simply map to the return values of the API endpoint in order. If an endpoint returns `(image, seed)`, you might define: ```python outputs={ "generated_image": gr.Image(), # Maps to first return value "used_seed": gr.Number(), # Maps to second return value } ``` #### `FnNode` Runs a Python function. Input ports are automatically discovered from the function signature. ```python from daggr import FnNode import gradio as gr def summarize(text: str, max_words: int = 100) -> str: words = text.split()[:max_words] return " ".join(words) + "..." summarizer = FnNode( fn=summarize, inputs={ "text": gr.Textbox(label="Text to Summarize", lines=5), "max_words": gr.Slider(minimum=10, maximum=500, value=100, label="Max Words"), }, outputs={ "summary": gr.Textbox(label="Summary"), }, ) ``` **Inputs:** Keys in the `inputs` dict must match the function's parameter names. If you don't specify an input, it uses the function's default value (if available). **Outputs:** Return values are mapped to output ports in the same order they are defined in the `outputs` dict—just like GradioNode. For a single output, simply return the value. For multiple outputs, return a tuple: ```python def process(text: str) -> tuple[str, int]: return text.upper(), len(text) node = FnNode( fn=process, inputs={"text": gr.Textbox()}, outputs={ "uppercase": gr.Textbox(), # First return value "length": gr.Number(), # Second return value }, ) ``` Note: If you return a dict or list, it will be treated as a single value (mapped to the first output port), not as a mapping to output ports. **Concurrency:** By default, FnNodes execute sequentially (one at a time per user session) to prevent resource contention from concurrent function calls. If your function is safe to run in parallel, you can enable concurrent execution: ```python # Allow this node to run in parallel with other nodes node = FnNode(my_func, concurrent=True) # Share a resource limit with other nodes (e.g., GPU memory) gpu_node_1 = FnNode(process_image, concurrency_group="gpu", max_concurrent=2) gpu_node_2 = FnNode(enhance_image, concurrency_group="gpu", max_concurrent=2) ``` | Parameter | Default | Description | |-----------|---------|-------------| | `concurrent` | `False` | If `True`, allow parallel execution | | `concurrency_group` | `None` | Name of a group sharing a concurrency limit | | `max_concurrent` | `1` | Max parallel executions in the group | > **Tip:** When possible, prefer `GradioNode` or `InferenceNode` over `FnNode`. These nodes automatically run concurrently (they're external API calls), and your Hugging Face token is automatically passed through for ZeroGPU quota tracking, private Spaces access, and gated model access. #### `InferenceNode` Calls a model via [Hugging Face Inference Providers](https://huggingface.co/docs/inference-providers/en/index). This lets you use models hosted on the Hugging Face Hub without downloading them. ```python from daggr import InferenceNode import gradio as gr llm = InferenceNode( model="meta-llama/Llama-3.1-8B-Instruct", inputs={ "prompt": gr.Textbox(label="Prompt", lines=3), }, outputs={ "response": gr.Textbox(label="Response"), }, ) ``` **Inputs:** The expected inputs depend on the model's task type. For text generation models, use `prompt`. For other tasks, check the model's documentation on the Hub. **Outputs:** Like other nodes, output names are arbitrary and map to return values in order. > **Tip:** `InferenceNode` and `GradioNode` automatically run concurrently and pass your HF token for ZeroGPU, private Spaces, and gated models. Prefer these over `FnNode` when possible. ### Preprocessing and Postprocessing `GradioNode`, `FnNode`, and `InferenceNode` all support optional `preprocess` and `postprocess` hooks that transform data on the way in and out of a node. **`preprocess`** receives the input dict and returns a modified dict before the node executes. This is useful when an upstream node outputs data in a different format than the downstream node expects: ```python def fix_image_input(inputs): img = inputs.get("image") if isinstance(img, dict) and "path" in img: inputs["image"] = img["path"] return inputs describer = GradioNode( "vikhyatk/moondream2", api_name="/answer_question", preprocess=fix_image_input, inputs={"image": image_gen.result, "prompt": "Describe this image."}, outputs={"description": gr.Textbox()}, ) ``` **`postprocess`** receives the raw return values from the node and lets you reshape them before they are mapped to output ports. If the node returns multiple values (a tuple), each value is passed as a separate argument. This is essential when working with Spaces that return extra values you don't need: ```python background_remover = GradioNode( "hf-applications/background-removal", api_name="/image", inputs={"image": some_node.image}, postprocess=lambda original, final: final, # Space returns (original, processed); keep only processed outputs={"image": gr.Image(label="Result")}, ) ``` Another common pattern is extracting a specific item from a complex return value: ```python image_gen = GradioNode( "multimodalart/stable-cascade", api_name="/run", inputs={...}, postprocess=lambda images, seed_used, seed_number: images[0]["image"], # Extract first image outputs={"image": gr.Image(label="Generated Image")}, ) ``` ### File Handling > **Key difference from Gradio:** In daggr, all file-based data (images, audio, video, 3D models) is passed between nodes as **file path strings**. Gradio's `type` parameter (e.g., `Image(type="numpy")`) is ignored — daggr does not convert files to numpy arrays, PIL images, or any other in-memory format. This means: - **Inputs** to your node arrive as file path strings (e.g., `"/tmp/daggr/abc123.png"`) - **Outputs** from your node should be file path strings pointing to a file on disk If your node expects a different format, use `preprocess` to convert file paths on the way in, and `postprocess` to convert back to file paths on the way out. This works with all node types: ```python from PIL import Image def load_image(inputs): inputs["image"] = Image.open(inputs["image"]) return inputs def save_image(result): out_path = "/tmp/processed.png" result.save(out_path) return out_path node = FnNode( lambda image: image.rotate(90), preprocess=load_image, postprocess=save_image, inputs={"image": gr.Image(label="Input")}, outputs={"output": gr.Image(label="Rotated")}, ) ``` For audio: ```python import soundfile as sf def load_audio(inputs): data, sr = sf.read(inputs["audio"]) inputs["audio"] = (sr, data) return inputs def save_audio(result): sr, data = result out_path = "/tmp/processed.wav" sf.write(out_path, data, sr) return out_path ``` #### `InputNode` The `InputNode` allows you to group multiple Gradio input components (such as `gr.Textbox`, `gr.Slider`, or `gr.Dropdown`) into a single, organized block on the canvas. This is particularly useful for workflows with many parameters, preventing the UI from becoming cluttered with numerous individual input nodes. **How it works:** Each key defined in the `ports` dictionary automatically becomes a distinct **output port** on the node. You can then wire these ports to downstream processing nodes. ```python import gradio as gr from daggr import InputNode, GradioNode, Graph parameters = InputNode( name="Parameters", ports={ "prompt": gr.Textbox( label="Prompt", value="A cheetah in the grassy savanna.", lines=3, ), "height": gr.Slider( label="Height", value=1024, minimum=512, maximum=2048, step=128 ), "width": gr.Slider( label="Width", value=1024, minimum=512, maximum=2048, step=128 ) }, ) glm_image = GradioNode( "hf-applications/Z-Image-Turbo", api_name="/generate_image", inputs={ "prompt": parameters.prompt, "height": parameters.height, "width": parameters.width, }, outputs={ "image": gr.Image(label="Image"), }, ) graph = Graph(name="Grouped Inputs Demo", nodes=[glm_image]) graph.launch() ``` ### Node Concurrency Different node types have different concurrency behaviors: | Node Type | Concurrency | Why | |-----------|-------------|-----| | `GradioNode` | **Concurrent** | External API calls—safe to parallelize | | `InferenceNode` | **Concurrent** | External API calls—safe to parallelize | | `FnNode` | **Sequential** (default) | Local Python code may have resource constraints | **Why sequential by default for FnNode?** Local Python functions often: - Access shared resources (files, databases, GPU memory) - Use libraries that aren't thread-safe - Consume significant CPU/memory By running FnNodes sequentially per session, daggr prevents race conditions and resource contention. If your function is safe to run in parallel, opt in with `concurrent=True`. **Concurrency groups** let multiple nodes share a resource limit: ```python # Both nodes share GPU—at most 2 concurrent executions total upscale = FnNode(upscale_image, concurrency_group="gpu", max_concurrent=2) enhance = FnNode(enhance_image, concurrency_group="gpu", max_concurrent=2) ``` ### Testing Nodes You can test-run any node in isolation using the `.test()` method: ```python tts = GradioNode("mrfakename/MeloTTS", api_name="/synthesize", ...) result = tts.test(text="Hello world", speaker="EN-US") # Returns: {"audio": "/path/to/audio.wav"} ``` If called without arguments, `.test()` auto-generates example values using each input component's `.example_value()` method: ```python result = tts.test() # Uses gr.Textbox().example_value(), etc. ``` This is useful for quickly checking what format a node returns without wiring up a full workflow. ### Input Types Each node's `inputs` dict accepts four types of values: | Type | Example | Result | |------|---------|--------| | **Gradio component** | `gr.Textbox(label="Topic")` | Creates UI input | | **Port reference** | `other_node.output_name` | Connects nodes | | **Fixed value** | `"Auto"` or `42` | Constant, no UI | | **Callable** | `random.random` | Called each run, no UI | ### Output Types Each node's `outputs` dict accepts two types of values: | Type | Example | Result | |------|---------|--------| | **Gradio component** | `gr.Image(label="Result")` | Displays output in node card | | **None** | `None` | Hidden, but can connect to downstream nodes | ### Scatter / Gather (experimental) When a node outputs a list and you want to process each item individually, use `.each` to scatter and `.all()` to gather: ```python script = FnNode(fn=generate_script, inputs={...}, outputs={"lines": gr.JSON()}) tts = FnNode( fn=text_to_speech, inputs={ "text": script.lines.each["text"], # Scatter: run once per item "speaker": script.lines.each["speaker"], }, outputs={"audio": gr.Audio()}, ) final = FnNode( fn=combine_audio, inputs={"audio_files": tts.audio.all()}, # Gather: collect all outputs outputs={"audio": gr.Audio()}, ) ``` ### Choice Nodes (experimental) Sometimes you want to offer multiple alternatives for the same step in your workflow—for example, two different TTS providers or image generators. Use the `|` operator to create a **choice node** that lets users switch between variants in the UI: ```python host_voice = GradioNode( space_or_url="abidlabs/tts", api_name="/generate_voice_design", inputs={ "voice_description": gr.Textbox(label="Host Voice"), "language": "Auto", "text": "Hi! I'm the host!", }, outputs={"audio": gr.Audio(label="Host Voice")}, ) | GradioNode( space_or_url="mrfakename/E2-F5-TTS", api_name="/basic_tts", inputs={ "ref_audio_input": gr.Audio(label="Reference Audio"), "gen_text_input": gr.Textbox(label="Text to Generate"), }, outputs={"audio": gr.Audio(label="Host Voice")}, ) # Downstream nodes connect to host_voice.audio regardless of which variant is selected dialogue = FnNode( fn=generate_dialogue, inputs={"host_voice": host_voice.audio, ...}, ... ) ``` In the canvas, choice nodes display an accordion UI where you can: - See all available variants - Click to select which variant to use - View the selected variant's input components The selected variant is persisted per sheet, so your choice is remembered across page refreshes. All variants must have the same output ports (so downstream connections work regardless of selection), but they can have different input ports. ## Putting It Together: A Mock Podcast Generator ```python import gradio as gr from daggr import FnNode, GradioNode, Graph # Generate voice profiles host_voice = GradioNode( space_or_url="abidlabs/tts", api_name="/generate_voice_design", inputs={ "voice_description": gr.Textbox(label="Host Voice", value="Deep British voice..."), "language": "Auto", "text": "Hi! I'm the host.", }, outputs={"audio": gr.Audio(label="Host Voice")}, ) guest_voice = GradioNode( space_or_url="abidlabs/tts", api_name="/generate_voice_design", inputs={ "voice_description": gr.Textbox(label="Guest Voice", value="Friendly American voice..."), "language": "Auto", "text": "Hi! I'm the guest.", }, outputs={"audio": gr.Audio(label="Guest Voice")}, ) # Generate dialogue (would be an LLM call in production) def generate_dialogue(topic: str, host_voice: str, guest_voice: str) -> tuple[list, str]: dialogue = [ {"voice": host_voice, "text": "Hello, how are you?"}, {"voice": guest_voice, "text": "I'm great, thanks!"}, ] html = "Host: Hello!
Guest: I'm great!" return dialogue, html # Returns tuple: first value -> "json", second -> "html" dialogue = FnNode( fn=generate_dialogue, inputs={ "topic": gr.Textbox(label="Topic", value="AI"), "host_voice": host_voice.audio, "guest_voice": guest_voice.audio, }, outputs={ "json": gr.JSON(visible=False), # Maps to first return value "html": gr.HTML(label="Script"), # Maps to second return value }, ) # Generate audio for each line (scatter) def text_to_speech(text: str, audio: str) -> str: return audio # Would call TTS model in production samples = FnNode( fn=text_to_speech, inputs={ "text": dialogue.json.each["text"], "audio": dialogue.json.each["voice"], }, outputs={"audio": gr.Audio(label="Sample")}, ) # Combine all audio (gather) def combine_audio(audio_files: list[str]) -> str: from pydub import AudioSegment combined = AudioSegment.empty() for path in audio_files: combined += AudioSegment.from_file(path) combined.export("output.mp3", format="mp3") return "output.mp3" final = FnNode( fn=combine_audio, inputs={"audio_files": samples.audio.all()}, outputs={"audio": gr.Audio(label="Full Podcast")}, ) graph = Graph(name="Podcast Generator", nodes=[host_voice, guest_voice, dialogue, samples, final]) graph.launch() ``` ## Sharing and Hosting Create a public URL to share your workflow with others: ```python graph.launch(share=True) ``` This generates a temporary public URL (expires in 1 week) using Gradio's tunneling infrastructure. ### Deploying to Hugging Face Spaces For permanent hosting, use `daggr deploy` to deploy your app to [Hugging Face Spaces](https://huggingface.co/spaces): ```bash daggr deploy my_app.py ``` Assuming you are logged in locally with your [Hugging Face token](https://huggingface.co/settings/tokens), this command: 1. Extracts the Graph from your script 2. Creates a Space named after your Graph (e.g., "Podcast Generator" → `podcast-generator`) 3. Uploads your script and dependencies 4. Configures the Space with the Gradio SDK #### Deploy Options ```bash # Custom Space name daggr deploy my_app.py --name my-custom-space # Deploy to an organization daggr deploy my_app.py --org huggingface # Private Space with GPU daggr deploy my_app.py --private --hardware t4-small # Add secrets (e.g., API keys) daggr deploy my_app.py --secret HF_TOKEN=xxx --secret OPENAI_KEY=yyy # Preview without deploying daggr deploy my_app.py --dry-run ``` | Option | Short | Description | |--------|-------|-------------| | `--name` | `-n` | Space name (default: derived from Graph name) | | `--title` | `-t` | Display title (default: Graph name) | | `--org` | `-o` | Organization to deploy under | | `--private` | `-p` | Make the Space private | | `--hardware` | | Hardware tier: `cpu-basic`, `cpu-upgrade`, `t4-small`, `t4-medium`, `a10g-small`, etc. | | `--secret` | `-s` | Add secrets (repeatable) | | `--requirements` | `-r` | Custom requirements.txt path | | `--dry-run` | | Preview what would be deployed | The deploy command automatically: - Detects local Python imports and includes them - Uses existing `requirements.txt` if present, or generates one with `daggr` - Renames your script to `app.py` (HF Spaces convention) - Generates the required `README.md` with Space metadata ### Manual Deployment You can also deploy manually by creating a new Space with the Gradio SDK, adding your workflow code to `app.py`, and including `daggr` in your `requirements.txt`. Daggr automatically reads the `GRADIO_SERVER_NAME` and `GRADIO_SERVER_PORT` environment variables, which Hugging Face Spaces sets automatically for Gradio apps. This means your daggr app will work on Spaces without any additional configuration. ## Persistence and Sheets Daggr automatically saves your workflow state—input values, node results, and canvas position—so you can pick up where you left off after a page reload. ### Sheets **Sheets** are like separate workspaces within a single Daggr app. Each sheet has its own: - Input values for all nodes - Cached results from previous runs - Canvas zoom and pan position Use sheets to work on multiple projects within the same workflow. For example, in a podcast generator app, each sheet could represent a different podcast episode you're working on. The sheet selector appears in the title bar. Click to switch between sheets, create new ones, rename them (double-click), or delete them. ### Result History and Provenance Tracking Every time a node runs, Daggr saves not just the output, but also a snapshot of all input values at that moment. This enables powerful exploratory workflows: **Browsing previous results**: Use the `‹` and `›` arrows in the node footer to navigate through all cached results for that node (shown as "1/3", "2/3", etc.). **Automatic input restoration**: When you select a previous result, Daggr automatically restores the input values that produced it. This means you can: 1. Generate multiple variations by running a node several times with different inputs 2. Browse through your results to find the best one 3. When you select a result, see exactly what inputs created it 4. Continue your workflow from that point with all the original context intact **Cascading restoration**: When you toggle through results on a node, Daggr also automatically selects the matching result on downstream nodes (if one exists). For example, if you generated 3 images and removed the background from 2 of them, selecting image #1 will automatically show background-removal result #1. #### Visual Staleness Indicators Daggr uses edge colors to show you which parts of your workflow are up-to-date: | Edge Color | Meaning | |------------|---------| | **Orange** | Fresh—the downstream node ran with this exact upstream value | | **Gray** | Stale—the upstream value has changed, or the downstream hasn't run yet | image image Edges are stale when: - You edit an input value (e.g., change a text prompt) - You select a different cached result on an upstream node - A downstream node hasn't been run yet This visual feedback helps you understand at a glance which results are current and which need to be re-run. It's especially useful in long workflows where you might forget which steps you've already executed with your current inputs. **Example workflow:** 1. Generate an image with prompt "A cheetah in the savanna" → edge turns orange 2. Edit the prompt to "A lion in the jungle" → edge turns gray (stale) 3. Re-run the image generation → edge turns orange again 4. Run the background removal node → that edge also turns orange This provenance tracking is particularly valuable for creative workflows where you're exploring variations and want to always know exactly what inputs produced each output. ### How Persistence Works | Environment | User Status | Persistence | |-------------|-------------|-------------| | **Local** | Not logged in | ✅ Saved as "local" user | | **Local** | HF logged in | ✅ Saved under your HF username | When running locally, your data is stored in a SQLite database at `~/.cache/huggingface/daggr/sessions.db`. ### The `persist_key` Parameter By default, the `persist_key` is derived from your graph's `name`: ```python Graph(name="My Podcast Generator") # persist_key = "my_podcast_generator" ``` If you later rename your app but want to keep the existing saved data, set `persist_key` explicitly: ```python Graph(name="Podcast Generator v2", persist_key="my_podcast_generator") ``` ### Disabling Persistence For scratch workflows or demos where you don't want data saved: ```python Graph(name="Quick Demo", persist_key=False) ``` This disables all persistence—no sheets UI, no saved state. ## Hugging Face Authentication Daggr automatically uses your local Hugging Face token for both `GradioNode` and `InferenceNode`. This enables: - **ZeroGPU quota tracking**: Your HF token is sent to Gradio Spaces running on ZeroGPU, so your usage is tracked against your account's quota - **Private Spaces access**: Connect to private Gradio Spaces you have access to - **Gated models**: Use gated models on Hugging Face that require accepting terms of service To log in with your Hugging Face account: ```bash pip install huggingface_hub hf auth login ``` You'll be prompted to enter your token, which you can find at https://huggingface.co/settings/tokens. Once logged in, the token is saved locally and daggr will automatically use it for all `GradioNode` and `InferenceNode` calls—no additional configuration needed. Alternatively, you can set the `HF_TOKEN` environment variable directly: ```bash export HF_TOKEN=hf_xxxxx ``` ## LLM-Friendly Error Messages Daggr is designed to be LLM-friendly, making it easy for AI coding assistants to generate and debug workflows. To give your AI coding assistant context on how to use daggr, you can install the daggr skill: ```bash npx skills add gradio-app/daggr ``` image When you (or an LLM) make a mistake, Daggr provides detailed, actionable error messages with suggestions: **Invalid API endpoint:** ``` ValueError: API endpoint '/infer' not found in 'hf-applications/background-removal'. Available endpoints: ['/image', '/text', '/png']. Did you mean '/image'? ``` **Typo in parameter name:** ``` ValueError: Invalid parameter(s) {'promt'} for endpoint '/generate_image' in 'hf-applications/Z-Image-Turbo'. Did you mean: 'promt' -> 'prompt'? Valid parameters: {'width', 'height', 'seed', 'prompt'} ``` **Missing required parameter:** ``` ValueError: Missing required parameter(s) {'prompt'} for endpoint '/generate_image' in 'hf-applications/Z-Image-Turbo'. These parameters have no default values. ``` **Invalid output port reference:** ``` ValueError: Output port 'img' not found on node 'Z-Image-Turbo'. Available outputs: image. Did you mean 'image'? ``` **Invalid function parameter:** ``` ValueError: Invalid input(s) {'toppic'} for function 'generate_dialogue'. Did you mean: 'toppic' -> 'topic'? Valid parameters: {'topic', 'host_voice', 'guest_voice'} ``` **Invalid model name:** ``` ValueError: Model 'meta-llama/nonexistent-model' not found on Hugging Face Hub. Please check the model name is correct (format: 'username/model-name'). ``` These errors make it easy for LLMs to understand what went wrong and fix the generated code automatically, enabling a smoother AI-assisted development experience. ### Discovering Output Formats When building workflows, LLMs can use `.test()` to discover a node's actual output format: ```python # LLM wants to understand what whisper returns whisper = InferenceNode("openai/whisper-large-v3", inputs={"audio": gr.Audio()}) result = whisper.test(audio="sample.wav") # Returns: {"text": "Hello, how are you?"} ``` This helps LLMs: - Understand the structure of node outputs - Apply `postprocess` functions to extract specific values - Create intermediate `FnNode`s to transform data between nodes For example, if a node returns multiple values but you only need one: ```python # After discovering the output format with .test() bg_remover = GradioNode( "hf-applications/background-removal", api_name="/image", inputs={"image": some_image.output}, postprocess=lambda original, final: final, # Keep only the second output outputs={"image": gr.Image()}, ) ``` ## Running Locally While in our examples above, we've seen how Daggr works with remote Gradio Spaces and Hugging Face Inference Providers, it's also well-suited for completely local, offline workflows. ### Automatic Local Execution The easiest way to run a Space locally is to set `run_locally=True` on any `GradioNode`. Daggr will automatically clone the Space, install dependencies in an isolated virtual environment, and launch the Gradio app: ```python from daggr import GradioNode, Graph import gradio as gr # Automatically clone and run the Space locally background_remover = GradioNode( "hf-applications/background-removal", api_name="/image", run_locally=True, # Run locally instead of calling the remote API inputs={"image": gr.Image(label="Input Image")}, outputs={"final_image": gr.Image(label="Output")}, ) graph = Graph(name="Local Background Removal", nodes=[background_remover]) graph.launch() ``` On first run, daggr will: 1. Clone the Space repository to `~/.cache/huggingface/daggr/spaces/` 2. Create an isolated virtual environment with the Space's dependencies 3. Launch the Gradio app on an available port 4. Connect to it automatically Subsequent runs reuse the cached clone and venv, making startup much faster. ### Graceful Fallback If local execution fails (missing dependencies, GPU requirements, etc.), daggr automatically falls back to the remote API and prints helpful guidance: ``` ⚠️ Local execution failed for 'owner/space-name' Reason: Failed to install dependencies Logs: ~/.cache/huggingface/daggr/logs/owner_space-name_pip_install_2026-01-27.log Falling back to remote API... ``` To disable fallback and see the full error (useful for debugging): ```bash export DAGGR_LOCAL_NO_FALLBACK=1 ``` ### Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `DAGGR_LOCAL_TIMEOUT` | `120` | Seconds to wait for the app to start | | `DAGGR_LOCAL_VERBOSE` | `0` | Set to `1` to show app stdout/stderr | | `DAGGR_LOCAL_NO_FALLBACK` | `0` | Set to `1` to disable fallback to remote | | `DAGGR_UPDATE_SPACES` | `0` | Set to `1` to re-clone cached Spaces | | `DAGGR_DEPENDENCY_CHECK` | *(unset)* | `skip`, `update`, or `error` — controls upstream hash checking | | `GRADIO_SERVER_NAME` | `127.0.0.1` | Host to bind to. Set to `0.0.0.0` on HF Spaces | | `GRADIO_SERVER_PORT` | `7860` | Port to bind to | ### Manual Local URL You can also run a Gradio app yourself and point to it directly: ```python from daggr import GradioNode, Graph import gradio as gr # Connect to a Gradio app you're running locally local_model = GradioNode( "http://localhost:7860", # Local URL instead of Space ID api_name="/predict", inputs={"text": gr.Textbox(label="Input")}, outputs={"result": gr.Textbox(label="Output")}, ) graph = Graph(name="Local Workflow", nodes=[local_model]) graph.launch() ``` This approach lets you run your entire workflow offline, use custom or fine-tuned models, and avoid API rate limits. ### API Access Daggr workflows can be called programmatically via REST API, making it easy to integrate workflows into other applications or run automated tests. #### Discovering the API Schema First, get the API schema to see available inputs and outputs: ```bash curl http://localhost:7860/api/schema ``` Response: ```json { "subgraphs": [ { "id": "main", "inputs": [ {"node": "image_gen", "port": "prompt", "type": "textbox", "id": "image_gen__prompt"} ], "outputs": [ {"node": "background_remover", "port": "image", "type": "image"} ] } ] } ``` #### Calling the Workflow Execute the entire workflow by POSTing inputs to `/api/call`: ```bash curl -X POST http://localhost:7860/api/call \ -H "Content-Type: application/json" \ -d '{"inputs": {"image_gen__prompt": "A mountain landscape"}}' ``` Response: ```json { "outputs": { "background_remover": { "image": "/file/path/to/output.png" } } } ``` Input keys follow the format `{node_name}__{port_name}` (with spaces/dashes replaced by underscores). #### Disconnected Subgraphs If your workflow has multiple disconnected subgraphs, use `/api/call/{subgraph_id}`: ```bash # List available subgraphs curl http://localhost:7860/api/schema # Call a specific subgraph curl -X POST http://localhost:7860/api/call/subgraph_0 \ -H "Content-Type: application/json" \ -d '{"inputs": {...}}' ``` #### Python Example ```python import requests # Get schema schema = requests.get("http://localhost:7860/api/schema").json() # Execute workflow response = requests.post( "http://localhost:7860/api/call", json={"inputs": {"my_node__text": "Hello world"}} ) outputs = response.json()["outputs"] ``` ## Hot Reload Mode During development, you can use the `daggr` CLI to run your app with automatic hot reloading. When you make changes to your Python file or its dependencies, the app automatically restarts: ```bash daggr examples/01_quickstart.py ``` This is much faster than manually stopping and restarting your app each time you make a change. ### CLI Options ```bash daggr ================================================ FILE: daggr/frontend/package.json ================================================ { "name": "daggr-frontend", "private": true, "version": "0.0.1", "type": "module", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" }, "dependencies": { "@babylonjs/viewer": "^8.47.2" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.3", "svelte": "^5.16.0", "vite": "^6.0.7" } } ================================================ FILE: daggr/frontend/src/App.svelte ================================================
{#each edgePaths as edge (edge.id)} {#if edge.forkPaths} {#each edge.forkPaths as forkD} {/each} {/if} {/each} {#each nodes as node (node.id)} {@const componentsToRender = getComponentsToRender(node)} {@const timeDisplay = getNodeTimeDisplay(node.name)}
{#if timeDisplay}
{timeDisplay.text}
{/if}
{node.type}{#if node.is_local} ⚡{/if} {#if node.url} {node.name} {:else} {node.name} {/if} {#if !node.is_input_node} {#key runModeVersion}
{#if runningNodes.has(node.name)} handleCancelNode(e, node.name)} title="Stop" role="button" tabindex="0" > {:else} handleRunNode(e, node.name)} onmouseenter={() => highlightRunTargets(node.name, getRunMode(node.name))} onmouseleave={() => clearHighlight()} title={(nodeRunModes[node.name] ?? 'toHere') === 'toHere' ? "Run to here" : "Run this step"} role="button" tabindex="0" > {#if node.is_map_node || (nodeRunModes[node.name] ?? 'toHere') === 'toHere'} {:else} {/if} {/if} toggleRunModeMenu(e, node.name)} role="button" tabindex="0" title="Run options" > {#if runModeMenuOpen === node.name}
clearHighlight()}>
{/if}
{/key} {/if}
{#each node.inputs as port (port.name)}
{port.name}
{/each}
{#each node.outputs as portName (portName)}
{portName}
{/each}
{#if nodeErrors[node.name]}
Error
{nodeErrors[node.name]}
{:else if node.variants && node.variants.length > 0} {@const currentVariantIdx = getSelectedVariant(node)}
{#each node.variants as variant, idx (idx)} {@const isSelected = idx === currentVariantIdx}
handleVariantSelect(node.id, idx)} role="button" tabindex="0" >
{#if isSelected}●{:else}○{/if} {variant.name}
{#if isSelected && variant.input_components.length > 0}
{#each variant.input_components as comp (comp.port_name)} handleInputChange(node.id, `variant_${idx}_${portName}`, value)} /> {/each}
{/if}
{/each}
{:else if componentsToRender.length > 0}
{#each componentsToRender as comp (comp.port_name)} handleInputChange(node.id, portName, value)} /> {/each}
{#if !node.is_input_node && getResultCount(node.name) > 1}
{(selectedResultIndex[node.name] ?? 0) + 1}/{getResultCount(node.name)}
{/if} {/if} {#if node.is_map_node && node.map_items && node.map_items.length > 0} {/if} {#if node.item_list_schema && node.item_list_items && node.item_list_items.length > 0} {/if}
{/each}
{zoomPercent}%
{graphData?.name || 'daggr'} {#if canPersist && sheets.length > 0} |
{#if editingSheetName} {:else} {/if} {#if sheetDropdownOpen}
{#each sheets as sheet (sheet.sheet_id)}
{#if sheets.length > 1} {/if}
{/each}
{/if}
{/if}
{#if !wsConnected}
Connecting...
{:else if !graphData}
Loading graph...
{/if} {#if hfUser && wsConnected && graphData}
{#if hfUser.avatar_url} {/if} {hfUser.username}
Your Hugging Face token is used for all GradioNode and InferenceNode calls. This enables ZeroGPU quota tracking and access to private Spaces and gated models.
{:else if wsConnected && graphData} {/if}
================================================ FILE: daggr/frontend/src/components/Audio.svelte ================================================
{label}
{#if editable && !isRecording && !src} {/if} {#if isRecording} {/if} {#if src && !isRecording} {/if}
{#if isRecording}
{formatTime(recordingTime)} Recording...
{:else if src} {:else}
No audio
{/if}
================================================ FILE: daggr/frontend/src/components/AudioPlayer.svelte ================================================
{formatTime(currentTime)} / {formatTime(duration)}
================================================ FILE: daggr/frontend/src/components/Button.svelte ================================================ ================================================ FILE: daggr/frontend/src/components/Checkbox.svelte ================================================
================================================ FILE: daggr/frontend/src/components/CheckboxGroup.svelte ================================================
{label}
{#each choices as [displayValue, internalValue]} {/each}
================================================ FILE: daggr/frontend/src/components/Code.svelte ================================================
{label} {language}
{#if editable} {:else}
{#if lineNumbers}
{#each lines as _, i} {i + 1} {/each}
{/if}
{value}
{/if}
================================================ FILE: daggr/frontend/src/components/ColorPicker.svelte ================================================
{label}
================================================ FILE: daggr/frontend/src/components/Dataframe.svelte ================================================
{label}
{#if value} {/if}
{#if value && value.data.length > 0}
{#each value.headers as header} {/each} {#each value.data as row, ri} {#each row as cell, ci} {/each} {/each}
#{header}
{ri + 1} startEdit(ri, ci, cell)} > {#if editingCell?.row === ri && editingCell?.col === ci} {:else} {cell ?? ''} {/if}
{:else}
No data
{/if}
================================================ FILE: daggr/frontend/src/components/Dialogue.svelte ================================================
{label}
{#if editable} {/if}
{#if value.length === 0}
No dialogue
{:else} {#each value as line, i}
{#if editable} {:else} {line.speaker} {/if}
{#if editable} {:else} {line.text} {/if}
{#if editable && value.length > 1} {/if}
{/each} {/if}
================================================ FILE: daggr/frontend/src/components/Dropdown.svelte ================================================
{label}
{#if isOpen && filteredChoices.length > 0}
{#each filteredChoices as [displayValue, internalValue]} {/each}
{/if} ================================================ FILE: daggr/frontend/src/components/EmbeddedComponent.svelte ================================================
{#if comp.component === 'textbox' || comp.component === 'text'} onchange?.(comp.port_name, v)} /> {:else if comp.component === 'number'}
{comp.props?.label || comp.port_name}
{:else if comp.component === 'checkbox'} {:else if comp.component === 'markdown'} {:else if comp.component === 'html'} {:else if comp.component === 'json'} onchange?.(comp.port_name, v)} /> {:else if comp.component === 'audio'}
================================================ FILE: daggr/frontend/src/components/File.svelte ================================================
{label}
{#if editable} {#if files.length > 0} {/if} {/if}
{#if files.length > 0}
{#each files as file, index}
{file.name} {formatSize(file.size)}
{#if editable} {/if}
{/each}
{:else if editable}
Drop files here or click to upload
{:else}
No file
{/if}
================================================ FILE: daggr/frontend/src/components/Gallery.svelte ================================================ {#if showPreview && selectedIndex !== null && items[selectedIndex]}
e.stopPropagation()}> {#if items.length > 1} {/if} {#if items[selectedIndex].image?.url} {items[selectedIndex].caption {:else if items[selectedIndex].video?.url} {/if} {#if items[selectedIndex].caption}
{items[selectedIndex].caption}
{/if}
{selectedIndex + 1} / {items.length}
{/if} ================================================ FILE: daggr/frontend/src/components/HighlightedText.svelte ================================================
{label}
{#if value && value.length > 0} {#if showLegend && allLabels.length > 0}
{#each allLabels as labelName}
{labelName}
{/each}
{/if}
{#each value as span} {#if span.label} {span.text} {#if showInlineCategory} {span.label} {/if} {:else} {span.text} {/if} {/each}
{:else}
No text
{/if}
================================================ FILE: daggr/frontend/src/components/Html.svelte ================================================
{#if showLabel && label} {label} {:else} {/if} {#if value} {/if}
{@html value}
================================================ FILE: daggr/frontend/src/components/Image.svelte ================================================
{label}
{#if showWebcam} {:else if src} {:else if editable} {/if}
{#if showWebcam}
{:else if src}
{label}
{:else}
No image
{/if}
================================================ FILE: daggr/frontend/src/components/ImageSlider.svelte ================================================
{label} {#if editable}
{/if}
{#if image1 || image2}
{#if image1} Before {:else}
No image
{/if}
{#if image2} After {:else}
No image
{/if}
Before After
{:else}
No images to compare
{/if}
================================================ FILE: daggr/frontend/src/components/ItemListSection.svelte ================================================
Items ({items.length})
{#each items as item (item.index)}
{#each schema as comp (comp.port_name)} {#if comp.component === 'dropdown'} {@const currentValue = getValue(nodeId, item.index, comp.port_name)} {:else if comp.component === 'textbox' || comp.component === 'text'}
{#if comp.props?.lines && comp.props.lines > 1} {:else} handleFieldChange(item.index, comp.port_name, (e.target as HTMLInputElement).value)} /> {/if}
{/if} {/each}
{/each}
================================================ FILE: daggr/frontend/src/components/Json.svelte ================================================
{label}
{#if value !== null && value !== undefined} {/if}
{#if editable}
{#if parseError} {parseError} {/if}
{:else if value !== null && value !== undefined}
{@render JsonNode({ value, depth: 0, maxOpen: typeof open === 'number' ? open : (open ? Infinity : 0), showIndices })}
{:else}
null
{/if}
{#snippet JsonNode(props: { value: any; depth: number; maxOpen: number; showIndices: boolean; key?: string | number })} {@const { value: nodeValue, depth, maxOpen, showIndices, key } = props} {@const isOpen = depth < maxOpen} {#if Array.isArray(nodeValue)}
{#if key !== undefined} {showIndices && typeof key === 'number' ? `[${key}]` : `"${key}"`} : {/if}
[{nodeValue.length} items]
{#each nodeValue as item, i} {@render JsonNode({ value: item, depth: depth + 1, maxOpen, showIndices, key: i })} {/each}
{:else if typeof nodeValue === 'object' && nodeValue !== null}
{#if key !== undefined} {showIndices && typeof key === 'number' ? `[${key}]` : `"${key}"`} : {/if}
{{Object.keys(nodeValue).length} keys}
{#each Object.entries(nodeValue) as [k, v]} {@render JsonNode({ value: v, depth: depth + 1, maxOpen, showIndices, key: k })} {/each}
{:else}
{#if key !== undefined} {showIndices && typeof key === 'number' ? `[${key}]` : `"${key}"`} : {/if} {#if typeof nodeValue === 'string'} "{nodeValue}" {:else if typeof nodeValue === 'number'} {nodeValue} {:else if typeof nodeValue === 'boolean'} {nodeValue ? 'true' : 'false'} {:else if nodeValue === null} null {:else} undefined {/if}
{/if} {/snippet} ================================================ FILE: daggr/frontend/src/components/Label.svelte ================================================
{label}
{#if value.label} {#if showHeading}
{value.label}
{/if} {#if sortedConfidences.length > 0}
{#each sortedConfidences as item, i}
{item.label} {formatConfidence(item.confidence)}
{/each}
{/if} {:else}
No label
{/if}
================================================ FILE: daggr/frontend/src/components/MapItemsSection.svelte ================================================
Items ({items.length})
{#each items as item (item.index)}
{#if item.is_audio_output && item.output} {:else if item.output} {item.output.length > 40 ? item.output.slice(0, 40) + '...' : item.output} {:else} Pending... {/if}
{/each}
================================================ FILE: daggr/frontend/src/components/Markdown.svelte ================================================
{#if showLabel && label} {label} {:else} {/if}
{@html renderedHtml}
================================================ FILE: daggr/frontend/src/components/Model3D.svelte ================================================
{label} {#if value}
{/if}
{#if value}
{#if loading || modelLoading}
{loading ? 'Loading 3D viewer...' : 'Loading model...'}
{/if} {#if error && !loading && !modelLoading}
⚠️ {error}
{/if}
{:else}
No model
{/if}
================================================ FILE: daggr/frontend/src/components/Number.svelte ================================================
{label}
================================================ FILE: daggr/frontend/src/components/Radio.svelte ================================================
{label}
{#each choices as [displayValue, internalValue]} {/each}
================================================ FILE: daggr/frontend/src/components/Slider.svelte ================================================
{label}
{min} {max}
================================================ FILE: daggr/frontend/src/components/Textbox.svelte ================================================
{label} {#if lines > 1} {:else} {/if}
================================================ FILE: daggr/frontend/src/components/Video.svelte ================================================
{label}
{#if isRecording} {:else if src} {:else if editable} {/if}
{#if isRecording && stream}
REC
{:else if src}
{:else}
No video
{/if}
================================================ FILE: daggr/frontend/src/components/index.ts ================================================ export { default as AudioPlayer } from './AudioPlayer.svelte'; export { default as Audio } from './Audio.svelte'; export { default as EmbeddedComponent } from './EmbeddedComponent.svelte'; export { default as MapItemsSection } from './MapItemsSection.svelte'; export { default as ItemListSection } from './ItemListSection.svelte'; export { default as Textbox } from './Textbox.svelte'; export { default as Image } from './Image.svelte'; export { default as Checkbox } from './Checkbox.svelte'; export { default as CheckboxGroup } from './CheckboxGroup.svelte'; export { default as Radio } from './Radio.svelte'; export { default as Dropdown } from './Dropdown.svelte'; export { default as Slider } from './Slider.svelte'; export { default as Number } from './Number.svelte'; export { default as Button } from './Button.svelte'; export { default as Video } from './Video.svelte'; export { default as File } from './File.svelte'; export { default as Dataframe } from './Dataframe.svelte'; export { default as Dialogue } from './Dialogue.svelte'; export { default as Gallery } from './Gallery.svelte'; export { default as Json } from './Json.svelte'; export { default as ColorPicker } from './ColorPicker.svelte'; export { default as Markdown } from './Markdown.svelte'; export { default as Html } from './Html.svelte'; export { default as Code } from './Code.svelte'; export { default as Label } from './Label.svelte'; export { default as HighlightedText } from './HighlightedText.svelte'; export { default as ImageSlider } from './ImageSlider.svelte'; export { default as Model3D } from './Model3D.svelte'; ================================================ FILE: daggr/frontend/src/main.ts ================================================ import App from './App.svelte' import { mount } from 'svelte' const app = mount(App, { target: document.getElementById('app')! }) export default app ================================================ FILE: daggr/frontend/src/types.ts ================================================ export interface Port { name: string; history_count?: number; } export interface GradioComponentData { component: string; type: string; port_name: string; props: Record; value?: any; } export interface MapItem { index: number; preview: string; output: string | null; is_audio_output: boolean; status?: string; } export interface ItemListItem { index: number; fields: Record; } export interface NodeVariant { name: string; input_components: GradioComponentData[]; output_components: GradioComponentData[]; } export interface GraphNode { id: string; name: string; type: string; url?: string; inputs: Port[]; outputs: string[]; input_components?: GradioComponentData[]; output_components?: GradioComponentData[]; x: number; y: number; status: string; is_output_node: boolean; is_input_node: boolean; is_map_node?: boolean; map_items?: MapItem[]; map_item_count?: number; item_list_schema?: GradioComponentData[]; item_list_items?: ItemListItem[]; variants?: NodeVariant[]; selected_variant?: number; } export interface GraphEdge { id: string; from_node: string; from_port: string; to_node: string; to_port: string; is_scattered?: boolean; is_gathered?: boolean; } export interface CanvasData { name: string; nodes: GraphNode[]; edges: GraphEdge[]; inputs?: Record>; session_id?: string; run_id?: string; completed_node?: string; } ================================================ FILE: daggr/frontend/svelte.config.js ================================================ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' export default { preprocess: vitePreprocess() } ================================================ FILE: daggr/frontend/vite.config.ts ================================================ import { defineConfig } from 'vite' import { svelte } from '@sveltejs/vite-plugin-svelte' export default defineConfig({ plugins: [svelte()], build: { outDir: 'dist', emptyOutDir: true, }, server: { port: 5173, proxy: { '/api': 'http://127.0.0.1:7860', '/ws': { target: 'ws://127.0.0.1:7860', ws: true } } } }) ================================================ FILE: daggr/graph.py ================================================ """Graph module for daggr. A Graph represents a directed acyclic graph (DAG) of nodes that can be executed to process data through a pipeline. """ from __future__ import annotations import itertools import os import re import sys import threading from collections.abc import Sequence from typing import TYPE_CHECKING, Any import networkx as nx from daggr._utils import suggest_similar from daggr.edge import Edge from daggr.local_space import prepare_local_node from daggr.node import ChoiceNode, GradioNode, InferenceNode, Node from daggr.port import Port if TYPE_CHECKING: from gradio.themes import ThemeClass as Theme def _parse_space_id(src: str) -> str | None: if src.startswith("http://") or src.startswith("https://"): match = re.match(r"https?://huggingface\.co/spaces/([^/]+/[^/?#]+)", src) if match: return match.group(1) return None if "/" in src: return src return None def _get_dependency_id(node) -> tuple[str | None, str]: if isinstance(node, GradioNode): space_id = _parse_space_id(node._src) return space_id, "space" elif isinstance(node, InferenceNode): return node._model_name_for_hub, "model" return None, "" def _fetch_current_sha(dep_id: str, dep_type: str) -> str | None: try: if dep_type == "space": from huggingface_hub import space_info info = space_info(dep_id) return info.sha elif dep_type == "model": from huggingface_hub import model_info info = model_info(dep_id) return info.sha except Exception: return None return None def _duplicate_space_at_revision( space_id: str, revision: str, username: str ) -> str | None: try: from huggingface_hub import ( create_repo, snapshot_download, upload_folder, ) space_name = space_id.split("/")[-1] new_repo_id = f"{username}/{space_name}" local_dir = snapshot_download( repo_id=space_id, repo_type="space", revision=revision, ) create_repo( repo_id=new_repo_id, repo_type="space", space_sdk="gradio", exist_ok=True, ) upload_folder( repo_id=new_repo_id, repo_type="space", folder_path=local_dir, ) return new_repo_id except Exception as e: print(f" [daggr] Failed to duplicate Space: {e}") return None def _prompt_dependency_changes(changed: list[dict]) -> None: from daggr import _client_cache is_tty = hasattr(sys.stdin, "isatty") and sys.stdin.isatty() print("\n ⚠️ Upstream dependency changes detected:\n") for item in changed: print( f" • {item['type']} '{item['id']}' (node: {item['node']._name})\n" f" cached: {item['cached_sha'][:12]}\n" f" current: {item['current_sha'][:12]}" ) print() if not is_tty: for item in changed: _client_cache.set_dependency_hash(item["id"], item["current_sha"]) print( " [daggr] Non-interactive mode: auto-updated all hashes.\n" " Set DAGGR_DEPENDENCY_CHECK=skip to suppress this warning.\n" ) return for item in changed: is_space = item["type"] == "space" if is_space: print( f" How would you like to handle '{item['id']}'?\n" f" [1] Duplicate the original version under your namespace (safer)\n" f" [2] Update to the latest version" ) else: print( f" How would you like to handle '{item['id']}'?\n" f" [1] Update to the latest version" ) try: choice = input(" Choice [1]: ").strip() or "1" except (EOFError, KeyboardInterrupt): choice = "1" if is_space and choice == "1": username = _get_hf_username() if username is None: print( " [daggr] Not logged in to Hugging Face. " "Updating hash instead.\n" " Run `huggingface-cli login` to enable Space duplication." ) _client_cache.set_dependency_hash(item["id"], item["current_sha"]) else: print( f" [daggr] Duplicating '{item['id']}' at revision " f"{item['cached_sha'][:12]} under {username}/..." ) new_id = _duplicate_space_at_revision( item["id"], item["cached_sha"], username ) if new_id: item["node"]._src = new_id _client_cache.set_dependency_hash(new_id, item["cached_sha"]) print( f" [daggr] Duplicated → '{new_id}'. " f"Node now points to duplicated Space." ) else: print( " [daggr] Duplication failed (revision may have been " "squashed). Updating hash instead." ) _client_cache.set_dependency_hash(item["id"], item["current_sha"]) else: _client_cache.set_dependency_hash(item["id"], item["current_sha"]) print(f" [daggr] Updated hash for '{item['id']}'.") print() def _get_hf_username() -> str | None: try: from huggingface_hub import get_token, whoami token = get_token() if not token: return None info = whoami(cache=True) return info.get("name") except Exception: return None class _Spinner: _CHARS = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" def __init__(self, message: str): self._message = message self._is_tty = hasattr(sys.stdout, "isatty") and sys.stdout.isatty() if self._is_tty: self._stop = threading.Event() self._thread = threading.Thread(target=self._spin, daemon=True) self._thread.start() def _spin(self): frames = itertools.cycle(self._CHARS) while not self._stop.is_set(): sys.stdout.write(f"\r {next(frames)} {self._message}") sys.stdout.flush() self._stop.wait(0.08) def _finish(self, symbol: str, suffix: str = ""): line = f" {symbol} {self._message}" if suffix: line += f" — {suffix}" if self._is_tty: self._stop.set() self._thread.join() sys.stdout.write(f"\r{line}\033[K\n") else: sys.stdout.write(f"{line}\n") sys.stdout.flush() def succeed(self, suffix: str = ""): self._finish("✓", suffix) def warn(self, suffix: str = ""): self._finish("⚠", suffix) def _get_node_display_label(node) -> str: if isinstance(node, GradioNode): label = node._src if node._api_name: label += f" ({node._api_name})" return label elif isinstance(node, InferenceNode): return node._model_name_for_hub return node._name class Graph: """A directed acyclic graph (DAG) of nodes for data processing. A Graph connects nodes together to form a pipeline. Data flows from entry nodes (nodes with no inputs) through the graph to output nodes. Example: >>> from daggr import Graph, FnNode >>> def step1(x): return {"out": x * 2} >>> def step2(y): return {"out": y + 1} >>> n1 = FnNode(step1) >>> n2 = FnNode(step2, inputs={"y": n1.out}) >>> graph = Graph("My Pipeline", nodes=[n2]) >>> graph.launch() """ def __init__( self, name: str, nodes: Sequence[Node] | None = None, persist_key: str | bool | None = None, ): """Create a new Graph. Args: name: Display name for this graph shown in the UI. nodes: Optional list of nodes to add to the graph. persist_key: Unique key used to store this graph's data in the database. If not provided, derived from name by converting to lowercase and replacing spaces/special chars with underscores. Set to False to disable persistence entirely. Use a custom string to ensure persistence works correctly if you change the display name later. """ if not name or not isinstance(name, str): raise ValueError( "Graph requires a 'name' parameter. " "Example: Graph(name='My Podcast Generator', nodes=[...])" ) self.name = name if persist_key is False: self.persist_key = None elif persist_key: self.persist_key = persist_key else: self.persist_key = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") self.nodes: dict[str, Node] = {} self._nx_graph = nx.DiGraph() self._edges: list[Edge] = [] if nodes: for node in nodes: self.add(node) def add(self, node: Node) -> Graph: """Add a node to the graph. Also adds any upstream nodes connected via the node's port connections. Args: node: The node to add. Returns: self, for method chaining. """ self._add_node(node) self._create_edges_from_port_connections(node) return self def edge(self, source: Port, target: Port) -> Graph: """Create an edge connecting two ports. Args: source: The source port (output of a node). target: The target port (input of a node). Returns: self, for method chaining. Raises: ValueError: If the edge would create a cycle. """ edge = Edge(source, target) self._add_edge(edge) return self def _add_node(self, node: Node) -> None: if node._name in self.nodes: if self.nodes[node._name] is not node: raise ValueError(f"Node with name '{node._name}' already exists") return self.nodes[node._name] = node self._nx_graph.add_node(node._name) def _create_edges_from_port_connections(self, node: Node) -> None: for target_port_name, source_port in node._port_connections.items(): source_node = source_port.node source_port_name = source_port.name if source_port_name not in source_node._output_ports: available = set(source_node._output_ports) suggestion = suggest_similar(source_port_name, available) available_str = ", ".join(available) or "(none)" msg = ( f"Output port '{source_port_name}' not found on node " f"'{source_node._name}'. Available outputs: {available_str}" ) if suggestion: msg += f" Did you mean '{suggestion}'?" raise ValueError(msg) is_new_node = source_node._name not in self.nodes self._add_node(source_node) if is_new_node: self._create_edges_from_port_connections(source_node) target_port = Port(node, target_port_name) edge = Edge(source_port, target_port) self._add_edge(edge) def _add_edge(self, edge: Edge) -> None: self._add_node(edge.source_node) self._add_node(edge.target_node) self._edges.append(edge) self._nx_graph.add_edge(edge.source_node._name, edge.target_node._name) if not nx.is_directed_acyclic_graph(self._nx_graph): self._nx_graph.remove_edge(edge.source_node._name, edge.target_node._name) self._edges.pop() raise ValueError("Connection would create a cycle in the DAG") def get_entry_nodes(self) -> list[Node]: """Get all nodes with no incoming edges (entry points of the graph).""" entry_nodes = [] for node_name in self.nodes: if self._nx_graph.in_degree(node_name) == 0: entry_nodes.append(self.nodes[node_name]) return entry_nodes def get_execution_order(self) -> list[str]: """Get the topologically sorted order of node names for execution.""" return list(nx.topological_sort(self._nx_graph)) def get_connections(self) -> list[tuple]: """Get all edges as tuples of (source_node, source_port, target_node, target_port).""" return [edge.as_tuple() for edge in self._edges] def _validate_edges(self) -> None: errors = [] for edge in self._edges: source_node = edge.source_node target_node = edge.target_node source_port = edge.source_port target_port = edge.target_port if source_port not in source_node._output_ports: available = set(source_node._output_ports) available_str = ", ".join(available) or "(none)" suggestion = suggest_similar(source_port, available) msg = ( f"Output port '{source_port}' not found on node " f"'{source_node._name}'. Available outputs: {available_str}" ) if suggestion: msg += f" Did you mean '{suggestion}'?" errors.append(msg) if target_port not in target_node._input_ports: available = set(target_node._input_ports) available_str = ", ".join(available) or "(none)" suggestion = suggest_similar(target_port, available) msg = ( f"Input port '{target_port}' not found on node " f"'{target_node._name}'. Available inputs: {available_str}" ) if suggestion: msg += f" Did you mean '{suggestion}'?" errors.append(msg) if errors: raise ValueError("Invalid port connections:\n - " + "\n - ".join(errors)) def launch( self, host: str | None = None, port: int | None = None, share: bool | None = None, open_browser: bool = True, theme: Theme | str | None = None, api_server: bool = True, **kwargs, ): """Launch the graph as an interactive web application. Starts a web server that displays the graph and allows users to execute nodes and view results. Args: host: Host to bind to. Defaults to GRADIO_SERVER_NAME env var, or "127.0.0.1" if not set. Set to "0.0.0.0" to make accessible on a network or when deploying to Hugging Face Spaces. port: Port to bind to. Defaults to GRADIO_SERVER_PORT env var, or 7860 if not set. share: If True, create a public share link. Defaults to True in Colab/Kaggle environments, False otherwise. open_browser: If True, automatically open the app in the default web browser. Defaults to True. theme: A Gradio theme to use for styling. Can be a Gradio `Theme` instance, a string name like "default", "soft", "monochrome", "glass", or a Hub theme like "gradio/seafoam". Defaults to the Gradio default theme. api_server: If True, expose the programmatic API endpoints (/api/call, /api/schema). Defaults to True. **kwargs: Additional arguments passed to uvicorn. """ from daggr.server import DaggrServer if host is None: host = os.environ.get("GRADIO_SERVER_NAME", "127.0.0.1") if port is None: port = int(os.environ.get("GRADIO_SERVER_PORT", "7860")) self._startup_display() server = DaggrServer(self, theme=theme, api_server=api_server) server.run( host=host, port=port, share=share, open_browser=open_browser, **kwargs ) def _prepare_local_nodes(self) -> None: for node in self.nodes.values(): if isinstance(node, ChoiceNode): for variant in node._variants: if isinstance(variant, GradioNode) and variant._run_locally: prepare_local_node(variant) elif isinstance(node, GradioNode) and node._run_locally: prepare_local_node(node) def _check_dependency_hashes(self) -> None: mode = os.environ.get("DAGGR_DEPENDENCY_CHECK", "").lower() if mode == "skip": return from daggr import _client_cache nodes_to_check: list[GradioNode | InferenceNode] = [] for node in self.nodes.values(): if isinstance(node, ChoiceNode): for variant in node._variants: if isinstance(variant, (GradioNode, InferenceNode)): nodes_to_check.append(variant) elif isinstance(node, (GradioNode, InferenceNode)): nodes_to_check.append(node) if not nodes_to_check: return changed: list[dict[str, Any]] = [] for node in nodes_to_check: dep_id, dep_type = _get_dependency_id(node) if dep_id is None: continue current_sha = _fetch_current_sha(dep_id, dep_type) if current_sha is None: continue cached_sha = _client_cache.get_dependency_hash(dep_id) if cached_sha is None: _client_cache.set_dependency_hash(dep_id, current_sha) elif cached_sha != current_sha: changed.append( { "type": dep_type, "id": dep_id, "node": node, "cached_sha": cached_sha, "current_sha": current_sha, } ) if not changed: return if mode == "update": for item in changed: _client_cache.set_dependency_hash(item["id"], item["current_sha"]) print( f" [daggr] Auto-updated hash for {item['type']} " f"'{item['id']}' → {item['current_sha'][:12]}" ) return if mode == "error": descs = [ f" • {item['type']} '{item['id']}': " f"{item['cached_sha'][:12]} → {item['current_sha'][:12]}" for item in changed ] raise RuntimeError( "Upstream dependencies have changed:\n" + "\n".join(descs) + "\nSet DAGGR_DEPENDENCY_CHECK=update to accept changes." ) _prompt_dependency_changes(changed) def _startup_display(self) -> None: mode = os.environ.get("DAGGR_DEPENDENCY_CHECK", "").lower() skip_hashes = mode == "skip" node_count = len(self.nodes) noun = "node" if node_count == 1 else "nodes" print(f"\n Launching Daggr ({self.name}) with {node_count} {noun}:\n") from daggr import _client_cache changed: list[dict[str, Any]] = [] def _check_hash(node): dep_id, dep_type = _get_dependency_id(node) if dep_id is None: return None current_sha = _fetch_current_sha(dep_id, dep_type) if current_sha is None: return None cached_sha = _client_cache.get_dependency_hash(dep_id) if cached_sha is None: _client_cache.set_dependency_hash(dep_id, current_sha) return ("recorded", f"hash {current_sha[:7]} recorded") elif cached_sha == current_sha: return ("matches", f"hash {current_sha[:7]} matches") else: changed.append( { "type": dep_type, "id": dep_id, "node": node, "cached_sha": cached_sha, "current_sha": current_sha, } ) return ("changed", "hash changed") for node in self.nodes.values(): if isinstance(node, ChoiceNode): spinner = _Spinner(node._name) for variant in node._variants: if isinstance(variant, GradioNode) and variant._run_locally: prepare_local_node(variant) results = [] if not skip_hashes: for variant in node._variants: if isinstance(variant, (GradioNode, InferenceNode)): result = _check_hash(variant) if result: results.append(result) if any(r[0] == "changed" for r in results): spinner.warn("hash changed") elif results: spinner.succeed(results[-1][1]) else: spinner.succeed() continue if isinstance(node, GradioNode) and node._run_locally: prepare_local_node(node) label = _get_node_display_label(node) if isinstance(node, (GradioNode, InferenceNode)) and not skip_hashes: spinner = _Spinner(label) result = _check_hash(node) if result and result[0] == "changed": spinner.warn(result[1]) elif result: spinner.succeed(result[1]) else: spinner.succeed() else: sys.stdout.write(f" ✓ {label}\n") sys.stdout.flush() print() if not changed: return if mode == "update": for item in changed: _client_cache.set_dependency_hash(item["id"], item["current_sha"]) print( f" [daggr] Auto-updated hash for {item['type']} " f"'{item['id']}' → {item['current_sha'][:12]}" ) return if mode == "error": descs = [ f" • {item['type']} '{item['id']}': " f"{item['cached_sha'][:12]} → {item['current_sha'][:12]}" for item in changed ] raise RuntimeError( "Upstream dependencies have changed:\n" + "\n".join(descs) + "\nSet DAGGR_DEPENDENCY_CHECK=update to accept changes." ) _prompt_dependency_changes(changed) def get_subgraphs(self) -> list[set[str]]: """Get all weakly connected components of the graph. Returns a list of sets, where each set contains the node names belonging to a connected subgraph. If the graph is fully connected, returns a single set with all node names. """ return [set(c) for c in nx.weakly_connected_components(self._nx_graph)] def get_output_nodes(self) -> list[str]: """Get all nodes with no outgoing edges (output/leaf nodes).""" return [ node_name for node_name in self.nodes if self._nx_graph.out_degree(node_name) == 0 ] def get_api_schema(self) -> dict: """Get the API schema describing inputs and outputs for each subgraph. Returns a dict with: - subgraphs: list of subgraph info, each containing: - id: subgraph identifier (e.g., "main" or "subgraph_0") - inputs: list of {node, port, type, component} for each input - outputs: list of {node, port, type, component} for each output """ subgraphs = self.get_subgraphs() output_nodes = set(self.get_output_nodes()) result = {"subgraphs": []} for idx, subgraph_nodes in enumerate(subgraphs): subgraph_id = "main" if len(subgraphs) == 1 else f"subgraph_{idx}" inputs = [] outputs = [] for node_name in subgraph_nodes: node = self.nodes[node_name] if isinstance(node, ChoiceNode): continue if node._input_components: for port_name, comp in node._input_components.items(): comp_type = self._get_component_type(comp) inputs.append( { "node": node_name, "port": port_name, "type": comp_type, "id": f"{node_name}__{port_name}".replace( " ", "_" ).replace("-", "_"), } ) if node_name in output_nodes and node._output_components: for port_name, comp in node._output_components.items(): if comp is None: continue comp_type = self._get_component_type(comp) outputs.append( { "node": node_name, "port": port_name, "type": comp_type, } ) result["subgraphs"].append( { "id": subgraph_id, "inputs": inputs, "outputs": outputs, } ) return result def _get_component_type(self, component) -> str: """Get the type string for a Gradio component.""" class_name = component.__class__.__name__ type_map = { "Audio": "audio", "Textbox": "textbox", "TextArea": "textarea", "JSON": "json", "Chatbot": "json", "Image": "image", "Number": "number", "Markdown": "markdown", "Text": "text", "Dropdown": "dropdown", "Video": "video", "File": "file", "Model3D": "model3d", "Gallery": "gallery", "Slider": "slider", "Radio": "radio", "Checkbox": "checkbox", "CheckboxGroup": "checkboxgroup", "ColorPicker": "colorpicker", "Label": "label", "HighlightedText": "highlightedtext", "Code": "code", "HTML": "html", "Dataframe": "dataframe", } return type_map.get(class_name, "text") def __repr__(self): return f"Graph(name={self.name}, nodes={len(self.nodes)}, edges={len(self._edges)})" ================================================ FILE: daggr/local_space.py ================================================ from __future__ import annotations import atexit import hashlib import json import os import re import select import shutil import socket import subprocess import sys import time import urllib.error import urllib.request from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from daggr.node import GradioNode from daggr.state import get_daggr_cache_dir def _get_spaces_cache_dir() -> Path: return get_daggr_cache_dir() / "spaces" def _get_logs_dir() -> Path: return get_daggr_cache_dir() / "logs" _running_processes: dict[str, subprocess.Popen] = {} def _get_space_dir(space_id: str) -> Path: spaces_dir = _get_spaces_cache_dir() parts = space_id.split("/") if len(parts) == 2: owner, name = parts return spaces_dir / owner / name return spaces_dir / space_id.replace("/", "_") def _get_metadata_path(space_dir: Path) -> Path: return space_dir / ".daggr_metadata.json" def _hash_file(file_path: Path) -> str: if not file_path.exists(): return "" return hashlib.sha256(file_path.read_bytes()).hexdigest()[:16] def _find_free_port(start: int = 7861, end: int = 7960) -> int: for port in range(start, end): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: try: s.bind(("127.0.0.1", port)) return port except OSError: continue raise RuntimeError(f"No free ports available in range {start}-{end}") def _is_space_id(src: str) -> bool: if src.startswith("http://") or src.startswith("https://"): return False return "/" in src and not src.startswith("/") class LocalSpaceManager: def __init__(self, node: GradioNode): self.node = node self.space_id = node._src self.space_dir = _get_space_dir(self.space_id) self.repo_dir = self.space_dir / "repo" self.venv_dir = self.space_dir / ".venv" self.metadata_path = _get_metadata_path(self.space_dir) self.process: subprocess.Popen | None = None self.local_url: str | None = None def ensure_ready(self) -> str: if not _is_space_id(self.space_id): raise ValueError( f"Cannot run locally: '{self.space_id}' is not a valid Space ID. " "Local mode only works with Hugging Face Spaces (format: 'owner/space-name')." ) try: self._ensure_cloned() self._ensure_venv() url = self._launch_app() return url except Exception as e: self._log_error(e) raise def _ensure_cloned(self) -> None: metadata = self._load_metadata() if self.repo_dir.exists() and metadata: should_update = os.environ.get("DAGGR_UPDATE_SPACES") == "1" if not should_update: return self.space_dir.mkdir(parents=True, exist_ok=True) from huggingface_hub import snapshot_download print(f" Cloning Space '{self.space_id}'...") if self.repo_dir.exists(): shutil.rmtree(self.repo_dir) snapshot_download( repo_id=self.space_id, repo_type="space", local_dir=self.repo_dir, ) requirements_path = self.repo_dir / "requirements.txt" metadata = { "cloned_at": datetime.now().isoformat(), "space_id": self.space_id, "requirements_hash": _hash_file(requirements_path), "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", } self._save_metadata(metadata) print(f" Cloned to {self.repo_dir}") def _get_sdk_version(self) -> str | None: readme_path = self.repo_dir / "README.md" if not readme_path.exists(): return None try: content = readme_path.read_text() if not content.startswith("---"): return None parts = content.split("---", 2) if len(parts) < 3: return None match = re.search(r"sdk_version:\s*['\"]?([^\s'\"]+)", parts[1]) if match: return match.group(1) except Exception: pass return None def _ensure_venv(self) -> None: requirements_path = self.repo_dir / "requirements.txt" current_hash = _hash_file(requirements_path) metadata = self._load_metadata() venv_python = self.venv_dir / "bin" / "python" if sys.platform == "win32": venv_python = self.venv_dir / "Scripts" / "python.exe" needs_reinstall = False if not self.venv_dir.exists() or not venv_python.exists(): needs_reinstall = True elif metadata and metadata.get("requirements_hash") != current_hash: needs_reinstall = True if not needs_reinstall: return print(f" Setting up virtual environment for '{self.space_id}'...") if self.venv_dir.exists(): shutil.rmtree(self.venv_dir) subprocess.run( [sys.executable, "-m", "venv", str(self.venv_dir)], check=True, capture_output=True, ) pip_path = self.venv_dir / "bin" / "pip" if sys.platform == "win32": pip_path = self.venv_dir / "Scripts" / "pip.exe" subprocess.run( [str(pip_path), "install", "--upgrade", "pip"], check=True, capture_output=True, ) sdk_version = self._get_sdk_version() if sdk_version: gradio_pkg = f"gradio=={sdk_version}" print(f" Installing {gradio_pkg}...") else: gradio_pkg = "gradio" print(" Installing gradio (latest)...") result = subprocess.run( [str(pip_path), "install", gradio_pkg], capture_output=True, text=True, ) if result.returncode != 0: error_msg = result.stderr or result.stdout self._log_to_file("pip_install_gradio", error_msg) print(f" Warning: Failed to install {gradio_pkg}") if requirements_path.exists(): print(f" Installing dependencies from {requirements_path}...") print(" (this may take a few minutes)") process = subprocess.Popen( [str(pip_path), "install", "-r", str(requirements_path)], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) output_lines = [] for line in iter(process.stdout.readline, ""): output_lines.append(line) line_stripped = line.strip() if line_stripped.startswith("Collecting "): pkg = line_stripped.replace("Collecting ", "").split()[0] print(f" Installing {pkg}...") elif ( line_stripped.startswith("ERROR:") or "error" in line_stripped.lower() ): print(f" {line_stripped}") process.wait() if process.returncode != 0: error_msg = "".join(output_lines) self._log_to_file("pip_install", error_msg) print("\n ❌ Dependency installation failed!") print(f" Full log: {self._get_log_path('pip_install')}") raise RuntimeError( f"Failed to install dependencies for '{self.space_id}'.\n" f"See logs at: {self._get_log_path('pip_install')}\n" f"You can try installing manually:\n" f" {pip_path} install -r {requirements_path}" ) if metadata: metadata["requirements_hash"] = current_hash self._save_metadata(metadata) print(" Virtual environment ready") def _launch_app(self) -> str: global _running_processes if self.space_id in _running_processes: proc = _running_processes[self.space_id] if proc.poll() is None: metadata = self._load_metadata() if metadata and metadata.get("local_url"): return metadata["local_url"] app_file = self._find_app_file() if not app_file: raise RuntimeError( f"No app.py or main.py found in '{self.space_id}'. " "Cannot determine how to launch this Space." ) port = _find_free_port() local_url = f"http://127.0.0.1:{port}" venv_python = self.venv_dir / "bin" / "python" if sys.platform == "win32": venv_python = self.venv_dir / "Scripts" / "python.exe" timeout = int(os.environ.get("DAGGR_LOCAL_TIMEOUT", "120")) env = os.environ.copy() env["GRADIO_SERVER_PORT"] = str(port) env["GRADIO_SERVER_NAME"] = "127.0.0.1" env["PYTHONUNBUFFERED"] = "1" print(f" Launching '{self.space_id}' on port {port}...") print(f" Waiting for app to start (timeout: {timeout}s)...") log_file = self._get_log_path("launch") log_file.parent.mkdir(parents=True, exist_ok=True) self.process = subprocess.Popen( [str(venv_python), str(app_file)], cwd=str(self.repo_dir), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) _running_processes[self.space_id] = self.process ready, error_output = self._wait_for_ready(local_url, timeout, verbose=True) if not ready: self._log_to_file("launch", error_output) if self.process.poll() is None: self.process.terminate() print("\n ❌ Space failed to start!") if error_output: error_lines = error_output.strip().split("\n") relevant_lines = [ln for ln in error_lines if ln.strip()][-10:] if relevant_lines: print(" Last output:") for line in relevant_lines: print(f" {line}") print(f" Full log: {log_file}") raise RuntimeError( f"Space '{self.space_id}' failed to start.\n" f"See logs at: {log_file}\n" "Suggestions:\n" " 1. Some Spaces require GPU hardware\n" " 2. Check the Space's README for requirements\n" " 3. Set DAGGR_LOCAL_VERBOSE=1 to see all output" ) metadata = self._load_metadata() or {} metadata["local_url"] = local_url metadata["last_successful_launch"] = datetime.now().isoformat() self._save_metadata(metadata) print(f" Space running at {local_url}") return local_url def _find_app_file(self) -> Path | None: for name in ["app.py", "main.py", "demo.py"]: path = self.repo_dir / name if path.exists(): return path return None def _wait_for_ready( self, url: str, timeout: int, verbose: bool = False ) -> tuple[bool, str]: output_lines: list[str] = [] start = time.time() last_status_time = start saw_error = False while time.time() - start < timeout: if self.process and self.process.stdout: while True: if sys.platform == "win32": line = self.process.stdout.readline() if not line: break else: ready, _, _ = select.select([self.process.stdout], [], [], 0) if not ready: break line = self.process.stdout.readline() if line: output_lines.append(line) line_lower = line.lower() if ( "traceback" in line_lower or "modulenotfounderror" in line_lower ): saw_error = True if verbose: print(f" [app] {line.rstrip()}") exit_code = self.process.poll() if self.process else None if exit_code is not None: if self.process and self.process.stdout: remaining = self.process.stdout.read() if remaining: output_lines.append(remaining) if verbose: for rem_line in remaining.strip().split("\n"): if rem_line.strip(): print(f" [app] {rem_line}") print(f" App process exited with code {exit_code}") return False, "".join(output_lines) if saw_error: time.sleep(0.5) if self.process and self.process.poll() is not None: if self.process.stdout: remaining = self.process.stdout.read() if remaining: output_lines.append(remaining) print(" App crashed during startup") return False, "".join(output_lines) elapsed = time.time() - start if elapsed - (last_status_time - start) >= 10: print(f" Still waiting... ({int(elapsed)}s elapsed)") last_status_time = time.time() try: with urllib.request.urlopen(url, timeout=2) as response: if response.status == 200: return True, "".join(output_lines) except (urllib.error.URLError, OSError): pass time.sleep(0.3) return False, "".join(output_lines) def _load_metadata(self) -> dict[str, Any] | None: if not self.metadata_path.exists(): return None try: return json.loads(self.metadata_path.read_text()) except (json.JSONDecodeError, OSError): return None def _save_metadata(self, metadata: dict[str, Any]) -> None: self.metadata_path.parent.mkdir(parents=True, exist_ok=True) self.metadata_path.write_text(json.dumps(metadata, indent=2)) def _get_log_path(self, log_type: str) -> Path: logs_dir = _get_logs_dir() logs_dir.mkdir(parents=True, exist_ok=True) safe_name = self.space_id.replace("/", "_") timestamp = datetime.now().strftime("%Y-%m-%d") return logs_dir / f"{safe_name}_{log_type}_{timestamp}.log" def _log_to_file(self, log_type: str, content: str) -> None: log_path = self._get_log_path(log_type) log_path.parent.mkdir(parents=True, exist_ok=True) with open(log_path, "w") as f: f.write(f"Timestamp: {datetime.now().isoformat()}\n") f.write(f"Space: {self.space_id}\n") f.write(f"Type: {log_type}\n") f.write("=" * 50 + "\n") f.write(content) def _log_error(self, error: Exception) -> None: self._log_to_file("error", str(error)) def prepare_local_node(node: GradioNode) -> None: if node._local_failed or node._local_url: return if not _is_space_id(node._src): return no_fallback = os.environ.get("DAGGR_LOCAL_NO_FALLBACK") == "1" try: manager = LocalSpaceManager(node) url = manager.ensure_ready() node._local_url = url except Exception as e: node._local_failed = True safe_name = node._src.replace("/", "_") print(f"\n ⚠️ Local setup failed for '{node._src}'") print(f" Reason: {e}") print(f" Logs: {_get_logs_dir()}/{safe_name}_*.log") if no_fallback: raise RuntimeError( f"Local execution failed for '{node._src}' and fallback is disabled. " f"Error: {e}" ) from e print(" Will fall back to remote API at execution time.\n") def get_local_client(node: GradioNode) -> Any: if node._local_failed: return None if node._local_url: from gradio_client import Client return Client(node._local_url, download_files=False, verbose=False) return None def cleanup_local_processes() -> None: global _running_processes for space_id, proc in list(_running_processes.items()): if proc.poll() is None: proc.terminate() try: proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() _running_processes.clear() atexit.register(cleanup_local_processes) ================================================ FILE: daggr/node.py ================================================ """Node types for daggr graphs. This module defines the various node types that can be used in a daggr graph: - Node: Abstract base class for all nodes - GradioNode: Wraps a Gradio Space or endpoint - InferenceNode: Wraps a Hugging Face Inference API model - FnNode: Wraps a Python function - InteractionNode: Represents user interaction points """ from __future__ import annotations import inspect import warnings from abc import ABC from collections.abc import Callable from typing import Any from daggr._utils import suggest_similar from daggr.port import ItemList, Port, PortNamespace, is_port _FILE_TYPE_COMPONENTS = { "Image", "Audio", "Video", "File", "Gallery", "ImageEditor", "ImageSlider", } def _warn_if_type_set(component: Any, port_name: str) -> None: constructor_args = getattr(component, "_constructor_args", None) if not constructor_args: return comp_type = constructor_args[0].get("type") if comp_type is None: return class_name = type(component).__name__ if class_name not in _FILE_TYPE_COMPONENTS: return if comp_type != "filepath": warnings.warn( f"Gradio component {class_name}(type={comp_type!r}) on port '{port_name}': " f"daggr ignores the `type` parameter. All file data is passed as file path " f"strings regardless of this setting.", stacklevel=4, ) def _is_gradio_component(obj: Any) -> bool: if obj is None: return False class_name = obj.__class__.__name__ module = getattr(obj.__class__, "__module__", "") return "gradio" in module or class_name in ( "Textbox", "TextArea", "Audio", "Image", "JSON", "Markdown", "Number", "Checkbox", "Dropdown", "Radio", "Slider", "File", "Video", "Gallery", "Chatbot", "Text", ) class Node(ABC): """Abstract base class for all nodes in a daggr graph. Nodes represent processing steps in a DAG. Each node has named input and output ports that can be connected to form a data processing pipeline. Ports can be accessed as attributes: `node.port_name` returns a Port object. Args: name: Optional display name for the node. If not provided, a name will be auto-generated based on the node type. """ _id_counter = 0 def __init__(self, name: str | None = None): self._id = Node._id_counter Node._id_counter += 1 self._name = name or "" self._name_explicitly_set = bool(name) self._input_ports: list[str] = [] self._output_ports: list[str] = [] self._input_components: dict[str, Any] = {} self._output_components: dict[str, Any] = {} self._item_list_schemas: dict[str, dict[str, Any]] = {} self._fixed_inputs: dict[str, Any] = {} self._port_connections: dict[str, Any] = {} @property def name(self) -> str: return self._name @name.setter def name(self, value: str) -> None: self._name = value self._name_explicitly_set = True def __getattr__(self, name: str) -> Port: if name.startswith("_"): raise AttributeError(name) return Port(self, name) def __dir__(self) -> list[str]: base = ["_name", "_inputs", "_outputs", "_input_ports", "_output_ports"] return base + self._input_ports + self._output_ports def __or__(self, other: Node) -> ChoiceNode: """Combine two nodes as alternatives using the | operator. Returns a ChoiceNode that lets users pick which variant to run. Example: >>> tts = GradioNode("space1/tts", ...) | GradioNode("space2/tts", ...) >>> # tts.audio works regardless of which variant is selected """ if isinstance(other, ChoiceNode): return ChoiceNode([self] + other._variants, name=self._name) return ChoiceNode([self, other], name=self._name) @property def _inputs(self) -> PortNamespace: return PortNamespace(self, self._input_ports) @property def _outputs(self) -> PortNamespace: return PortNamespace(self, self._output_ports) def _default_output_port(self) -> Port: if self._output_ports: return Port(self, self._output_ports[0]) return Port(self, "output") def _default_input_port(self) -> Port: if self._input_ports: return Port(self, self._input_ports[0]) return Port(self, "input") def _validate_ports(self): all_ports = set(self._input_ports + self._output_ports) underscore_ports = [p for p in all_ports if p.startswith("_")] if underscore_ports: warnings.warn( f"Port names {underscore_ports} start with underscore. " f"Use node._inputs.{underscore_ports[0]} or node._outputs.{underscore_ports[0]} to access." ) def _process_inputs(self, inputs: dict[str, Any]) -> None: for port_name, value in inputs.items(): self._input_ports.append(port_name) if is_port(value): self._port_connections[port_name] = value elif _is_gradio_component(value): _warn_if_type_set(value, port_name) self._input_components[port_name] = value else: self._fixed_inputs[port_name] = value def _process_outputs(self, outputs: dict[str, Any]) -> None: for port_name, component in outputs.items(): self._output_ports.append(port_name) if component is not None and _is_gradio_component(component): _warn_if_type_set(component, port_name) self._output_components[port_name] = component def test(self, **inputs) -> dict[str, Any]: """Test-run this node in isolation and return the raw result. If no inputs are provided, auto-generates example values using: - Gradio component's .example_value() method - Port's associated output component's .example_value() - Callable inputs are called - Fixed values are used directly Args: **inputs: Override inputs for the test run. Returns: Dict mapping output port names to their values. Example: >>> tts = GradioNode("mrfakename/MeloTTS", api_name="/synthesize", ...) >>> result = tts.test(text="Hello world", speaker="EN-US") >>> # Returns: {"audio": "/path/to/audio.wav"} >>> >>> # Or with auto-generated example values: >>> result = tts.test() """ from daggr import Graph from daggr.executor import SequentialExecutor if not inputs: inputs = self._generate_example_inputs() graph = Graph("_test", nodes=[self], persist_key=False) executor = SequentialExecutor(graph) return executor.execute_node(self._name, inputs) def _generate_example_inputs(self) -> dict[str, Any]: """Generate example values for all input ports.""" examples = {} # From input components (Gradio components) for port_name, comp in self._input_components.items(): if hasattr(comp, "example_value"): examples[port_name] = comp.example_value() # From fixed inputs (constants, callables, or port connections) for port_name, source in self._fixed_inputs.items(): if callable(source): examples[port_name] = source() else: examples[port_name] = source # From port connections (use the connected port's output component) for port_name, port in self._port_connections.items(): if is_port(port): comp = port._node._output_components.get(port._port_name) if comp and hasattr(comp, "example_value"): examples[port_name] = comp.example_value() return examples def __repr__(self): return f"{self.__class__.__name__}(name={self._name})" class ChoiceNode(Node): """A node that wraps multiple alternative nodes. ChoiceNode allows users to select which variant to run from a set of alternatives. Created using the | operator between nodes. The output ports are the union of all variants' output ports, so downstream nodes can connect to any output that exists in at least one variant. Args: variants: List of Node objects that serve as alternatives. name: Optional display name. Defaults to the first variant's name. Example: >>> tts = GradioNode("space1/tts", ...) | GradioNode("space2/tts", ...) >>> # tts is a ChoiceNode with two variants >>> # tts.audio works regardless of which variant is selected """ def __init__( self, variants: list[Node], name: str | None = None, ): if not variants: raise ValueError("ChoiceNode requires at least one variant") super().__init__(name) self._variants = variants self._selected_variant = 0 if not self._name: self._name = variants[0]._name self._output_ports = self._compute_union_output_ports() self._output_components = self._compute_union_output_components() for variant in variants: for port_name, port in variant._port_connections.items(): if port_name not in self._port_connections: self._port_connections[port_name] = port def _compute_union_output_ports(self) -> list[str]: seen = set() ports = [] for variant in self._variants: for port in variant._output_ports: if port not in seen: seen.add(port) ports.append(port) return ports def _compute_union_output_components(self) -> dict[str, Any]: components = {} for variant in self._variants: for port_name, comp in variant._output_components.items(): if port_name not in components: components[port_name] = comp return components def __or__(self, other: Node) -> ChoiceNode: if isinstance(other, ChoiceNode): return ChoiceNode(self._variants + other._variants, name=self._name) return ChoiceNode(self._variants + [other], name=self._name) def __repr__(self): variant_names = [v._name for v in self._variants] return f"ChoiceNode(name={self._name}, variants={variant_names})" class GradioNode(Node): """A node that wraps a Gradio Space or endpoint. GradioNode connects to a Hugging Face Space or any Gradio app and exposes its API as a node in the graph. Args: space_or_url: Hugging Face Space ID (e.g., "username/space-name") or a full URL to a Gradio app. api_name: The API endpoint to call (e.g., "/predict"). Defaults to "/predict". name: Optional display name for the node. inputs: Dict mapping input port names to Gradio components, Port connections, or fixed values. outputs: Dict mapping output port names to Gradio components for display. validate: Whether to validate the Space exists and has the specified endpoint. run_locally: If True, clone and run the Space locally instead of using the remote API. Example: >>> tts = GradioNode( ... "mrfakename/MeloTTS", ... api_name="/synthesize", ... inputs={"text": gr.Textbox(), "speaker": "EN-US"}, ... outputs={"audio": gr.Audio()}, ... ) """ _name_counters: dict[str, int] = {} def __init__( self, space_or_url: str, api_name: str | None = None, name: str | None = None, inputs: dict[str, Any] | None = None, outputs: dict[str, Any] | None = None, validate: bool = True, run_locally: bool = False, preprocess: Callable[[dict], dict] | None = None, postprocess: Callable[..., Any] | None = None, ): super().__init__(name) self._src = space_or_url self._api_name = api_name self._run_locally = run_locally self._local_url: str | None = None self._local_failed = False self._preprocess = preprocess self._postprocess = postprocess if validate: self._validate_space_format() if not self._name: base_name = self._src.split("/")[-1] if base_name not in GradioNode._name_counters: GradioNode._name_counters[base_name] = 0 self._name = base_name else: GradioNode._name_counters[base_name] += 1 self._name = f"{base_name}_{GradioNode._name_counters[base_name]}" self._process_inputs(inputs or {}) self._process_outputs(outputs or {}) self._validate_ports() if validate and not run_locally: self._validate_gradio_api(inputs or {}, outputs or {}) def _validate_space_format(self) -> None: src = self._src if not ("/" in src or src.startswith("http://") or src.startswith("https://")): raise ValueError( f"Invalid space_or_url '{src}'. Expected format: 'username/space-name' " f"or a full URL like 'https://...'" ) def _get_api_info(self) -> dict: from daggr import _client_cache cached = _client_cache.get_api_info(self._src) if cached is not None: return cached from gradio_client import Client client = _client_cache.get_client(self._src) if client is None: client = Client(self._src, download_files=False, verbose=False) _client_cache.set_client(self._src, client) api_info = client.view_api(return_format="dict", print_info=False) _client_cache.set_api_info(self._src, api_info) return api_info def _validate_gradio_api( self, inputs: dict[str, Any], outputs: dict[str, Any] ) -> None: from daggr import _client_cache api_name = self._api_name or "/predict" if not api_name.startswith("/"): api_name = "/" + api_name cache_key = ( self._src, api_name, tuple(sorted(inputs.keys())), tuple(sorted(outputs.keys())) if outputs else (), ) if _client_cache.is_validated(cache_key): return api_info = self._get_api_info() named_endpoints = api_info.get("named_endpoints", {}) unnamed_endpoints = api_info.get("unnamed_endpoints", {}) endpoint_info = None if api_name in named_endpoints: endpoint_info = named_endpoints[api_name] else: try: fn_index = int(api_name.lstrip("/")) if fn_index in unnamed_endpoints or str(fn_index) in unnamed_endpoints: endpoint_info = unnamed_endpoints.get( fn_index, unnamed_endpoints.get(str(fn_index)) ) except ValueError: pass if endpoint_info is None: available = list(named_endpoints.keys()) if unnamed_endpoints: available.extend([f"/{k}" for k in unnamed_endpoints.keys()]) suggested = suggest_similar(api_name, set(available)) msg = ( f"API endpoint '{api_name}' not found in '{self._src}'. " f"Available endpoints: {available}" ) if suggested: msg += f" Did you mean '{suggested}'?" raise ValueError(msg) params_info = endpoint_info.get("parameters", []) valid_params = {p.get("parameter_name", p["label"]) for p in params_info} input_params = set(inputs.keys()) invalid_params = input_params - valid_params if invalid_params: suggestions = {} for inv in invalid_params: suggestion = suggest_similar(inv, valid_params) if suggestion: suggestions[inv] = suggestion msg = ( f"Invalid parameter(s) {invalid_params} for endpoint '{api_name}' " f"in '{self._src}'." ) if suggestions: suggestion_str = ", ".join( f"'{k}' -> '{v}'" for k, v in suggestions.items() ) msg += f" Did you mean: {suggestion_str}?" msg += f" Valid parameters: {valid_params}" raise ValueError(msg) required_params = { p.get("parameter_name", p["label"]) for p in params_info if not p.get("parameter_has_default", False) } provided_params = set(inputs.keys()) missing_required = required_params - provided_params if missing_required: raise ValueError( f"Missing required parameter(s) {missing_required} for endpoint " f"'{api_name}' in '{self._src}'. These parameters have no default values." ) api_returns = endpoint_info.get("returns", []) if outputs and api_returns and not self._postprocess: num_returns = len(api_returns) num_outputs = len(outputs) if num_outputs > num_returns: warnings.warn( f"GradioNode '{self._name}' defines {num_outputs} outputs but " f"endpoint '{api_name}' only returns {num_returns} value(s). " f"Extra outputs will be None." ) _client_cache.mark_validated(cache_key) class InferenceNode(Node): """A node that wraps a Hugging Face Inference API model. InferenceNode uses the Hugging Face Inference API to run models without needing to download them locally. The task type (text-generation, text-to-image, etc.) is automatically determined from the model's pipeline_tag on the Hub. Args: model: The Hugging Face model ID (e.g., "meta-llama/Llama-2-7b-chat-hf"). name: Optional display name for the node. inputs: Dict mapping input port names to values or components. outputs: Dict mapping output port names to components. validate: Whether to validate the model exists on the Hub. preprocess: Optional function that receives the input dict and returns a modified dict before the inference call. postprocess: Optional function that receives the raw inference result and returns a transformed value before it is mapped to output ports. Example: >>> llm = InferenceNode("meta-llama/Llama-2-7b-chat-hf") """ def __init__( self, model: str, name: str | None = None, inputs: dict[str, Any] | None = None, outputs: dict[str, Any] | None = None, validate: bool = True, preprocess: Callable[[dict], dict] | None = None, postprocess: Callable[..., Any] | None = None, ): super().__init__(name) self._model = model self._task: str | None = None self._task_fetched: bool = False self._preprocess = preprocess self._postprocess = postprocess if not self._name: # Strip provider tag (e.g., ":replicate") for display name self._name = self._model_name_for_hub.split("/")[-1] if inputs: self._process_inputs(inputs) else: self._input_ports = ["input"] if outputs: self._process_outputs(outputs) else: self._output_ports = ["output"] self._validate_ports() if validate: self._fetch_model_info() @property def _model_name_for_hub(self) -> str: """Return the model name without provider tags (e.g., ':replicate').""" # HF Inference Client allows tags like "model:provider" for routing # Strip these for Hub API calls and display return self._model.split(":")[0] @property def _provider(self) -> str | None: """Return the provider tag if specified (e.g., 'replicate' from 'model:replicate').""" parts = self._model.split(":") return parts[1] if len(parts) > 1 else None def _fetch_model_info(self) -> None: if self._task_fetched: return from daggr import _client_cache # Use model name without provider tag for Hub lookups hub_model = self._model_name_for_hub found_in_cache, cached = _client_cache.get_model_task(hub_model) if found_in_cache: if cached == "__NOT_FOUND__": raise ValueError(f"Model '{hub_model}' not found on Hugging Face Hub.") self._task = cached self._task_fetched = True return from huggingface_hub import model_info from huggingface_hub.utils import RepositoryNotFoundError try: info = model_info(hub_model) self._task = info.pipeline_tag _client_cache.set_model_task(hub_model, self._task) self._task_fetched = True except RepositoryNotFoundError: _client_cache.set_model_not_found(hub_model) raise ValueError( f"Model '{hub_model}' not found on Hugging Face Hub. " f"Please check the model name is correct (format: 'username/model-name')." ) class FnNode(Node): """A node that wraps a Python function. FnNode allows you to use any Python function as a node in the graph. Input ports are automatically discovered from the function signature. Return values are mapped to output ports in order, just like GradioNode: - Single value: maps to the first output port - Tuple: each element maps to the corresponding output port in order Concurrency: By default, FnNodes execute sequentially (one at a time per session) to prevent resource contention. Use the concurrency parameters to allow parallel execution: - concurrent=True: Allow this node to run in parallel with others - concurrency_group: Group nodes that share a resource (e.g., GPU) - max_concurrent: Max parallel executions within a group (default: 1) Note: GradioNode and InferenceNode always run concurrently since they are external API calls. Prefer these over FnNode when possible. Args: fn: The Python function to wrap. name: Optional display name. Defaults to the function name. inputs: Optional dict to explicitly define input ports and their connections or UI components. outputs: Optional dict mapping output port names to UI components or ItemList schemas. concurrent: If True, allow parallel execution. Default: False. concurrency_group: Name of a group sharing a concurrency limit. max_concurrent: Max parallel executions in the group. Default: 1. Example: >>> def process_text(text: str) -> tuple[str, int]: ... return text.upper(), len(text) >>> node = FnNode( ... process_text, ... outputs={"uppercase": gr.Textbox(), "length": gr.Number()} ... ) >>> # Allow parallel execution >>> node = FnNode(my_func, concurrent=True) >>> # Share GPU with other nodes (max 2 concurrent) >>> node = FnNode(gpu_func, concurrency_group="gpu", max_concurrent=2) """ def __init__( self, fn: Callable, name: str | None = None, inputs: dict[str, Any] | None = None, outputs: dict[str, Any] | None = None, preprocess: Callable[[dict], dict] | None = None, postprocess: Callable[..., Any] | None = None, concurrent: bool = False, concurrency_group: str | None = None, max_concurrent: int = 1, ): super().__init__(name) self._fn = fn self._preprocess = preprocess self._postprocess = postprocess self._concurrent = concurrent self._concurrency_group = concurrency_group self._max_concurrent = max_concurrent if not self._name: self._name = self._fn.__name__ if inputs: self._validate_fn_inputs(inputs) self._process_inputs(inputs) else: self._discover_signature() if outputs: self._process_outputs(outputs) else: self._output_ports = ["output"] self._validate_ports() def _discover_signature(self): sig = inspect.signature(self._fn) self._input_ports = list(sig.parameters.keys()) def _validate_fn_inputs(self, inputs: dict[str, Any]) -> None: sig = inspect.signature(self._fn) valid_params = set(sig.parameters.keys()) provided_params = set(inputs.keys()) invalid_params = provided_params - valid_params if invalid_params: suggestions = {} for inv in invalid_params: suggestion = suggest_similar(inv, valid_params) if suggestion: suggestions[inv] = suggestion msg = ( f"Invalid input(s) {invalid_params} for function '{self._fn.__name__}'." ) if suggestions: suggestion_str = ", ".join( f"'{k}' -> '{v}'" for k, v in suggestions.items() ) msg += f" Did you mean: {suggestion_str}?" msg += f" Valid parameters: {valid_params}" raise ValueError(msg) def _process_outputs(self, outputs: dict[str, Any]) -> None: for port_name, component in outputs.items(): self._output_ports.append(port_name) if component is None: continue if isinstance(component, ItemList): self._item_list_schemas[port_name] = component.schema elif _is_gradio_component(component): self._output_components[port_name] = component class InteractionNode(Node): """A node representing a user interaction point in the graph. InteractionNodes pause execution and wait for user input before continuing. They are used for approval steps, selections, or other human-in-the-loop interactions. Args: name: Optional display name for the node. interaction_type: Type of interaction (e.g., "generic", "approve", "choose_one"). inputs: Dict mapping input port names to components or connections. outputs: Dict mapping output port names to components. """ def __init__( self, name: str | None = None, interaction_type: str = "generic", inputs: dict[str, Any] | None = None, outputs: dict[str, Any] | None = None, ): super().__init__(name) self._interaction_type = interaction_type if inputs: self._process_inputs(inputs) else: self._input_ports = ["input"] if outputs: self._process_outputs(outputs) else: self._output_ports = ["output"] if not self._name: self._name = f"interaction_{self._id}" self._validate_ports() class InputNode(Node): """ A node that groups multiple Gradio input components into a single, organized block. Each component defined in the `ports` dictionary becomes a distinct output port. """ _name_counters: dict[str, int] = {} def __init__( self, name: str | None = None, ports: dict[str, Any] | None = None, ): """ Initializes the InputNode. Args: ports: A dictionary where keys are port names and values are Gradio components (e.g., gr.Textbox). name: An optional display name for the node. A default name will be generated if not provided. """ super().__init__(name) ports = ports or {} if not isinstance(ports, dict): raise TypeError( "InputNode `ports` must be a dictionary mapping port names to Gradio components." ) invalid_ports = [ f"{port_name} ({type(comp).__name__})" for port_name, comp in ports.items() if not _is_gradio_component(comp) ] if invalid_ports: invalid_ports_list = ", ".join(invalid_ports) raise ValueError( "InputNode `ports` values must be Gradio components. " f"Invalid entries: {invalid_ports_list}" ) self._output_ports = list(ports.keys()) self._input_components = dict(ports) self._input_ports = [] self._output_components = {} if not self._name: base_name = "Inputs" if base_name not in InputNode._name_counters: InputNode._name_counters[base_name] = 0 self._name = base_name else: InputNode._name_counters[base_name] += 1 self._name = f"{base_name}_{InputNode._name_counters[base_name]}" self._validate_ports() ================================================ FILE: daggr/ops.py ================================================ from __future__ import annotations from daggr.node import InteractionNode class ChooseOne(InteractionNode): _instance_counter = 0 def __init__(self, name: str | None = None): ChooseOne._instance_counter += 1 super().__init__( name=name or f"choose_one_{ChooseOne._instance_counter}", interaction_type="choose_one", ) self._input_ports = ["options"] self._output_ports = ["selected"] class Approve(InteractionNode): _instance_counter = 0 def __init__(self, name: str | None = None): Approve._instance_counter += 1 super().__init__( name=name or f"approve_{Approve._instance_counter}", interaction_type="approve", ) self._input_ports = ["input"] self._output_ports = ["output"] class TextInput(InteractionNode): _instance_counter = 0 def __init__(self, name: str | None = None, label: str = "Input"): TextInput._instance_counter += 1 super().__init__( name=name or f"text_input_{TextInput._instance_counter}", interaction_type="text_input", ) self._label = label self._input_ports = [] self._output_ports = ["text"] class ImageInput(InteractionNode): _instance_counter = 0 def __init__(self, name: str | None = None, label: str = "Image"): ImageInput._instance_counter += 1 super().__init__( name=name or f"image_input_{ImageInput._instance_counter}", interaction_type="image_input", ) self._label = label self._input_ports = [] self._output_ports = ["image"] ================================================ FILE: daggr/package.json ================================================ { "name": "daggr", "version": "0.8.0", "description": "", "python": "true" } ================================================ FILE: daggr/port.py ================================================ """Port module for node input/output definitions. Ports are named connection points on nodes. Output ports can be connected to input ports to form edges in the graph. """ from __future__ import annotations from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from daggr.node import Node class Port: """A named connection point on a node. Ports represent inputs or outputs of a node. Access them as attributes on a node: `node.port_name`. Attributes: node: The node this port belongs to. name: The name of the port. """ def __init__(self, node: Node, name: str): self.node = node self.name = name def __repr__(self): return f"Port({self.node._name}.{self.name})" def _as_source(self) -> tuple[Node, str]: return (self.node, self.name) def _as_target(self) -> tuple[Node, str]: return (self.node, self.name) def __getattr__(self, attr: str) -> ScatteredPort: if attr.startswith("_"): raise AttributeError(attr) if ( hasattr(self.node, "_item_list_schemas") and self.name in self.node._item_list_schemas ): schema = self.node._item_list_schemas[self.name] if attr in schema: return ScatteredPort(self, attr) raise AttributeError(f"Port '{self.name}' has no attribute '{attr}'") @property def each(self) -> ScatteredPort: """Scatter this port's output - run the downstream node once per item in the list.""" return ScatteredPort(self) def all(self) -> GatheredPort: """Gather outputs from a scattered node back into a list.""" return GatheredPort(self) class ScatteredPort: """A port that scatters its list output to run downstream nodes per-item. Created by accessing `.each` on a port. When connected to a downstream node, that node will be executed once for each item in the list. """ def __init__(self, port: Port, item_key: str | None = None): self.port = port self.item_key = item_key @property def node(self): return self.port.node @property def name(self): return self.port.name def __getitem__(self, key: str) -> ScatteredPort: """Access a specific field from each scattered item (e.g., dialogue.json.each["text"]).""" return ScatteredPort(self.port, key) def __repr__(self): if self.item_key: return f"ScatteredPort({self.port}['{self.item_key}'])" return f"ScatteredPort({self.port})" class GatheredPort: """A port that gathers scattered results back into a list. Created by calling `.all()` on a port. Collects results from all scattered executions back into a single list. """ def __init__(self, port: Port): self.port = port @property def node(self): return self.port.node @property def name(self): return self.port.name def __repr__(self): return f"GatheredPort({self.port})" PortLike = Port | ScatteredPort | GatheredPort def is_port(obj: Any) -> bool: """Check if an object is a Port, ScatteredPort, or GatheredPort.""" return isinstance(obj, (Port, ScatteredPort, GatheredPort)) class PortNamespace: """A namespace for accessing ports that start with underscores. Used via `node._inputs` or `node._outputs` to access ports whose names start with underscores (which can't be accessed directly as attributes). """ def __init__(self, node: Node, port_names: list[str]): self._node = node self._names = set(port_names) def __getattr__(self, name: str) -> Port: if name.startswith("_"): raise AttributeError(name) return Port(self._node, name) def __dir__(self) -> list[str]: return list(self._names) def __repr__(self): return f"PortNamespace({list(self._names)})" class ItemList: """Define an editable list output with per-item schema. Example: outputs={ "items": ItemList( speaker=gr.Dropdown(choices=["Host", "Guest"]), text=gr.Textbox(lines=2), ), } The function should return a list of dicts matching the schema keys. """ def __init__(self, **schema): self.schema = schema ================================================ FILE: daggr/py.typed ================================================ ================================================ FILE: daggr/server.py ================================================ from __future__ import annotations import asyncio import base64 import json import mimetypes import os import secrets import socket import tempfile import threading import time import traceback import uuid import webbrowser from pathlib import Path from typing import TYPE_CHECKING, Any import uvicorn from fastapi import FastAPI, Header, Request, WebSocket, WebSocketDisconnect from fastapi.responses import ( FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, Response, ) from gradio_client.utils import is_file_obj_with_meta from daggr.executor import AsyncExecutor, FileValue from daggr.node import ( _FILE_TYPE_COMPONENTS, ChoiceNode, GradioNode, InferenceNode, InputNode, InteractionNode, ) from daggr.session import ExecutionSession from daggr.state import SessionState, get_daggr_cache_dir _FILE_COMP_TYPES = {c.lower() for c in _FILE_TYPE_COMPONENTS} if TYPE_CHECKING: from gradio.themes import Base as Theme from daggr.graph import Graph INITIAL_PORT_VALUE = int(os.getenv("DAGGR_SERVER_PORT", "7860")) TRY_NUM_PORTS = int(os.getenv("DAGGR_NUM_PORTS", "100")) def _find_available_port(host: str, start_port: int) -> int: """Find an available port starting from start_port.""" for port in range(start_port, start_port + TRY_NUM_PORTS): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host if host != "0.0.0.0" else "127.0.0.1", port)) s.close() return port except OSError: continue raise OSError( f"Cannot find empty port in range: {start_port}-{start_port + TRY_NUM_PORTS - 1}. " f"You can specify a different port by setting the DAGGR_SERVER_PORT environment variable " f"or passing the port parameter to launch()." ) def _get_theme(theme: "Theme | str | None") -> "Theme": """Get a Gradio theme instance from a theme specification. Args: theme: Can be a Theme instance, a string name like "default", "soft", "monochrome", "glass", or a Hub theme like "gradio/seafoam". Returns: A Theme instance. """ from gradio.themes import Default if theme is None: return Default() if isinstance(theme, str): from gradio.themes import Base, Default, Glass, Monochrome, Soft theme_mapping = { "default": Default, "soft": Soft, "monochrome": Monochrome, "glass": Glass, "base": Base, } theme_lower = theme.lower() if theme_lower in theme_mapping: return theme_mapping[theme_lower]() # Try loading from Hub try: return Base.from_hub(theme) except Exception: return Default() return theme class DaggrServer: def __init__( self, graph: Graph, theme: "Theme | str | None" = None, api_server: bool = True, ): self.graph = graph self.api_server = api_server self.executor = AsyncExecutor(graph) self.state = SessionState(db_path=os.environ.get("DAGGR_DB_PATH")) self.app = FastAPI(title=graph.name) self.connections: dict[str, WebSocket] = {} self.theme = _get_theme(theme) self.theme_css = self.theme._get_theme_css() self._setup_routes() def _extract_token_from_header(self, authorization: str | None) -> str | None: if authorization and authorization.startswith("Bearer "): return authorization[7:] return None def _validate_hf_token(self, token: str) -> dict | None: try: from huggingface_hub import whoami info = whoami(token=token, cache=True) return { "username": info.get("name"), "fullname": info.get("fullname"), "avatar_url": info.get("avatarUrl"), } except Exception: return None def _setup_routes(self): frontend_dir = Path(__file__).parent / "frontend" / "dist" if not frontend_dir.exists(): raise RuntimeError( f"Frontend not found at {frontend_dir}. " "If developing, run 'npm run build' in daggr/frontend/" ) @self.app.get("/theme.css", response_class=PlainTextResponse) async def get_theme_css(): return PlainTextResponse(self.theme_css, media_type="text/css") @self.app.get("/api/graph") async def get_graph(): return self._build_graph_data() @self.app.get("/api/hf_user") async def get_hf_user(): return self._get_hf_user_info() @self.app.get("/api/user_info") async def get_user_info(authorization: str | None = Header(default=None)): browser_token = self._extract_token_from_header(authorization) if browser_token: hf_user = self._validate_hf_token(browser_token) else: hf_user = self._get_hf_user_info() user_id = self.state.get_effective_user_id(hf_user) is_on_spaces = os.environ.get("SPACE_ID") is not None persistence_enabled = self.graph.persist_key is not None return { "hf_user": hf_user, "user_id": user_id, "is_on_spaces": is_on_spaces, "can_persist": user_id is not None and persistence_enabled, } @self.app.post("/api/auth/login") async def auth_login(request: Request): try: body = await request.json() token = body.get("token") if not token: return JSONResponse({"error": "Token is required"}, status_code=400) hf_user = self._validate_hf_token(token) if not hf_user: return JSONResponse({"error": "Invalid token"}, status_code=401) return {"hf_user": hf_user, "success": True} except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) @self.app.post("/api/auth/logout") async def auth_logout(): return {"success": True} @self.app.get("/api/sheets") async def list_sheets(authorization: str | None = Header(default=None)): if not self.graph.persist_key: return {"sheets": [], "user_id": None} browser_token = self._extract_token_from_header(authorization) if browser_token: hf_user = self._validate_hf_token(browser_token) else: hf_user = self._get_hf_user_info() user_id = self.state.get_effective_user_id(hf_user) if not user_id: return JSONResponse( {"error": "Login required to access sheets on Spaces"}, status_code=401, ) sheets = self.state.list_sheets(user_id, self.graph.persist_key) return {"sheets": sheets, "user_id": user_id} @self.app.post("/api/sheets") async def create_sheet( request: Request, authorization: str | None = Header(default=None) ): if not self.graph.persist_key: return JSONResponse( {"error": "Persistence is disabled for this graph"}, status_code=400, ) browser_token = self._extract_token_from_header(authorization) if browser_token: hf_user = self._validate_hf_token(browser_token) else: hf_user = self._get_hf_user_info() user_id = self.state.get_effective_user_id(hf_user) if not user_id: return JSONResponse( {"error": "Login required to create sheets on Spaces"}, status_code=401, ) body = await request.json() name = body.get("name") sheet_id = self.state.create_sheet(user_id, self.graph.persist_key, name) sheet = self.state.get_sheet(sheet_id) return {"sheet": sheet} @self.app.patch("/api/sheets/{sheet_id}") async def rename_sheet( sheet_id: str, request: Request, authorization: str | None = Header(default=None), ): browser_token = self._extract_token_from_header(authorization) if browser_token: hf_user = self._validate_hf_token(browser_token) else: hf_user = self._get_hf_user_info() user_id = self.state.get_effective_user_id(hf_user) if not user_id: return JSONResponse({"error": "Login required"}, status_code=401) sheet = self.state.get_sheet(sheet_id) if not sheet: return JSONResponse({"error": "Sheet not found"}, status_code=404) if sheet["user_id"] != user_id: return JSONResponse({"error": "Access denied"}, status_code=403) body = await request.json() new_name = body.get("name") if not new_name: return JSONResponse({"error": "Name required"}, status_code=400) self.state.rename_sheet(sheet_id, new_name) return {"success": True, "sheet": self.state.get_sheet(sheet_id)} @self.app.delete("/api/sheets/{sheet_id}") async def delete_sheet( sheet_id: str, authorization: str | None = Header(default=None) ): browser_token = self._extract_token_from_header(authorization) if browser_token: hf_user = self._validate_hf_token(browser_token) else: hf_user = self._get_hf_user_info() user_id = self.state.get_effective_user_id(hf_user) if not user_id: return JSONResponse({"error": "Login required"}, status_code=401) sheet = self.state.get_sheet(sheet_id) if not sheet: return JSONResponse({"error": "Sheet not found"}, status_code=404) if sheet["user_id"] != user_id: return JSONResponse({"error": "Access denied"}, status_code=403) self.state.delete_sheet(sheet_id) return {"success": True} @self.app.get("/api/sheets/{sheet_id}/state") async def get_sheet_state( sheet_id: str, authorization: str | None = Header(default=None) ): browser_token = self._extract_token_from_header(authorization) if browser_token: hf_user = self._validate_hf_token(browser_token) else: hf_user = self._get_hf_user_info() user_id = self.state.get_effective_user_id(hf_user) if not user_id: return JSONResponse({"error": "Login required"}, status_code=401) sheet = self.state.get_sheet(sheet_id) if not sheet: return JSONResponse({"error": "Sheet not found"}, status_code=404) if sheet["user_id"] != user_id: return JSONResponse({"error": "Access denied"}, status_code=403) state = self.state.get_sheet_state(sheet_id) return {"sheet": sheet, "state": state} @self.app.post("/api/run/{node_name}") async def run_to_node(node_name: str, data: dict): session = ExecutionSession(self.graph) session_id = data.get("session_id") input_values = data.get("inputs", {}) selected_results = data.get("selected_results", {}) return await self._execute_to_node( session, node_name, session_id, input_values, selected_results ) if self.api_server: @self.app.get("/api/schema") async def get_api_schema(): return self.graph.get_api_schema() @self.app.post("/api/call") async def call_workflow(request: Request): return await self._execute_workflow_api(request, subgraph_id=None) @self.app.post("/api/call/{subgraph_id}") async def call_subgraph(subgraph_id: str, request: Request): return await self._execute_workflow_api( request, subgraph_id=subgraph_id ) @self.app.websocket("/ws/{session_id}") async def websocket_endpoint(websocket: WebSocket, session_id: str): await websocket.accept() self.connections[session_id] = websocket hf_user = self._get_hf_user_info() user_id = self.state.get_effective_user_id(hf_user) current_sheet_id: str | None = None session = ExecutionSession(self.graph) running_tasks: dict[str, asyncio.Task] = {} async def run_node_execution( node_name: str, sheet_id: str | None, input_values: dict, item_list_values: dict, selected_results: dict, run_id: str, user_id: str | None, run_ancestors: bool = True, ): try: async for result in self._execute_to_node_streaming( session, node_name, sheet_id, input_values, item_list_values, selected_results, run_id, user_id, run_ancestors, ): await websocket.send_json(result) except asyncio.CancelledError: pass except Exception as e: await websocket.send_json( { "type": "error", "run_id": run_id, "error": str(e), "node": node_name, } ) try: while True: data = await websocket.receive_json() action = data.get("action") if "hf_token" in data: browser_hf_token = data.get("hf_token") old_user_id = user_id if browser_hf_token: hf_user = self._validate_hf_token(browser_hf_token) user_id = self.state.get_effective_user_id(hf_user) session.set_hf_token(browser_hf_token) else: hf_user = self._get_hf_user_info() user_id = self.state.get_effective_user_id(hf_user) session.set_hf_token(None) if old_user_id != user_id: session.clear_results() current_sheet_id = None if action == "run": node_name = data.get("node_name") input_values = data.get("inputs", {}) item_list_values = data.get("item_list_values", {}) selected_results = data.get("selected_results", {}) run_id = data.get("run_id") sheet_id = data.get("sheet_id") or current_sheet_id run_ancestors = data.get("run_ancestors", True) task = asyncio.create_task( run_node_execution( node_name, sheet_id, input_values, item_list_values, selected_results, run_id, user_id, run_ancestors, ) ) running_tasks[run_id] = task task.add_done_callback( lambda t, rid=run_id: running_tasks.pop(rid, None) ) elif action == "cancel": cancel_run_id = data.get("run_id") cancel_node = data.get("node_name") task = running_tasks.get(cancel_run_id) if task: task.cancel() await websocket.send_json( { "type": "cancelled", "run_id": cancel_run_id, "node": cancel_node, } ) elif action == "get_graph": try: sheet_id = data.get("sheet_id") persisted_inputs = {} persisted_results: dict[str, list[Any]] = {} persisted_transform = None if user_id and sheet_id: sheet = self.state.get_sheet(sheet_id) if sheet and sheet["user_id"] == user_id: current_sheet_id = sheet_id state = self.state.get_sheet_state(sheet_id) persisted_inputs = state.get("inputs", {}) persisted_results = state.get("results", {}) persisted_transform = sheet.get("transform") node_results = {} for node_name, results_list in persisted_results.items(): if results_list: last_entry = results_list[-1] if ( isinstance(last_entry, dict) and "result" in last_entry ): node_results[node_name] = last_entry["result"] else: node_results[node_name] = last_entry graph_data = self._build_graph_data( node_results=node_results, input_values=persisted_inputs, ) graph_data["session_id"] = session_id graph_data["sheet_id"] = current_sheet_id graph_data["user_id"] = user_id graph_data["persisted_results"] = ( self._transform_persisted_results(persisted_results) ) graph_data["transform"] = persisted_transform await websocket.send_json( {"type": "graph", "data": graph_data} ) except Exception as e: print(f"[ERROR] get_graph failed: {e}") traceback.print_exc() await websocket.send_json( {"type": "error", "error": str(e)} ) elif action == "save_input": if user_id and current_sheet_id: node_id = data.get("node_id") port_name = data.get("port_name") value = data.get("value") if node_id and port_name is not None: self.state.save_input( current_sheet_id, node_id, port_name, value ) await websocket.send_json( {"type": "input_saved", "node_id": node_id} ) elif action == "save_transform": if user_id and current_sheet_id: x = data.get("x", 0) y = data.get("y", 0) scale = data.get("scale", 1) self.state.save_transform(current_sheet_id, x, y, scale) elif action == "set_sheet": sheet_id = data.get("sheet_id") if user_id and sheet_id: sheet = self.state.get_sheet(sheet_id) if sheet and sheet["user_id"] == user_id: current_sheet_id = sheet_id session.clear_results() await websocket.send_json( {"type": "sheet_set", "sheet_id": sheet_id} ) elif action == "save_variant_selection": node_id = data.get("node_id") variant_index = data.get("variant_index", 0) if user_id and current_sheet_id and node_id is not None: self.state.save_input( current_sheet_id, node_id, "_selected_variant", variant_index, ) await websocket.send_json( { "type": "variant_selection_saved", "node_id": node_id, "variant_index": variant_index, } ) elif action == "clear_sheet": if user_id and current_sheet_id: self.state.clear_sheet_data(current_sheet_id) await websocket.send_json({"type": "sheet_cleared"}) except WebSocketDisconnect: for task in running_tasks.values(): task.cancel() if session_id in self.connections: del self.connections[session_id] except Exception as e: for task in running_tasks.values(): task.cancel() print(f"[ERROR] WebSocket error: {e}") traceback.print_exc() @self.app.get("/") async def serve_index(): index_path = frontend_dir / "index.html" if index_path.exists(): return FileResponse(index_path) return HTMLResponse(self._get_dev_html()) @self.app.get("/assets/{path:path}") async def serve_assets(path: str): file_path = frontend_dir / "assets" / path if file_path.exists(): content_type, _ = mimetypes.guess_type(str(file_path)) return FileResponse(file_path, media_type=content_type) return Response(status_code=404) @self.app.get("/daggr-assets/{path:path}") async def serve_daggr_assets(path: str): assets_dir = Path(__file__).parent / "assets" file_path = assets_dir / path if file_path.exists(): content_type, _ = mimetypes.guess_type(str(file_path)) return FileResponse(file_path, media_type=content_type) return Response(status_code=404) @self.app.get("/file/{path:path}") async def serve_local_file(path: str): if len(path) >= 2 and path[1] == ":": file_path = Path(path) else: file_path = Path("/") / path temp_dir = Path(tempfile.gettempdir()).resolve() daggr_cache = get_daggr_cache_dir().resolve() try: resolved = file_path.resolve() is_allowed = str(resolved).startswith(str(temp_dir)) or str( resolved ).startswith(str(daggr_cache)) if not is_allowed: return Response(status_code=403) except (ValueError, OSError): return Response(status_code=403) if resolved.exists() and resolved.is_file(): content_type, _ = mimetypes.guess_type(str(resolved)) return FileResponse( resolved, media_type=content_type or "application/octet-stream" ) return Response(status_code=404) @self.app.get("/{path:path}") async def serve_static(path: str): if path.startswith("api/") or path.startswith("ws/"): return Response(status_code=404) file_path = frontend_dir / path if file_path.exists() and file_path.is_file(): return FileResponse(file_path) index_path = frontend_dir / "index.html" if index_path.exists(): return FileResponse(index_path) return HTMLResponse(self._get_dev_html()) def _get_dev_html(self) -> str: return f""" {self.graph.name}
""" def _get_node_url(self, node) -> str | None: if isinstance(node, GradioNode): src = node._src if src.startswith("http://") or src.startswith("https://"): return src elif "/" in src: return f"https://huggingface.co/spaces/{src}" elif isinstance(node, InferenceNode): return f"https://huggingface.co/{node._model_name_for_hub}" return None def _get_node_type(self, node, node_name: str) -> str: type_map = { "FnNode": "FN", "TextInput": "INPUT", "ImageInput": "IMAGE", "ChooseOne": "SELECT", "Approve": "APPROVE", "GradioNode": "GRADIO", "InferenceNode": "MODEL", "InteractionNode": "ACTION", "ChoiceNode": "CHOICE", } if isinstance(node, ChoiceNode): return "CHOICE" if isinstance(node, InputNode): return "INPUT" class_name = node.__class__.__name__ return type_map.get(class_name, class_name.upper()) def _has_scattered_input(self, node_name: str) -> bool: for edge in self.graph._edges: if edge.target_node._name == node_name and edge.is_scattered: return True return False def _get_scattered_edge(self, node_name: str): for edge in self.graph._edges: if edge.target_node._name == node_name and edge.is_scattered: return edge return None def _is_output_node(self, node_name: str) -> bool: return self.graph._nx_graph.out_degree(node_name) == 0 def _is_running_locally(self, node) -> bool: if not isinstance(node, GradioNode): return False return bool(node._run_locally and node._local_url and not node._local_failed) def _build_variant_data(self, variant, input_values: dict) -> dict[str, Any]: variant_name = variant._name if isinstance(variant, GradioNode) and not variant._name_explicitly_set: variant_name = f"{variant._src}" if variant._api_name: variant_name += f" ({variant._api_name})" input_components = [] for port_name, comp in variant._input_components.items(): comp_data = self._serialize_component(comp, port_name) input_components.append(comp_data) output_components = [] for port_name, comp in variant._output_components.items(): if comp is None: continue visible = getattr(comp, "visible", True) if visible is False: continue comp_data = self._serialize_component(comp, port_name) output_components.append(comp_data) return { "name": variant_name, "input_components": input_components, "output_components": output_components, } def _get_component_type(self, component) -> str: class_name = component.__class__.__name__ type_map = { "Audio": "audio", "Textbox": "textbox", "TextArea": "textarea", "JSON": "json", "Chatbot": "json", "Image": "image", "Number": "number", "Markdown": "markdown", "Text": "text", "Dropdown": "dropdown", "Video": "video", "File": "file", "Model3D": "model3d", "Gallery": "gallery", "Slider": "slider", "Radio": "radio", "Checkbox": "checkbox", "CheckboxGroup": "checkboxgroup", "ColorPicker": "colorpicker", "Label": "label", "HighlightedText": "highlightedtext", "Code": "code", "HTML": "html", "Dataframe": "dataframe", } return type_map.get(class_name, "text") def _serialize_component(self, comp, port_name: str) -> dict[str, Any]: comp_type = self._get_component_type(comp) comp_class = comp.__class__.__name__ props = { "label": getattr(comp, "label", "") or port_name, "show_label": bool(getattr(comp, "label", "")), "interactive": getattr(comp, "interactive", True), "visible": getattr(comp, "visible", True), } if hasattr(comp, "placeholder"): props["placeholder"] = comp.placeholder if hasattr(comp, "lines"): props["lines"] = comp.lines if hasattr(comp, "max_lines"): props["max_lines"] = comp.max_lines if hasattr(comp, "type"): props["type"] = comp.type if hasattr(comp, "choices") and comp.choices: choices = [] for c in comp.choices: if isinstance(c, (tuple, list)) and len(c) >= 2: choices.append([c[0], c[1]]) else: choices.append([str(c), c]) props["choices"] = choices if hasattr(comp, "minimum"): props["minimum"] = comp.minimum if hasattr(comp, "maximum"): props["maximum"] = comp.maximum if hasattr(comp, "step"): props["step"] = comp.step value = getattr(comp, "value", None) if is_file_obj_with_meta(value): value = self._file_to_url(value["path"]) return { "component": comp_class.lower(), "type": comp_type, "port_name": port_name, "props": props, "value": value, } def _file_to_url(self, value: Any) -> Any: if isinstance(value, str) and not value.startswith("/file/"): path = Path(value) if path.is_absolute() and path.exists(): normalized = value.replace("\\", "/") if normalized.startswith("/"): return f"/file{normalized}" return f"/file/{normalized}" return value def _validate_file_value(self, value: Any, comp_type: str) -> str | None: """Validate that a value is appropriate for a file-type component. Returns an error message if invalid, None if valid.""" if value is None: return None if isinstance(value, str): return None if isinstance(value, dict): if "url" in value or "path" in value: return None keys = list(value.keys()) if keys: return ( f"Expected a file path string for {comp_type}, but got a dict " f"with keys {keys}. If using postprocess, extract the path: " f"e.g., `postprocess=lambda x: x['{keys[0]}']`" ) return ( f"Expected a file path string for {comp_type}, but got an empty dict." ) return f"Expected a file path string for {comp_type}, but got {type(value).__name__}." def _transform_file_paths(self, data: Any) -> Any: if isinstance(data, str): return self._file_to_url(data) elif isinstance(data, dict): return {k: self._transform_file_paths(v) for k, v in data.items()} elif isinstance(data, list): return [self._transform_file_paths(item) for item in data] return data def _transform_persisted_results( self, persisted_results: dict[str, list[Any]] ) -> dict[str, list[Any]]: """Transform persisted results, handling both old format (just result) and new format (dict with result and inputs_snapshot).""" transformed: dict[str, list[Any]] = {} for node_name, results_list in persisted_results.items(): transformed[node_name] = [] for entry in results_list: if isinstance(entry, dict) and "result" in entry: transformed[node_name].append( { "result": self._transform_file_paths(entry["result"]), "inputs_snapshot": entry.get("inputs_snapshot"), } ) else: transformed[node_name].append(self._transform_file_paths(entry)) return transformed def _build_input_components(self, node) -> list[dict[str, Any]]: if not node._input_components: return [] return [ self._serialize_component(comp, port_name) for port_name, comp in node._input_components.items() ] def _build_output_components( self, node, result: Any = None ) -> tuple[list[dict[str, Any]], str | None]: if not node._output_components: return [], None components = [] validation_error = None for port_name, comp in node._output_components.items(): if comp is None: continue visible = getattr(comp, "visible", True) if visible is False: continue comp_data = self._serialize_component(comp, port_name) comp_type = self._get_component_type(comp) if result is not None: if isinstance(result, dict): value = result.get( port_name, result.get(comp_data["props"]["label"]) ) else: value = result if comp_type in _FILE_COMP_TYPES: error = self._validate_file_value(value, comp_type) if error and validation_error is None: validation_error = error value = self._file_to_url(value) comp_data["value"] = value components.append(comp_data) return components, validation_error def _build_scattered_items( self, node_name: str, result: Any = None ) -> list[dict[str, Any]]: scattered_edge = self._get_scattered_edge(node_name) if not scattered_edge: return [] node = self.graph.nodes[node_name] item_output_type = "text" for comp in node._output_components.values(): if comp is None: continue comp_type = self._get_component_type(comp) if comp_type == "audio": item_output_type = "audio" break items = [] if result and isinstance(result, dict) and "_scattered_results" in result: results = result["_scattered_results"] source_items = result.get("_items", []) for i, item_result in enumerate(results): source_item = source_items[i] if i < len(source_items) else None preview = "" output = None if isinstance(source_item, dict): preview_parts = [ f"{k}: {str(v)[:20]}" for k, v in list(source_item.items())[:2] ] preview = ", ".join(preview_parts) elif source_item: preview = str(source_item)[:50] if isinstance(item_result, dict): first_key = list(item_result.keys())[0] if item_result else None if first_key: output = item_result[first_key] else: output = item_result if output: output = str(output) items.append( { "index": i + 1, "preview": preview or f"Item {i + 1}", "output": output, "is_audio_output": item_output_type == "audio", } ) return items def _serialize_item_list_schema( self, schema: dict[str, Any] ) -> list[dict[str, Any]]: serialized = [] for field_name, comp in schema.items(): comp_data = self._serialize_component(comp, field_name) serialized.append(comp_data) return serialized def _build_item_list_items( self, node, port_name: str, result: Any = None ) -> list[dict[str, Any]]: schema = node._item_list_schemas.get(port_name, {}) if not schema: return [] items = [] if result and isinstance(result, dict) and port_name in result: item_list = result[port_name] if isinstance(item_list, list): for i, item_data in enumerate(item_list): item = {"index": i, "fields": {}} if isinstance(item_data, dict): for field_name in schema: item["fields"][field_name] = item_data.get(field_name) items.append(item) return items def _apply_item_list_edits( self, node_name: str, result: Any, item_list_values: dict ) -> Any: node = self.graph.nodes[node_name] if not node._item_list_schemas: return result node_id = node_name.replace(" ", "_").replace("-", "_") edits = item_list_values.get(node_id, {}) if not edits: return result first_port = list(node._item_list_schemas.keys())[0] if isinstance(result, dict) and first_port in result: items = result[first_port] if isinstance(items, list): for idx_str, field_edits in edits.items(): idx = int(idx_str) if 0 <= idx < len(items) and isinstance(items[idx], dict): items[idx].update(field_edits) return result def _compute_node_depths(self) -> dict[str, int]: depths: dict[str, int] = {} connections = self.graph.get_connections() for node_name in self.graph.nodes: if self.graph._nx_graph.in_degree(node_name) == 0: depths[node_name] = 0 changed = True while changed: changed = False for source, _, target, _ in connections: if source in depths: new_depth = depths[source] + 1 if target not in depths or depths[target] < new_depth: depths[target] = new_depth changed = True for node_name in self.graph.nodes: if node_name not in depths: depths[node_name] = 0 return depths def _get_hf_user_info(self) -> dict | None: try: from huggingface_hub import get_token, whoami token = get_token() if not token: return None info = whoami(cache=True) return { "username": info.get("name"), "fullname": info.get("fullname"), "avatar_url": info.get("avatarUrl"), } except Exception: return None def _build_graph_data( self, node_results: dict[str, Any] | None = None, node_statuses: dict[str, str] | None = None, input_values: dict[str, Any] | None = None, history: dict[str, dict[str, list[dict]]] | None = None, session_id: str | None = None, selected_results: dict[str, int] | None = None, ) -> dict: node_results = node_results or {} node_statuses = node_statuses or {} input_values = input_values or {} history = history or {} selected_results = selected_results or {} depths = self._compute_node_depths() synthetic_input_nodes: list[dict[str, Any]] = [] synthetic_edges: list[dict[str, Any]] = [] input_node_positions: dict[str, tuple] = {} component_to_input_node: dict[int, str] = {} creation_order = 0 for node_name in self.graph.nodes: node = self.graph.nodes[node_name] if isinstance(node, ChoiceNode): continue if isinstance(node, InputNode): continue if node._input_components: for idx, (port_name, comp) in enumerate(node._input_components.items()): comp_id = id(comp) if comp_id in component_to_input_node: existing_input_node = component_to_input_node[comp_id] existing_input_id = existing_input_node.replace( " ", "_" ).replace("-", "_") synthetic_edges.append( { "from_node": existing_input_id, "from_port": "value", "to_node": node_name.replace(" ", "_").replace( "-", "_" ), "to_port": port_name, } ) continue input_node_name = f"{node_name}__{port_name}" input_node_id = input_node_name.replace(" ", "_").replace("-", "_") component_to_input_node[comp_id] = input_node_name comp_data = self._serialize_component(comp, "value") label = comp_data["props"].get("label") or port_name if input_node_id in input_values: comp_data["value"] = input_values[input_node_id].get( "value", comp_data["value"] ) synthetic_input_nodes.append( { "node_name": input_node_name, "display_name": label, "target_node": node_name, "target_port": port_name, "component": comp_data, "index": idx, "creation_order": creation_order, } ) creation_order += 1 synthetic_edges.append( { "from_node": input_node_id, "from_port": "value", "to_node": node_name.replace(" ", "_").replace("-", "_"), "to_port": port_name, } ) max_depth = max(depths.values()) if depths else 0 nodes_by_depth: dict[int, list[str]] = {} for node_name, depth in depths.items(): if depth not in nodes_by_depth: nodes_by_depth[depth] = [] nodes_by_depth[depth].append(node_name) x_spacing = 350 input_column_x = 50 x_start = 400 y_start = 120 y_gap = 30 base_node_height = 100 component_base_height = 60 line_height = 18 def calc_component_height(comp_data: dict) -> int: lines = comp_data.get("props", {}).get("lines", 1) lines = min(lines, 6) return component_base_height + max(0, lines - 1) * line_height def calc_node_height(components: list[dict], num_ports: int = 1) -> int: comp_height = sum(calc_component_height(c) for c in components) port_height = max(num_ports, 1) * 22 return base_node_height + port_height + comp_height all_input_nodes_sorted: list[dict] = [] for syn_node in synthetic_input_nodes: target_depth = depths.get(syn_node["target_node"], 0) all_input_nodes_sorted.append({**syn_node, "target_depth": target_depth}) all_input_nodes_sorted.sort(key=lambda x: x["creation_order"]) current_input_y = y_start for syn_node in all_input_nodes_sorted: input_node_positions[syn_node["node_name"]] = ( input_column_x, current_input_y, ) node_height = calc_node_height([syn_node["component"]], 1) current_input_y += node_height + y_gap node_positions: dict[str, tuple] = {} for depth in range(max_depth + 1): depth_nodes = nodes_by_depth.get(depth, []) current_y = y_start for node_name in depth_nodes: node = self.graph.nodes[node_name] output_comps, _ = self._build_output_components(node) num_ports = max( len(node._input_ports or []), len(node._output_ports or []) ) node_height = calc_node_height(output_comps, num_ports) x = x_start + depth * x_spacing node_positions[node_name] = (x, current_y) current_y += node_height + y_gap nodes = [] for syn_node in synthetic_input_nodes: node_name = syn_node["node_name"] display_name = syn_node["display_name"] node_id = node_name.replace(" ", "_").replace("-", "_") x, y = input_node_positions.get(node_name, (50, 50)) comp = syn_node["component"] nodes.append( { "id": node_id, "name": display_name, "type": "INPUT", "inputs": [], "outputs": ["value"], "x": x, "y": y, "has_input": False, "input_value": "", "input_components": [comp], "output_components": [], "is_map_node": False, "map_items": [], "map_item_count": 0, "item_output_type": "text", "status": "pending", "result": "", "is_output_node": False, "is_input_node": True, } ) for node_name in self.graph.nodes: node = self.graph.nodes[node_name] x, y = node_positions.get(node_name, (50, 50)) result = node_results.get(node_name) result_str = "" is_scattered = self._has_scattered_input(node_name) if result is not None and not node._output_components and not is_scattered: if isinstance(result, dict): display_result = { k: v for k, v in result.items() if not k.startswith("_") } result_str = json.dumps(display_result, indent=2, default=str)[:300] elif isinstance(result, (list, tuple)): result_str = json.dumps(list(result)[:5], default=str) else: result_str = str(result)[:300] node_id = node_name.replace(" ", "_").replace("-", "_") input_ports_data = [] for port in node._input_ports or []: if port in node._fixed_inputs: continue port_history = history.get(node_name, {}).get(port, []) input_ports_data.append( { "name": port, "history_count": len(port_history) if port_history else 0, } ) output_components, validation_error = self._build_output_components( node, result ) scattered_items = ( self._build_scattered_items(node_name, result) if is_scattered else [] ) item_output_type = "text" if is_scattered: for comp in node._output_components.values(): if comp is None: continue comp_type = self._get_component_type(comp) if comp_type == "audio": item_output_type = "audio" break item_list_schema = None item_list_items = [] if node._item_list_schemas: first_port = list(node._item_list_schemas.keys())[0] item_list_schema = self._serialize_item_list_schema( node._item_list_schemas[first_port] ) item_list_items = self._build_item_list_items(node, first_port, result) output_ports = [] for port_name in node._output_ports or []: if port_name in node._item_list_schemas: schema = node._item_list_schemas[port_name] for field_name in schema: output_ports.append(f"{port_name}.{field_name}") elif port_name in node._output_components or isinstance( node, InputNode ): output_ports.append(port_name) is_output = self._is_output_node(node_name) is_local = self._is_running_locally(node) variants = None selected_variant = None if isinstance(node, ChoiceNode): variants = [ self._build_variant_data(v, input_values) for v in node._variants ] selected_variant = input_values.get(node_id, {}).get( "_selected_variant", 0 ) is_input_node = isinstance(node, InputNode) node_type = self._get_node_type(node, node_name) embedded_components = [] output_components, validation_error = [], None if is_input_node: embedded_components = self._build_input_components(node) else: result = node_results.get(node_name) output_components, validation_error = self._build_output_components( node, result ) embedded_components = output_components nodes.append( { "id": node_id, "name": node_name, "type": node_type, "url": self._get_node_url(node), "inputs": input_ports_data, "outputs": output_ports, "x": x, "y": y, "has_input": False, "input_value": input_values.get(node_name, ""), "input_components": embedded_components if is_input_node else [], "output_components": output_components, "is_map_node": is_scattered, "map_items": scattered_items, "map_item_count": len(scattered_items), "item_output_type": item_output_type, "item_list_schema": item_list_schema, "item_list_items": item_list_items, "status": node_statuses.get(node_name, "pending"), "result": result_str, "is_output_node": is_output, "is_input_node": is_input_node, "is_local": is_local, "variants": variants, "selected_variant": selected_variant, "validation_error": validation_error, } ) edges = [] for i, edge in enumerate(self.graph._edges): from_port = edge.source_port if edge.item_key: from_port = f"{edge.source_port}.{edge.item_key}" edges.append( { "id": f"edge_{i}", "from_node": edge.source_node._name.replace(" ", "_").replace( "-", "_" ), "from_port": from_port, "to_node": edge.target_node._name.replace(" ", "_").replace( "-", "_" ), "to_port": edge.target_port, "is_scattered": edge.is_scattered, "is_gathered": edge.is_gathered, } ) for i, syn_edge in enumerate(synthetic_edges): edges.append( { "id": f"input_edge_{i}", "from_node": syn_edge["from_node"], "from_port": syn_edge["from_port"], "to_node": syn_edge["to_node"], "to_port": syn_edge["to_port"], } ) return { "name": self.graph.name, "nodes": nodes, "edges": edges, "inputs": input_values, "selected_results": selected_results, "history": history, "session_id": session_id, } def _get_ancestors(self, node_name: str) -> list[str]: ancestors = set() to_visit = [node_name] while to_visit: current = to_visit.pop() for source, _, target, _ in self.graph.get_connections(): if target == current and source not in ancestors: ancestors.add(source) to_visit.append(source) return list(ancestors) def _get_user_provided_output( self, node, node_id: str, input_values: dict[str, Any] ) -> dict[str, Any] | None: if not node._output_components: return None node_inputs = input_values.get(node_id, {}) if not node_inputs: return None result = {} has_user_value = False for port_name, comp in node._output_components.items(): if comp is None: continue if port_name in node_inputs: value = node_inputs[port_name] if value is not None: if isinstance(value, str) and value.startswith("data:"): value = self._save_data_url_as_gradio_file(value) result[port_name] = value has_user_value = True return result if has_user_value else None def _save_data_url_as_gradio_file(self, data_url: str): try: header, data = data_url.split(",", 1) mime_type = header.split(":")[1].split(";")[0] ext_map = { "image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp", "audio/webm": ".webm", "audio/wav": ".wav", "audio/mp3": ".mp3", "audio/mpeg": ".mp3", } ext = ext_map.get(mime_type, ".bin") file_data = base64.b64decode(data) temp_dir = Path(tempfile.gettempdir()) / "daggr_uploads" temp_dir.mkdir(exist_ok=True) file_path = temp_dir / f"{uuid.uuid4()}{ext}" file_path.write_bytes(file_data) return FileValue(str(file_path)) except Exception as e: print(f"[ERROR] Failed to save data URL: {e}") return data_url def _convert_urls_to_file_values(self, data: Any) -> Any: if isinstance(data, str): if data.startswith(("http://", "https://", "/")) and any( data.lower().endswith(ext) for ext in ( ".png", ".jpg", ".jpeg", ".gif", ".webp", ".wav", ".mp3", ".webm", ".mp4", ".ogg", ) ): return FileValue(data) return data elif isinstance(data, dict): return {k: self._convert_urls_to_file_values(v) for k, v in data.items()} elif isinstance(data, list): return [self._convert_urls_to_file_values(item) for item in data] return data async def _execute_to_node( self, session: ExecutionSession, target_node: str, session_id: str | None, input_values: dict[str, Any], selected_results: dict[str, int], ) -> dict: if not session_id: session_id = self.state.create_session(self.graph.persist_key) for node_name, node in self.graph.nodes.items(): if isinstance(node, ChoiceNode): node_id = node_name.replace(" ", "_").replace("-", "_") variant_idx = input_values.get(node_id, {}).get("_selected_variant", 0) session.selected_variants[node_name] = variant_idx ancestors = self._get_ancestors(target_node) nodes_to_run = ancestors + [target_node] execution_order = self.graph.get_execution_order() nodes_to_execute = [n for n in execution_order if n in nodes_to_run] entry_inputs: dict[str, dict[str, Any]] = {} for node_name in nodes_to_execute: node = self.graph.nodes[node_name] if node._input_components: node_inputs = {} for port_name in node._input_components: input_node_name = f"{node_name}__{port_name}" input_node_id = input_node_name.replace(" ", "_").replace("-", "_") if input_node_id in input_values: value = input_values[input_node_id].get("value") if value is not None: node_inputs[port_name] = value current_node_id = node_name.replace(" ", "_").replace("-", "_") if current_node_id in input_values: if port_name in input_values[current_node_id]: value = input_values[current_node_id][port_name] if value is not None: node_inputs[port_name] = value if node_inputs: entry_inputs[node_name] = node_inputs elif isinstance(node, InteractionNode): value = input_values.get(node_name, "") port = node._input_ports[0] if node._input_ports else "input" entry_inputs[node_name] = {port: value} existing_results = {} if session_id: for node_name in nodes_to_execute: if node_name in selected_results: cached = self.state.get_result_by_index( session_id, node_name, selected_results[node_name] ) else: cached = self.state.get_latest_result(session_id, node_name) if cached is not None: existing_results[node_name] = self._convert_urls_to_file_values( cached ) for k, v in existing_results.items(): if k not in session.results: session.results[k] = v if target_node in session.results: del session.results[target_node] node_results = {} node_statuses = {} for node_name in nodes_to_execute: if node_name in existing_results: node_results[node_name] = existing_results[node_name] node_statuses[node_name] = "completed" continue if node_name in session.results: node_results[node_name] = session.results[node_name] node_statuses[node_name] = "completed" continue node_statuses[node_name] = "running" user_input = entry_inputs.get(node_name, {}) result = await self.executor.execute_node(session, node_name, user_input) node_results[node_name] = result node_statuses[node_name] = "completed" self.state.save_result(session_id, node_name, result) return self._build_graph_data( node_results, node_statuses, input_values, {}, session_id, selected_results ) async def _execute_to_node_streaming( self, session: ExecutionSession, target_node: str, sheet_id: str | None, input_values: dict[str, Any], item_list_values: dict[str, Any], selected_results: dict[str, int], run_id: str, user_id: str | None = None, run_ancestors: bool = True, ): can_persist = ( user_id is not None and sheet_id is not None and self.graph.persist_key is not None ) for node_name, node in self.graph.nodes.items(): if isinstance(node, ChoiceNode): node_id = node_name.replace(" ", "_").replace("-", "_") variant_idx = input_values.get(node_id, {}).get("_selected_variant", 0) session.selected_variants[node_name] = variant_idx if run_ancestors: ancestors = self._get_ancestors(target_node) nodes_to_run = ancestors + [target_node] else: nodes_to_run = [target_node] execution_order = self.graph.get_execution_order() nodes_to_execute = [n for n in execution_order if n in nodes_to_run] entry_inputs: dict[str, dict[str, Any]] = {} for node_name in nodes_to_execute: node = self.graph.nodes[node_name] if node._input_components: node_inputs = {} for port_name in node._input_components: input_node_name = f"{node_name}__{port_name}" input_node_id = input_node_name.replace(" ", "_").replace("-", "_") if input_node_id in input_values: value = input_values[input_node_id].get("value") if value is not None: node_inputs[port_name] = value current_node_id = node_name.replace(" ", "_").replace("-", "_") if current_node_id in input_values: if port_name in input_values[current_node_id]: value = input_values[current_node_id][port_name] if value is not None: node_inputs[port_name] = value if node_inputs: entry_inputs[node_name] = node_inputs elif isinstance(node, InteractionNode): value = input_values.get(node_name, "") port = node._input_ports[0] if node._input_ports else "input" entry_inputs[node_name] = {port: value} existing_results = {} for node_name in nodes_to_execute: node = self.graph.nodes[node_name] node_id = node_name.replace(" ", "_").replace("-", "_") user_output = self._get_user_provided_output(node, node_id, input_values) if user_output is not None: existing_results[node_name] = user_output if can_persist: snapshot = { "inputs": input_values, "selected_results": selected_results, } self.state.save_result(sheet_id, node_name, user_output, snapshot) continue if node_name == target_node: continue if can_persist: if node_name in selected_results: cached = self.state.get_result_by_index( sheet_id, node_name, selected_results[node_name] ) else: cached = self.state.get_latest_result(sheet_id, node_name) if cached is not None: existing_results[node_name] = self._convert_urls_to_file_values( cached ) for k, v in existing_results.items(): if k not in session.results: session.results[k] = v if target_node in session.results: del session.results[target_node] node_results = {} node_statuses = {} try: for node_name in nodes_to_execute: if node_name in existing_results: result = existing_results[node_name] result = self._apply_item_list_edits( node_name, result, item_list_values ) node_results[node_name] = result session.results[node_name] = result node_statuses[node_name] = "completed" continue if node_name in session.results: result = session.results[node_name] result = self._apply_item_list_edits( node_name, result, item_list_values ) node_results[node_name] = result node_statuses[node_name] = "completed" continue can_execute = await session.start_node_execution(node_name) if not can_execute: if node_name == target_node: return await session.wait_for_node(node_name) if node_name in session.results: result = session.results[node_name] result = self._apply_item_list_edits( node_name, result, item_list_values ) node_results[node_name] = result node_statuses[node_name] = "completed" continue try: node_statuses[node_name] = "running" user_input = entry_inputs.get(node_name, {}) yield { "type": "node_started", "started_node": node_name, "run_id": run_id, } start_time = time.time() result = await self.executor.execute_node( session, node_name, user_input ) elapsed_ms = (time.time() - start_time) * 1000 result = self._apply_item_list_edits( node_name, result, item_list_values ) session.results[node_name] = result node_results[node_name] = result node_statuses[node_name] = "completed" if can_persist: current_count = self.state.get_result_count(sheet_id, node_name) snapshot = { "inputs": input_values, "selected_results": selected_results, } self.state.save_result(sheet_id, node_name, result, snapshot) selected_results[node_name] = current_count graph_data = self._build_graph_data( node_results, node_statuses, input_values, {}, sheet_id, selected_results, ) graph_data["type"] = "node_complete" graph_data["completed_node"] = node_name graph_data["run_id"] = run_id graph_data["execution_time_ms"] = elapsed_ms finally: await session.finish_node_execution(node_name) yield graph_data except Exception as e: error_node = None if nodes_to_execute: current_idx = len(node_results) if current_idx < len(nodes_to_execute): error_node = nodes_to_execute[current_idx] node_statuses[error_node] = "error" node_results[error_node] = {"error": str(e)} graph_data = self._build_graph_data( node_results, node_statuses, input_values, {}, sheet_id, selected_results, ) graph_data["type"] = "error" graph_data["run_id"] = run_id graph_data["error"] = str(e) graph_data["nodes_to_clear"] = nodes_to_execute if error_node: graph_data["node"] = error_node graph_data["completed_node"] = error_node yield graph_data async def _execute_workflow_api( self, request: Request, subgraph_id: str | None = None ) -> JSONResponse: try: body = await request.json() except Exception: body = {} input_values = body.get("inputs", {}) session = ExecutionSession(self.graph) subgraphs = self.graph.get_subgraphs() output_node_names = set(self.graph.get_output_nodes()) if subgraph_id is None: if len(subgraphs) > 1: return JSONResponse( { "error": "Multiple subgraphs detected. Please specify a subgraph_id.", "available_subgraphs": [ f"subgraph_{i}" for i in range(len(subgraphs)) ], }, status_code=400, ) target_nodes = subgraphs[0] if subgraphs else set(self.graph.nodes.keys()) else: if subgraph_id == "main" and len(subgraphs) == 1: target_nodes = subgraphs[0] elif subgraph_id.startswith("subgraph_"): try: idx = int(subgraph_id.split("_")[1]) if idx < 0 or idx >= len(subgraphs): return JSONResponse( {"error": f"Subgraph '{subgraph_id}' not found"}, status_code=404, ) target_nodes = subgraphs[idx] except (ValueError, IndexError): return JSONResponse( {"error": f"Invalid subgraph_id '{subgraph_id}'"}, status_code=400, ) else: return JSONResponse( {"error": f"Subgraph '{subgraph_id}' not found"}, status_code=404, ) for node_name, node in self.graph.nodes.items(): if isinstance(node, ChoiceNode): node_id = node_name.replace(" ", "_").replace("-", "_") variant_idx = input_values.get(f"{node_id}___selected_variant", 0) session.selected_variants[node_name] = variant_idx execution_order = self.graph.get_execution_order() nodes_to_execute = [n for n in execution_order if n in target_nodes] entry_inputs: dict[str, dict[str, Any]] = {} for node_name in nodes_to_execute: node = self.graph.nodes[node_name] if node._input_components: node_inputs = {} for port_name in node._input_components: input_node_id = f"{node_name}__{port_name}".replace( " ", "_" ).replace("-", "_") if input_node_id in input_values: node_inputs[port_name] = input_values[input_node_id] if node_inputs: entry_inputs[node_name] = node_inputs session.results = {} node_results = {} try: for node_name in nodes_to_execute: user_input = entry_inputs.get(node_name, {}) result = await self.executor.execute_node( session, node_name, user_input ) node_results[node_name] = result except Exception as e: return JSONResponse( {"error": f"Execution error in node '{node_name}': {str(e)}"}, status_code=500, ) outputs = {} for node_name in nodes_to_execute: if node_name in output_node_names and node_name in node_results: result = node_results[node_name] result = self._transform_file_paths(result) outputs[node_name] = result return JSONResponse({"outputs": outputs}) def run( self, host: str | None = None, port: int | None = None, share: bool | None = None, open_browser: bool = True, **kwargs, ): from gradio.utils import colab_check, ipython_check if host is None: host = os.environ.get("GRADIO_SERVER_NAME", "127.0.0.1") if port is None: port = int(os.environ.get("GRADIO_SERVER_PORT", "7860")) actual_port = _find_available_port(host, port) if actual_port != port: print(f"\n Port {port} is in use, using {actual_port} instead.") self.graph._validate_edges() is_colab = colab_check() is_kaggle = os.environ.get("KAGGLE_KERNEL_RUN_TYPE") is not None is_notebook = is_colab or is_kaggle or ipython_check() if share is None: share = is_colab or is_kaggle if is_notebook or share: config = uvicorn.Config( app=self.app, host=host, port=actual_port, log_level="warning", ) server = _Server(config) server.run_in_thread() local_url = f"http://{host}:{actual_port}" print(f"\n UI running at: {local_url}") if self.api_server: print(f" API server at: {local_url}/api") share_url = None if share: from gradio.networking import setup_tunnel share_token = secrets.token_urlsafe(32) share_url = setup_tunnel( local_host=host, local_port=actual_port, share_token=share_token, share_server_address=None, share_server_tls_certificate=None, ) print(f" Public URL: {share_url}") print( "\n This share link expires in 1 week. For permanent hosting, deploy to Hugging Face Spaces.\n" ) if is_colab or is_kaggle: from IPython.display import HTML, display url = share_url or local_url display( HTML(f'Open daggr app: {url}') ) elif open_browser: webbrowser.open_new_tab(share_url or local_url) try: while True: time.sleep(1) except KeyboardInterrupt: print("\nShutting down...") server.close() else: local_url = f"http://{host}:{actual_port}" print(f"\n UI running at: {local_url}") if self.api_server: print(f" API server at: {local_url}/api") print() if open_browser: threading.Timer(0.5, lambda: webbrowser.open_new_tab(local_url)).start() uvicorn.run( self.app, host=host, port=actual_port, log_level="warning", **kwargs ) class _Server(uvicorn.Server): def install_signal_handlers(self): pass def run_in_thread(self): self.thread = threading.Thread(target=self.run, daemon=True) self.thread.start() start = time.time() while not self.started: time.sleep(1e-3) if time.time() - start > 5: raise RuntimeError( "Server failed to start. Please check that the port is available." ) def close(self): self.should_exit = True self.thread.join(timeout=5) ================================================ FILE: daggr/session.py ================================================ """Session management for daggr, including per-session execution contexts for security isolation and concurrency management.""" from __future__ import annotations import asyncio from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from daggr.graph import Graph class ConcurrencyManager: """Manages concurrency limits for FnNode execution within a session. By default, only one FnNode runs at a time per session. FnNodes can opt into concurrent execution via the `concurrent` parameter, and can share limits via `concurrency_group`. """ def __init__(self): self._default_semaphore = asyncio.Semaphore(1) self._group_semaphores: dict[str, asyncio.Semaphore] = {} self._lock = asyncio.Lock() async def get_semaphore( self, concurrent: bool, concurrency_group: str | None, max_concurrent: int, ) -> asyncio.Semaphore | None: """Get the appropriate semaphore for a FnNode. Returns None if the node should run without concurrency limits (concurrent=True with no group). """ if not concurrent: return self._default_semaphore if concurrency_group: async with self._lock: if concurrency_group not in self._group_semaphores: self._group_semaphores[concurrency_group] = asyncio.Semaphore( max_concurrent ) return self._group_semaphores[concurrency_group] return None class ExecutionSession: """Per-session execution context. Each WebSocket connection gets its own ExecutionSession, providing: - Isolated HF token - Isolated results cache - Isolated Gradio client cache - Per-session concurrency management - Node execution coordination (wait for dependencies) """ def __init__(self, graph: Graph, hf_token: str | None = None): self.graph = graph self.hf_token = hf_token self.results: dict[str, Any] = {} self.scattered_results: dict[str, list[Any]] = {} self.selected_variants: dict[str, int] = {} self.clients: dict[str, Any] = {} self.concurrency = ConcurrencyManager() self._executing_nodes: dict[str, asyncio.Event] = {} self._execution_lock = asyncio.Lock() def set_hf_token(self, token: str | None): """Update the HF token and clear cached clients.""" if token != self.hf_token: self.hf_token = token self.clients = {} def clear_results(self): """Clear cached results for a fresh execution.""" self.results = {} self.scattered_results = {} async def wait_for_node(self, node_name: str) -> bool: """Wait for a node to finish executing if it's currently running. Returns True if we waited (node was executing), False otherwise. """ async with self._execution_lock: event = self._executing_nodes.get(node_name) if event: await event.wait() return True return False async def start_node_execution(self, node_name: str) -> bool: """Mark a node as starting execution. Returns True if we can start (no one else is executing it). Returns False if someone else is already executing it. """ async with self._execution_lock: if node_name in self._executing_nodes: return False self._executing_nodes[node_name] = asyncio.Event() return True async def finish_node_execution(self, node_name: str): """Mark a node as finished executing and notify waiters.""" async with self._execution_lock: event = self._executing_nodes.pop(node_name, None) if event: event.set() ================================================ FILE: daggr/state.py ================================================ from __future__ import annotations import json import os import sqlite3 import uuid from datetime import datetime from pathlib import Path from typing import Any from huggingface_hub import constants def get_daggr_cache_dir() -> Path: """Get the daggr cache directory, respecting HF_HOME env var.""" cache_dir = Path(constants.HF_HOME) / "daggr" cache_dir.mkdir(parents=True, exist_ok=True) return cache_dir def get_daggr_files_dir() -> Path: files_dir = get_daggr_cache_dir() / "files" files_dir.mkdir(parents=True, exist_ok=True) return files_dir class SessionState: def __init__(self, db_path: str | None = None): if db_path is None: db_path = str(get_daggr_cache_dir() / "sessions.db") self.db_path = db_path self._init_db() def _init_db(self): conn = sqlite3.connect(self.db_path) cursor = conn.cursor() self._migrate_legacy_schema(cursor) cursor.execute(""" CREATE TABLE IF NOT EXISTS sheets ( sheet_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, graph_name TEXT NOT NULL, name TEXT, transform TEXT, created_at TEXT, updated_at TEXT ) """) cursor.execute("PRAGMA table_info(sheets)") columns = [col[1] for col in cursor.fetchall()] if "transform" not in columns: cursor.execute("ALTER TABLE sheets ADD COLUMN transform TEXT") cursor.execute(""" CREATE INDEX IF NOT EXISTS idx_sheets_user_graph ON sheets(user_id, graph_name) """) cursor.execute(""" CREATE TABLE IF NOT EXISTS node_inputs ( id INTEGER PRIMARY KEY AUTOINCREMENT, sheet_id TEXT, node_name TEXT, port_name TEXT, value TEXT, updated_at TEXT, FOREIGN KEY (sheet_id) REFERENCES sheets(sheet_id) ON DELETE CASCADE, UNIQUE(sheet_id, node_name, port_name) ) """) cursor.execute(""" CREATE INDEX IF NOT EXISTS idx_node_inputs_sheet ON node_inputs(sheet_id) """) cursor.execute(""" CREATE TABLE IF NOT EXISTS node_results ( id INTEGER PRIMARY KEY AUTOINCREMENT, sheet_id TEXT, node_name TEXT, result TEXT, inputs_snapshot TEXT, created_at TEXT, FOREIGN KEY (sheet_id) REFERENCES sheets(sheet_id) ON DELETE CASCADE ) """) cursor.execute("PRAGMA table_info(node_results)") result_columns = [col[1] for col in cursor.fetchall()] if "inputs_snapshot" not in result_columns: cursor.execute("ALTER TABLE node_results ADD COLUMN inputs_snapshot TEXT") cursor.execute(""" CREATE INDEX IF NOT EXISTS idx_node_results_sheet_node ON node_results(sheet_id, node_name) """) conn.commit() conn.close() def _migrate_legacy_schema(self, cursor): cursor.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='node_inputs'" ) if cursor.fetchone(): cursor.execute("PRAGMA table_info(node_inputs)") columns = [col[1] for col in cursor.fetchall()] if "session_id" in columns and "sheet_id" not in columns: cursor.execute("ALTER TABLE node_inputs RENAME TO _node_inputs_old") cursor.execute("ALTER TABLE node_results RENAME TO _node_results_old") cursor.execute("ALTER TABLE sessions RENAME TO _sessions_old") cursor.execute(""" CREATE TABLE sheets ( sheet_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, graph_name TEXT NOT NULL, name TEXT, created_at TEXT, updated_at TEXT ) """) cursor.execute(""" CREATE TABLE node_inputs ( id INTEGER PRIMARY KEY AUTOINCREMENT, sheet_id TEXT, node_name TEXT, port_name TEXT, value TEXT, updated_at TEXT, FOREIGN KEY (sheet_id) REFERENCES sheets(sheet_id) ON DELETE CASCADE, UNIQUE(sheet_id, node_name, port_name) ) """) cursor.execute(""" CREATE TABLE node_results ( id INTEGER PRIMARY KEY AUTOINCREMENT, sheet_id TEXT, node_name TEXT, result TEXT, created_at TEXT, FOREIGN KEY (sheet_id) REFERENCES sheets(sheet_id) ON DELETE CASCADE ) """) cursor.execute(""" INSERT INTO sheets (sheet_id, user_id, graph_name, name, created_at, updated_at) SELECT session_id, 'local', graph_name, 'Migrated Sheet', created_at, updated_at FROM _sessions_old """) cursor.execute(""" INSERT INTO node_inputs (sheet_id, node_name, port_name, value, updated_at) SELECT session_id, node_name, port_name, value, updated_at FROM _node_inputs_old """) cursor.execute(""" INSERT INTO node_results (sheet_id, node_name, result, created_at) SELECT session_id, node_name, result, created_at FROM _node_results_old """) cursor.execute("DROP TABLE _sessions_old") cursor.execute("DROP TABLE _node_inputs_old") cursor.execute("DROP TABLE _node_results_old") def get_effective_user_id(self, hf_user: dict | None = None) -> str | None: is_on_spaces = os.environ.get("SPACE_ID") is not None if hf_user and hf_user.get("username"): return hf_user["username"] if is_on_spaces: return None return "local" def create_sheet( self, user_id: str, graph_name: str, name: str | None = None ) -> str: sheet_id = str(uuid.uuid4()) now = datetime.now().isoformat() if not name: count = self.get_sheet_count(user_id, graph_name) name = f"Sheet {count + 1}" conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute( """INSERT INTO sheets (sheet_id, user_id, graph_name, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)""", (sheet_id, user_id, graph_name, name, now, now), ) conn.commit() conn.close() return sheet_id def get_sheet_count(self, user_id: str, graph_name: str) -> int: conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute( "SELECT COUNT(*) FROM sheets WHERE user_id = ? AND graph_name = ?", (user_id, graph_name), ) count = cursor.fetchone()[0] conn.close() return count def list_sheets(self, user_id: str, graph_name: str) -> list[dict[str, Any]]: conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute( """SELECT sheet_id, name, created_at, updated_at FROM sheets WHERE user_id = ? AND graph_name = ? ORDER BY updated_at DESC""", (user_id, graph_name), ) rows = cursor.fetchall() conn.close() return [ { "sheet_id": row[0], "name": row[1], "created_at": row[2], "updated_at": row[3], } for row in rows ] def get_sheet(self, sheet_id: str) -> dict[str, Any] | None: conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute( """SELECT sheet_id, user_id, graph_name, name, transform, created_at, updated_at FROM sheets WHERE sheet_id = ?""", (sheet_id,), ) row = cursor.fetchone() conn.close() if row: transform = None if row[4]: try: transform = json.loads(row[4]) except (json.JSONDecodeError, TypeError): pass return { "sheet_id": row[0], "user_id": row[1], "graph_name": row[2], "name": row[3], "transform": transform, "created_at": row[5], "updated_at": row[6], } return None def save_transform(self, sheet_id: str, x: float, y: float, scale: float) -> bool: now = datetime.now().isoformat() transform = json.dumps({"x": x, "y": y, "scale": scale}) conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute( "UPDATE sheets SET transform = ?, updated_at = ? WHERE sheet_id = ?", (transform, now, sheet_id), ) updated = cursor.rowcount > 0 conn.commit() conn.close() return updated def rename_sheet(self, sheet_id: str, new_name: str) -> bool: now = datetime.now().isoformat() conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute( "UPDATE sheets SET name = ?, updated_at = ? WHERE sheet_id = ?", (new_name, now, sheet_id), ) updated = cursor.rowcount > 0 conn.commit() conn.close() return updated def delete_sheet(self, sheet_id: str) -> bool: conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute("DELETE FROM node_inputs WHERE sheet_id = ?", (sheet_id,)) cursor.execute("DELETE FROM node_results WHERE sheet_id = ?", (sheet_id,)) cursor.execute("DELETE FROM sheets WHERE sheet_id = ?", (sheet_id,)) deleted = cursor.rowcount > 0 conn.commit() conn.close() return deleted def get_or_create_sheet( self, user_id: str, graph_name: str, sheet_id: str | None = None ) -> str: if sheet_id: sheet = self.get_sheet(sheet_id) if sheet and sheet["user_id"] == user_id: return sheet_id sheets = self.list_sheets(user_id, graph_name) if sheets: return sheets[0]["sheet_id"] return self.create_sheet(user_id, graph_name) def save_input(self, sheet_id: str, node_name: str, port_name: str, value: Any): now = datetime.now().isoformat() value_json = json.dumps(value, default=str) conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute( """INSERT INTO node_inputs (sheet_id, node_name, port_name, value, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(sheet_id, node_name, port_name) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at""", (sheet_id, node_name, port_name, value_json, now), ) cursor.execute( "UPDATE sheets SET updated_at = ? WHERE sheet_id = ?", (now, sheet_id), ) conn.commit() conn.close() def get_inputs(self, sheet_id: str) -> dict[str, dict[str, Any]]: conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute( "SELECT node_name, port_name, value FROM node_inputs WHERE sheet_id = ?", (sheet_id,), ) results = cursor.fetchall() conn.close() inputs: dict[str, dict[str, Any]] = {} for node_name, port_name, value_json in results: if node_name not in inputs: inputs[node_name] = {} inputs[node_name][port_name] = json.loads(value_json) return inputs def save_result( self, sheet_id: str, node_name: str, result: Any, inputs_snapshot: dict[str, Any] | None = None, ): now = datetime.now().isoformat() result_json = json.dumps(result, default=str) inputs_json = ( json.dumps(inputs_snapshot, default=str) if inputs_snapshot else None ) conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute( "INSERT INTO node_results (sheet_id, node_name, result, inputs_snapshot, created_at) VALUES (?, ?, ?, ?, ?)", (sheet_id, node_name, result_json, inputs_json, now), ) cursor.execute( "UPDATE sheets SET updated_at = ? WHERE sheet_id = ?", (now, sheet_id), ) conn.commit() conn.close() def get_latest_result(self, sheet_id: str, node_name: str) -> Any | None: conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute( """SELECT result FROM node_results WHERE sheet_id = ? AND node_name = ? ORDER BY created_at DESC LIMIT 1""", (sheet_id, node_name), ) result = cursor.fetchone() conn.close() if result: return json.loads(result[0]) return None def get_result_count(self, sheet_id: str, node_name: str) -> int: conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute( "SELECT COUNT(*) FROM node_results WHERE sheet_id = ? AND node_name = ?", (sheet_id, node_name), ) count = cursor.fetchone()[0] conn.close() return count def get_result_by_index( self, sheet_id: str, node_name: str, index: int ) -> Any | None: conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute( """SELECT result FROM node_results WHERE sheet_id = ? AND node_name = ? ORDER BY created_at ASC""", (sheet_id, node_name), ) results = cursor.fetchall() conn.close() if results and 0 <= index < len(results): return json.loads(results[index][0]) elif results: return json.loads(results[-1][0]) return None def get_all_results(self, sheet_id: str) -> dict[str, list[Any]]: conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute( """SELECT node_name, result, inputs_snapshot FROM node_results WHERE sheet_id = ? ORDER BY created_at ASC""", (sheet_id,), ) results = cursor.fetchall() conn.close() all_results: dict[str, list[Any]] = {} for node_name, result_json, inputs_json in results: if node_name not in all_results: all_results[node_name] = [] result_data = { "result": json.loads(result_json), "inputs_snapshot": json.loads(inputs_json) if inputs_json else None, } all_results[node_name].append(result_data) return all_results def get_sheet_state(self, sheet_id: str) -> dict[str, Any]: return { "inputs": self.get_inputs(sheet_id), "results": self.get_all_results(sheet_id), } def clear_sheet_data(self, sheet_id: str): conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute("DELETE FROM node_inputs WHERE sheet_id = ?", (sheet_id,)) cursor.execute("DELETE FROM node_results WHERE sheet_id = ?", (sheet_id,)) conn.commit() conn.close() def create_session(self, graph_name: str) -> str: return self.create_sheet("local", graph_name) def get_or_create_session(self, session_id: str | None, graph_name: str) -> str: return self.get_or_create_sheet("local", graph_name, session_id) ================================================ FILE: examples/01_quickstart.py ================================================ # Showcases basic GradioNode chaining: generate an image then remove its background. import random import gradio as gr from daggr import GradioNode, Graph glm_image = GradioNode( "hf-applications/Z-Image-Turbo", api_name="/generate_image", inputs={ "prompt": gr.Textbox( # An input node is created for the prompt label="Prompt", value="A cheetah in the grassy savanna.", lines=3, ), "height": 1024, # Fixed value (does not appear in the canvas) "width": 1024, # Fixed value (does not appear in the canvas) "seed": random.random, # Functions are rerun every time the workflow is run (not shown in the canvas) }, outputs={ "image": gr.Image( label="Image" # Display original image ), }, ) background_remover = GradioNode( "hf-applications/background-removal", api_name="/image", inputs={ "image": glm_image.image, }, postprocess=lambda _, final: final, outputs={ "image": gr.Image(label="Final Image"), # Display only final image }, ) graph = Graph( name="Transparent Background Image Generator", nodes=[glm_image, background_remover] ) graph.launch() ================================================ FILE: examples/02_voice_design_comparator_app.py ================================================ # Showcases parallel execution by comparing two TTS services (Qwen and Maya) with the same input. import gradio as gr from daggr import GradioNode, Graph voice_description = gr.Textbox( label="Host Voice Description", value="Deep British voice that is very professional and authoritative...", lines=3, ) text_to_speak = gr.Textbox( label="Text to Speak", value="Hi! I'm the host of a podcast. It's going to be a great episode!", lines=3, ) qwen_voice = GradioNode( space_or_url="Qwen/Qwen3-TTS", api_name="/generate_voice_design", inputs={ "voice_description": voice_description, "language": "Auto", "text": text_to_speak, }, outputs={ "audio": gr.Audio(label="Host Voice"), "status": None, }, ) maya_voice = GradioNode( space_or_url="maya-research/maya1", api_name="/generate_speech", inputs={ "preset_name": "Male American", "description": voice_description, "text": text_to_speak, "temperature": 0.4, "max_tokens": 1500, }, outputs={ "audio": gr.Audio(label="Host Voice"), "status": None, }, ) graph = Graph( name="Voice Designing Comparator", nodes=[qwen_voice, maya_voice], ) graph.launch() ================================================ FILE: examples/03_mock_podcast_app.py ================================================ # Showcases scatter/gather with ItemList: generate dialogue items and process each with TTS, then combine. import ssl import tempfile import time import urllib.request import gradio as gr from pydub import AudioSegment from daggr import FnNode, GradioNode, Graph, ItemList host_voice = GradioNode( space_or_url="abidlabs/tts", # Currently mocked. But this would be a call to e.g. Qwen/Qwen3-TTS api_name="/generate_voice_design", inputs={ "voice_description": gr.Textbox( label="Host Voice Description", value="Deep British voice that is very professional and authoritative...", lines=3, ), "language": "Auto", "text": "Hi! I'm the host of podcast. It's going to be a great episode!", }, outputs={ "audio": gr.Audio(label="Host Voice"), "status": gr.Text(visible=False), }, ) guest_voice = GradioNode( space_or_url="abidlabs/tts", api_name="/generate_voice_design", inputs={ "voice_description": gr.Textbox( label="Guest Voice Description", value="Energetic, friendly young voice with American accent...", lines=3, ), "language": "Auto", "text": "Hi! I'm the guest of podcast. Super excited to be here!", }, outputs={ "audio": gr.Audio(label="Guest Voice"), "status": gr.Text(visible=False), }, ) def generate_dialogue(topic: str) -> list: time.sleep(1) return [ {"speaker": "Host", "text": "Hello, welcome to the show!"}, {"speaker": "Guest", "text": "Thanks for having me!"}, {"speaker": "Host", "text": "Today we're discussing " + topic}, {"speaker": "Guest", "text": "Yes, it's a fascinating topic!"}, ] dialogue = FnNode( fn=generate_dialogue, inputs={ "topic": gr.Textbox(label="Topic", value="AI in healthcare..."), }, outputs={ "items": ItemList( speaker=gr.Dropdown(choices=["Host", "Guest"]), text=gr.Textbox(lines=2), ), }, ) def chatterbox(text: str, speaker: str, host_audio: str, guest_audio: str) -> str: voice_map = {"Host": host_audio, "Guest": guest_audio} return voice_map.get(speaker, host_audio) samples = FnNode( fn=chatterbox, inputs={ "text": dialogue.items.text, "speaker": dialogue.items.speaker, "host_audio": host_voice.audio, "guest_audio": guest_voice.audio, }, outputs={ "audio": gr.Audio(label="Sample"), }, ) def combine_audio_files(audio_files: list[str]) -> str: if not audio_files: return None if len(audio_files) == 1: return audio_files[0] combined = AudioSegment.empty() for audio_path in audio_files: if audio_path: if audio_path.startswith(("http://", "https://")): tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE with urllib.request.urlopen(audio_path, context=ctx) as response: tmp.write(response.read()) tmp.close() segment = AudioSegment.from_file(tmp.name) else: segment = AudioSegment.from_file(audio_path) combined += segment output_path = tempfile.mktemp(suffix=".mp3") combined.export(output_path, format="mp3") return output_path full_audio = FnNode( fn=combine_audio_files, inputs={ "audio_files": samples.audio.all(), }, outputs={ "audio": gr.Audio(label="Full Audio"), }, ) graph = Graph( name="Mock Podcast Generator", nodes=[host_voice, guest_voice, dialogue, samples, full_audio], ) graph.launch() ================================================ FILE: examples/04_complete_podcast_app.py ================================================ # Showcases a complete podcast generator using real TTS (Qwen3-TTS) with scatter/gather for multi-speaker audio. import ssl import tempfile import time import urllib.request import gradio as gr from pydub import AudioSegment from daggr import FnNode, GradioNode, Graph, ItemList host_voice = GradioNode( space_or_url="Qwen/Qwen3-TTS", # Currently mocked. But this would be a call to e.g. Qwen/Qwen3-TTS api_name="/generate_voice_design", inputs={ "voice_description": gr.Textbox( label="Host Voice Description", value="Deep British voice that is very professional and authoritative...", lines=3, ), "language": "English", "text": "Hi! I'm the host of this podcast. It's going to be a great episode!", }, outputs={ "audio": gr.Audio(label="Host Voice"), "status": None, }, ) guest_voice = GradioNode( space_or_url="Qwen/Qwen3-TTS", # Currently mocked. But this would be a call to e.g. Qwen/Qwen3-TTS api_name="/generate_voice_design", inputs={ "voice_description": gr.Textbox( label="Guest Voice Description", value="Energetic, friendly young woman with American accent...", lines=3, ), "language": "English", "text": "Hi! I'm the guest on this podcast. Super excited to be here!", }, outputs={ "audio": gr.Audio(label="Guest Voice"), "status": None, }, ) def generate_dialogue(topic: str) -> list: time.sleep(1) return [ {"speaker": "Host", "text": "Hello, welcome to the show!"}, {"speaker": "Guest", "text": "Thanks for having me!"}, {"speaker": "Host", "text": "Today we're discussing " + topic}, {"speaker": "Guest", "text": "Yes, it's a fascinating topic!"}, ] dialogue = FnNode( fn=generate_dialogue, inputs={ "topic": gr.Textbox(label="Topic", value="AI in healthcare..."), }, outputs={ "items": ItemList( speaker=gr.Dropdown(choices=["Host", "Guest"]), text=gr.Textbox(lines=2), ), }, ) def chatterbox(text: str, speaker: str, host_audio: str, guest_audio: str) -> str: voice_map = {"Host": host_audio, "Guest": guest_audio} return voice_map.get(speaker, host_audio) samples = FnNode( fn=chatterbox, inputs={ "text": dialogue.items.text, "speaker": dialogue.items.speaker, "host_audio": host_voice.audio, "guest_audio": guest_voice.audio, }, outputs={ "audio": gr.Audio(label="Sample"), }, ) def combine_audio_files(audio_files: list[str]) -> str: if not audio_files: return None if len(audio_files) == 1: return audio_files[0] combined = AudioSegment.empty() for audio_path in audio_files: if audio_path: if audio_path.startswith(("http://", "https://")): tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE with urllib.request.urlopen(audio_path, context=ctx) as response: tmp.write(response.read()) tmp.close() segment = AudioSegment.from_file(tmp.name) else: segment = AudioSegment.from_file(audio_path) combined += segment output_path = tempfile.mktemp(suffix=".mp3") combined.export(output_path, format="mp3") return output_path full_audio = FnNode( fn=combine_audio_files, inputs={ "audio_files": samples.audio.all(), }, outputs={ "audio": gr.Audio(label="Full Audio"), }, ) graph = Graph( name="Complete Podcast Generator", nodes=[host_voice, guest_voice, dialogue, samples, full_audio], ) graph.launch() ================================================ FILE: examples/05_local_translation_app.py ================================================ # Showcases running a GradioNode locally with run_locally=True instead of calling a remote Space. import gradio as gr from daggr import GradioNode, Graph translator = GradioNode( "abidlabs/en2fr", api_name="/predict", run_locally=True, inputs={ "text": gr.Textbox( label="English Text", value="Hello, how are you today?", lines=3, ), }, outputs={ "translation": gr.Textbox(label="French Translation"), }, ) graph = Graph(name="English to French Translator (Local)", nodes=[translator]) graph.launch() ================================================ FILE: examples/06_pig_latin_voice_app.py ================================================ # Showcases InferenceNode for speech-to-text and text-to-speech with an FnNode transformation in between. import gradio as gr from daggr import FnNode, Graph, InferenceNode original = InferenceNode( model="openai/whisper-large-v3:replicate", inputs={ "audio": gr.Audio(label="Audio"), }, outputs={ "text": gr.Textbox(label="Text"), }, ) def pig_latin_sentence(text: str) -> str: words = text.split() pig_latin_words = [] for word in words: pig_latin_words.append(word[1:] + word[0] + "ay") return " ".join(pig_latin_words) pig_latin = FnNode( fn=pig_latin_sentence, inputs={ "text": original.text, }, outputs={ "text": gr.Textbox(label="Text"), }, ) output = InferenceNode( model="hexgrad/Kokoro-82M", inputs={ "text": pig_latin.text, }, outputs={ "audio": gr.Audio(label="Audio"), }, ) graph = Graph(name="Pig Latin Voice App", nodes=[original, pig_latin, output]) graph.launch() ================================================ FILE: examples/07_image_to_3d_app.py ================================================ # Showcases a multi-step image-to-3D pipeline: background removal → downscaling → FLUX enhancement → TRELLIS 3D. import uuid from typing import Any import gradio as gr from PIL import Image from daggr import FnNode, GradioNode, Graph, InferenceNode from daggr.state import get_daggr_files_dir def downscale_image_to_file(image: Any, scale: float = 0.25) -> str | None: pil_img = Image.open(image) scale_f = max(0.05, min(1.0, float(scale))) w, h = pil_img.size new_w = max(1, int(w * scale_f)) new_h = max(1, int(h * scale_f)) resized = pil_img.resize((new_w, new_h), resample=Image.LANCZOS) out_path = get_daggr_files_dir() / f"{uuid.uuid4()}.png" resized.save(out_path) return str(out_path) background_remover = GradioNode( "merve/background-removal", api_name="/image", run_locally=True, inputs={ "image": gr.Image(), }, outputs={ "original_image": None, "final_image": gr.Image(label="Final Image"), }, ) downscaler = FnNode( downscale_image_to_file, name="Downscale image for Inference", inputs={ "image": background_remover.final_image, "scale": gr.Slider( label="Downscale factor", minimum=0.25, maximum=0.75, step=0.05, value=0.25, ), }, outputs={ "image": gr.Image(label="Downscaled Image", type="filepath"), }, ) flux_enhancer = InferenceNode( model="black-forest-labs/FLUX.2-klein-4B:fal-ai", inputs={ "image": downscaler.image, "prompt": gr.Textbox( label="prompt", value=("Transform this into a clean 3D asset render"), lines=3, ), }, outputs={ "image": gr.Image(label="3D-Ready Enhanced Image"), }, ) trellis_3d = GradioNode( "microsoft/TRELLIS.2", api_name="/image_to_3d", inputs={ "image": flux_enhancer.image, "ss_guidance_strength": 7.5, "ss_sampling_steps": 12, }, outputs={ "glb": gr.HTML(label="3D Asset (GLB preview)"), }, ) graph = Graph( name="Image to 3D Asset Pipeline", nodes=[background_remover, downscaler, flux_enhancer, trellis_3d], ) if __name__ == "__main__": graph.launch() ================================================ FILE: examples/08_text_to_3d_app.py ================================================ # Showcases a text-to-3D pipeline: FLUX image generation → background removal → TRELLIS mesh extraction. import gradio as gr from daggr import GradioNode, Graph text_to_image = GradioNode( "hysts-mcp/FLUX.1-dev", api_name="/infer", inputs={ "prompt": gr.Textbox( label="Prompt", value="A cute baby dragon breathing fire", lines=3, ), "height": 1024, "width": 1024, "seed": gr.Number( label="Seed (Image generation)", value=0, minimum=0, maximum=1000 ), }, outputs={ "image": gr.Image(label="Image"), }, ) background_remover = GradioNode( "hysts-mcp/rembg", api_name="/remove_background", inputs={ "image": text_to_image.image, }, outputs={ "output": gr.Image(label="Output"), "original_image": None, }, ) image_to_3d_step1 = GradioNode( "hysts-mcp/TRELLIS", api_name="/image_to_3d", inputs={ "image": background_remover.output, "seed": gr.Number( label="Seed (Mesh generation)", value=0, minimum=0, maximum=1000 ), "ss_guidance_strength": 7.5, "ss_sampling_steps": 12, "slat_guidance_strength": 3.0, "slat_sampling_steps": 12, }, outputs={ "state": gr.File(label="State file"), "video": gr.Video(label="Video visualization"), }, ) image_to_3d_step2 = GradioNode( "hysts-mcp/TRELLIS", api_name="/extract_glb", inputs={ "state_path": image_to_3d_step1.state, "mesh_simplify": 0.95, "texture_size": 1024, }, outputs={ "Mesh": gr.Model3D(label="Mesh"), }, ) graph = Graph( name="text to image to 3d", nodes=[text_to_image, background_remover, image_to_3d_step1, image_to_3d_step2], ) graph.launch() ================================================ FILE: examples/09_slideshow_app.py ================================================ # Showcases parallel image generation, video transitions between scenes, and ffmpeg concatenation into a slideshow. import subprocess import tempfile import gradio as gr from PIL import Image from daggr import FnNode, GradioNode, Graph def resize_image(image_path: str, size: int = 256) -> str: """Resize and center-crop image to square dimensions (required by video API).""" img = Image.open(image_path) # Center crop to square w, h = img.size min_dim = min(w, h) left = (w - min_dim) // 2 top = (h - min_dim) // 2 img = img.crop((left, top, left + min_dim, top + min_dim)) # Resize to target size img = img.resize((size, size), Image.Resampling.LANCZOS) output_path = tempfile.mktemp(suffix=".png") img.save(output_path, "PNG") return output_path prompt1 = gr.Textbox( label="Scene 1", value="A serene mountain landscape at sunrise, golden light rays, photorealistic", lines=2, ) prompt2 = gr.Textbox( label="Scene 2", value="A dense forest with mist and sunbeams filtering through trees, photorealistic", lines=2, ) prompt3 = gr.Textbox( label="Scene 3", value="An ocean wave crashing on rocks at sunset, photorealistic", lines=2, ) prompt4 = gr.Textbox( label="Scene 4", value="A starry night sky with the milky way over a desert, photorealistic", lines=2, ) prompt5 = gr.Textbox( label="Scene 5", value="Northern lights dancing over a frozen lake, photorealistic", lines=2, ) transition_prompt = gr.Textbox( label="Transition Style", value="Smooth cinematic morph transition, natural movement", lines=1, ) image1 = GradioNode( space_or_url="Tongyi-MAI/Z-Image-Turbo", api_name="/generate", name="Image 1", inputs={ "prompt": prompt1, "resolution": "1280x720 ( 16:9 )", "steps": 8, "random_seed": True, }, postprocess=lambda images, seed_used, seed_number: images[0]["image"], outputs={ "image": gr.Image(label="Scene 1"), }, ) image2 = GradioNode( space_or_url="Tongyi-MAI/Z-Image-Turbo", api_name="/generate", name="Image 2", inputs={ "prompt": prompt2, "resolution": "1280x720 ( 16:9 )", "steps": 8, "random_seed": True, }, postprocess=lambda images, seed_used, seed_number: images[0]["image"], outputs={ "image": gr.Image(label="Scene 2"), }, ) image3 = GradioNode( space_or_url="Tongyi-MAI/Z-Image-Turbo", api_name="/generate", name="Image 3", inputs={ "prompt": prompt3, "resolution": "1280x720 ( 16:9 )", "steps": 8, "random_seed": True, }, postprocess=lambda images, seed_used, seed_number: images[0]["image"], outputs={ "image": gr.Image(label="Scene 3"), }, ) image4 = GradioNode( space_or_url="Tongyi-MAI/Z-Image-Turbo", api_name="/generate", name="Image 4", inputs={ "prompt": prompt4, "resolution": "1280x720 ( 16:9 )", "steps": 8, "random_seed": True, }, postprocess=lambda images, seed_used, seed_number: images[0]["image"], outputs={ "image": gr.Image(label="Scene 3"), }, ) image5 = GradioNode( space_or_url="Tongyi-MAI/Z-Image-Turbo", api_name="/generate", name="Image 5", inputs={ "prompt": prompt5, "resolution": "1280x720 ( 16:9 )", "steps": 8, "random_seed": True, }, postprocess=lambda images, seed_used, seed_number: images[0]["image"], outputs={ "image": gr.Image(label="Scene 3"), }, ) # Resize images for video transition (smaller images = faster/more reliable) resize1 = FnNode( resize_image, name="Resize 1", inputs={"image_path": image1.image}, outputs={"resized": gr.Image()}, ) resize2 = FnNode( resize_image, name="Resize 2", inputs={"image_path": image2.image}, outputs={"resized": gr.Image()}, ) resize3 = FnNode( resize_image, name="Resize 3", inputs={"image_path": image3.image}, outputs={"resized": gr.Image()}, ) resize4 = FnNode( resize_image, name="Resize 4", inputs={"image_path": image4.image}, outputs={"resized": gr.Image()}, ) resize5 = FnNode( resize_image, name="Resize 5", inputs={"image_path": image5.image}, outputs={"resized": gr.Image()}, ) transition_1_2 = GradioNode( space_or_url="multimodalart/wan-2-2-first-last-frame", api_name="/generate_video", name="Transition 1→2", inputs={ "start_image_pil": resize1.resized, "end_image_pil": resize2.resized, "prompt": transition_prompt, "negative_prompt": "blurry, distorted, low quality", "duration_seconds": 2.0, "steps": 8, "guidance_scale": 1.0, "randomize_seed": True, }, postprocess=lambda video, seed: video, outputs={ "video": gr.Video(label="Transition 1→2"), }, ) transition_2_3 = GradioNode( space_or_url="multimodalart/wan-2-2-first-last-frame", api_name="/generate_video", name="Transition 2→3", inputs={ "start_image_pil": resize2.resized, "end_image_pil": resize3.resized, "prompt": transition_prompt, "negative_prompt": "blurry, distorted, low quality", "duration_seconds": 2.0, "steps": 8, "guidance_scale": 1.0, "randomize_seed": True, }, postprocess=lambda video, seed: video, outputs={ "video": gr.Video(label="Transition 2→3"), }, ) transition_3_4 = GradioNode( space_or_url="multimodalart/wan-2-2-first-last-frame", api_name="/generate_video", name="Transition 3→4", inputs={ "start_image_pil": resize3.resized, "end_image_pil": resize4.resized, "prompt": transition_prompt, "negative_prompt": "blurry, distorted, low quality", "duration_seconds": 2.0, "steps": 8, "guidance_scale": 1.0, "randomize_seed": True, }, postprocess=lambda video, seed: video, outputs={ "video": gr.Video(label="Transition 3→4"), }, ) transition_4_5 = GradioNode( space_or_url="multimodalart/wan-2-2-first-last-frame", api_name="/generate_video", name="Transition 4→5", inputs={ "start_image_pil": resize4.resized, "end_image_pil": resize5.resized, "prompt": transition_prompt, "negative_prompt": "blurry, distorted, low quality", "duration_seconds": 2.0, "steps": 8, "guidance_scale": 1.0, "randomize_seed": True, }, postprocess=lambda video, seed: video, outputs={ "video": gr.Video(label="Transition 4→5"), }, ) def concat_videos(v1: str, v2: str, v3: str, v4: str) -> str: """Concatenate 4 transition videos into one slideshow.""" list_file = tempfile.mktemp(suffix=".txt") output_path = tempfile.mktemp(suffix=".mp4") with open(list_file, "w") as f: for v in [v1, v2, v3, v4]: f.write(f"file '{v}'\n") cmd = [ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_file, "-c", "copy", output_path, ] subprocess.run(cmd, check=True, capture_output=True) return output_path combine_videos = FnNode( concat_videos, name="Combine Slideshow", inputs={ "v1": transition_1_2.video, "v2": transition_2_3.video, "v3": transition_3_4.video, "v4": transition_4_5.video, }, outputs={ "final_video": gr.Video(label="Final Slideshow"), }, ) graph = Graph( name="AI Slideshow with Smooth Transitions", nodes=[ image1, image2, image3, image4, image5, resize1, resize2, resize3, resize4, resize5, transition_1_2, transition_2_3, transition_3_4, transition_4_5, combine_videos, ], ) graph.launch() ================================================ FILE: examples/10_real_podcast_app.py ================================================ # Showcases a full document-to-podcast pipeline: URL extraction → dialogue generation → TTS → audio combining. import gradio as gr from daggr import FnNode, Graph def extract_content(url: str, custom_text: str) -> tuple[str, str]: """ Extracts and cleans text content from a URL or uses custom text. Returns: (cleaned_text, title) """ import re import requests from bs4 import BeautifulSoup if custom_text.strip(): lines = custom_text.strip().split("\n") title = lines[0][:50] if lines else "Custom Content" return custom_text, title if not url.strip(): return "Please provide a URL or paste your content.", "No Content" try: headers = {"User-Agent": "Mozilla/5.0"} response = requests.get(url, headers=headers, timeout=10) soup = BeautifulSoup(response.text, "html.parser") title = soup.find("title") title = title.text.strip() if title else "Untitled" for element in soup(["script", "style", "nav", "footer", "header"]): element.decompose() article = soup.find("article") or soup.find("main") or soup.body if article: text = article.get_text(separator="\n") text = re.sub(r"\n\s*\n", "\n\n", text) text = re.sub(r" +", " ", text) text = text.strip() if len(text) > 10000: text = text[:10000] + "..." return text, title return "Could not extract content from URL.", title except Exception as e: return f"Error fetching URL: {str(e)}", "Error" content_extractor = FnNode( fn=extract_content, inputs={ "url": gr.Textbox( label="🔗 Article URL", placeholder="https://github.com/gradio-app/daggr/blob/main/README.md", value="", ), "custom_text": gr.Textbox( label="📝 Or paste your content directly", placeholder="Paste article text, research paper, or any content here...", lines=5, ), }, outputs={ "content": gr.Textbox(label="📄 Extracted Content", lines=10), "title": gr.Textbox(label="📰 Title"), }, ) def generate_dialogue( content: str, title: str, host_style: str, episode_length: str ) -> tuple[list, str]: """ Generates a natural conversation script between two podcast hosts. Returns: (dialogue_lines for scatter, html_preview) In production, this would use an LLM. Demo shows expected structure. """ exchanges = { "Short (2-3 min)": 6, "Medium (5-7 min)": 12, "Long (10+ min)": 20, }.get(episode_length, 8) dialogue = [] dialogue.append( { "speaker": "host", "text": f"Welcome back to the show! Today we're diving into something fascinating: {title}. I've been really excited to discuss this one.", "voice_style": host_style, } ) dialogue.append( { "speaker": "guest", "text": "Me too! When I first read through this, I was struck by how relevant it is. There's a lot to unpack here.", "voice_style": "friendly, curious", } ) dialogue.append( { "speaker": "host", "text": "So let's start with the main point. Can you give our listeners the key takeaway?", "voice_style": host_style, } ) dialogue.append( { "speaker": "guest", "text": "Absolutely. The core idea here is really about understanding the bigger picture. The author makes a compelling case that we need to think differently about this topic.", "voice_style": "thoughtful, explaining", } ) for i in range((exchanges - 4) // 2): dialogue.append( { "speaker": "host", "text": f"That's a great point. What really stood out to you in section {i + 1}?", "voice_style": host_style, } ) dialogue.append( { "speaker": "guest", "text": "Well, I think the author's argument about context is particularly strong. It challenges conventional thinking in a productive way.", "voice_style": "engaged, analytical", } ) dialogue.append( { "speaker": "host", "text": "This has been such a great conversation. Any final thoughts for our listeners?", "voice_style": host_style, } ) dialogue.append( { "speaker": "guest", "text": "I'd encourage everyone to check out the original piece. There's so much more depth there. Thanks for having me!", "voice_style": "warm, grateful", } ) dialogue.append( { "speaker": "host", "text": "Thanks for listening everyone! Don't forget to subscribe and we'll see you next time.", "voice_style": host_style, } ) html = f"""

🎙️ {title}

Episode Preview • {len(dialogue)} segments

""" for line in dialogue[:6]: speaker_color = "#2563eb" if line["speaker"] == "host" else "#059669" speaker_label = "🎤 Host" if line["speaker"] == "host" else "🗣️ Guest" html += f"""
{speaker_label}

{line["text"]}

""" if len(dialogue) > 6: html += f"

... and {len(dialogue) - 6} more segments

" html += "
" return dialogue, html dialogue_generator = FnNode( fn=generate_dialogue, inputs={ "content": content_extractor.content, "title": content_extractor.title, "host_style": gr.Dropdown( label="🎭 Host Personality", choices=[ "enthusiastic, energetic", "calm, professional", "casual, conversational", "intellectual, thoughtful", ], value="enthusiastic, energetic", ), "episode_length": gr.Radio( label="⏱️ Episode Length", choices=["Short (2-3 min)", "Medium (5-7 min)", "Long (10+ min)"], value="Medium (5-7 min)", ), }, outputs={ "dialogue": gr.JSON(label="📋 Dialogue Script", visible=False), "preview": gr.HTML(label="👀 Script Preview"), }, ) def generate_all_voice_segments(dialogue: list) -> list: """ Generate TTS audio for ALL dialogue lines in a single node. Bypasses daggr's scatter/gather which has a bug. """ from gradio_client import Client client = Client("ysharma/Qwen3-TTS") audio_files = [] for i, item in enumerate(dialogue): print(f"Generating audio for segment {i + 1}/{len(dialogue)}...") try: result = client.predict( text=item["text"], language="Auto", voice_description=item.get("voice_style", "friendly"), api_name="/generate_voice_design", ) audio_path = result[0] if isinstance(result, tuple) else result audio_files.append(audio_path) print(f" ✓ Generated: {audio_path}") except Exception as e: print(f" ✗ Error on segment {i + 1}: {e}") audio_files.append(None) return audio_files voice_generator = FnNode( fn=generate_all_voice_segments, inputs={ "dialogue": dialogue_generator.dialogue, }, outputs={ "audio_files": gr.JSON(label="🔊 Generated Audio Files", visible=False), }, ) def combine_podcast( audio_files: list, dialogue: list, title: str, ) -> tuple[str, str]: """ Combines all audio segments into a final podcast episode. """ import tempfile from pydub import AudioSegment combined = AudioSegment.silent(duration=500) successful_segments = 0 for i, audio_path in enumerate(audio_files): if audio_path: try: segment = AudioSegment.from_file(audio_path) combined += segment pause_duration = 300 if i < len(dialogue) - 1 else 0 combined += AudioSegment.silent(duration=pause_duration) successful_segments += 1 except Exception as e: print(f"Error loading segment {i}: {e}") continue combined += AudioSegment.silent(duration=1000) combined = combined.normalize() output_path = tempfile.mktemp(suffix=".mp3") combined.export(output_path, format="mp3", bitrate="192k") duration_mins = len(combined) / 60000 summary = f"""

🎙️ Podcast Ready!

{title}

{duration_mins:.1f}
minutes
{successful_segments}
segments
2
speakers
""" return output_path, summary final_podcast = FnNode( fn=combine_podcast, inputs={ "audio_files": voice_generator.audio_files, "dialogue": dialogue_generator.dialogue, "title": content_extractor.title, }, outputs={ "podcast": gr.Audio(label="🎙️ Final Podcast Episode"), "summary": gr.HTML(label="📊 Episode Summary"), }, ) graph = Graph( name="🎙️ Document to Podcast Generator", nodes=[ content_extractor, dialogue_generator, voice_generator, final_podcast, ], ) if __name__ == "__main__": graph.launch() ================================================ FILE: examples/11_viral_content_generator_app.py ================================================ # Showcases a social media content pipeline: idea expansion → parallel image/video generation → content packaging. import random import gradio as gr from daggr import FnNode, GradioNode, Graph def expand_content_idea( topic: str, platform: str, tone: str, include_cta: bool ) -> tuple[str, str, str, str, str]: """ Expands a simple topic into full content strategy. Returns: (image_prompt, alt_image_prompt, video_prompt, caption, hashtags) In production, use an LLM for more creative output. """ platform_styles = { "Instagram": { "aspect": "square, centered composition", "tone_prefix": "aesthetic, instagram-worthy", "video_style": "smooth transitions, satisfying", }, "TikTok": { "aspect": "vertical, dynamic framing", "tone_prefix": "eye-catching, bold", "video_style": "fast-paced, trendy", }, "Twitter/X": { "aspect": "horizontal, clean design", "tone_prefix": "attention-grabbing", "video_style": "informative, quick", }, "LinkedIn": { "aspect": "professional, clean", "tone_prefix": "business-appropriate, polished", "video_style": "professional, educational", }, } style = platform_styles.get(platform, platform_styles["Instagram"]) base_prompt = f"{style['tone_prefix']}, {topic}, {tone} mood, {style['aspect']}, high quality, trending" image_prompt = f"{base_prompt}, vibrant colors, professional photography style" alt_image_prompt = f"{base_prompt}, minimalist design, artistic interpretation" video_prompt = ( f"{topic}, {style['video_style']}, {tone} atmosphere, cinematic, 4k quality" ) tone_emojis = { "Professional": "📊", "Fun & Playful": "🎉", "Inspirational": "✨", "Educational": "💡", "Trending/Viral": "🔥", } emoji = tone_emojis.get(tone, "✨") hook = f"{emoji} {topic.capitalize()}" body = f"Here's something that changed my perspective on {topic}..." cta = "\n\n👇 Drop your thoughts below!" if include_cta else "" caption = f"{hook}\n\n{body}{cta}" topic_words = topic.lower().replace(",", "").split() base_hashtags = [f"#{word}" for word in topic_words[:3] if len(word) > 3] platform_hashtags = { "Instagram": ["#instagood", "#photooftheday", "#explore"], "TikTok": ["#fyp", "#viral", "#trending"], "Twitter/X": ["#tech", "#innovation"], "LinkedIn": ["#leadership", "#growth", "#business"], } all_hashtags = base_hashtags + platform_hashtags.get(platform, [])[:3] hashtags = " ".join(all_hashtags[:7]) return image_prompt, alt_image_prompt, video_prompt, caption, hashtags content_strategy = FnNode( fn=expand_content_idea, inputs={ "topic": gr.Textbox( label="💡 What's your content about?", placeholder="e.g., AI tools that save time, morning routine tips, startup lessons", value="the future of AI and creativity", lines=2, ), "platform": gr.Dropdown( label="📱 Primary Platform", choices=["Instagram", "TikTok", "Twitter/X", "LinkedIn"], value="Instagram", ), "tone": gr.Radio( label="🎭 Content Tone", choices=[ "Professional", "Fun & Playful", "Inspirational", "Educational", "Trending/Viral", ], value="Inspirational", ), "include_cta": gr.Checkbox(label="📣 Include Call-to-Action", value=True), }, outputs={ "image_prompt": gr.Textbox(label="🖼️ Primary Image Prompt"), "alt_image_prompt": gr.Textbox(label="🖼️ Alternative Image Prompt"), "video_prompt": gr.Textbox(label="🎬 Video Prompt"), "caption": gr.Textbox(label="📝 Caption", lines=4), "hashtags": gr.Textbox(label="#️⃣ Hashtags"), }, ) primary_image = GradioNode( space_or_url="hf-applications/Z-Image-Turbo", api_name="/generate_image", inputs={ "prompt": content_strategy.image_prompt, "seed": random.randint(0, 999999), "width": 1024, "height": 1024, }, outputs={ "image": gr.Image(label="🖼️ Primary Image"), }, ) alt_image = GradioNode( space_or_url="hf-applications/Z-Image-Turbo", api_name="/generate_image", inputs={ "prompt": content_strategy.alt_image_prompt, "seed": random.randint(0, 999999), "width": 1024, "height": 1024, }, outputs={ "image": gr.Image(label="🖼️ Alternative Image (A/B Test)"), }, ) content_video = GradioNode( space_or_url="Lightricks/ltx-2-distilled", api_name="/generate_video", inputs={ "input_image": primary_image.image, "prompt": content_strategy.video_prompt, "duration": 3, }, outputs={ "video": gr.Video(label="🎬 Animated Content"), "seed": None, }, ) def package_content( primary_img: str, alt_img: str, video: str, caption: str, hashtags: str, platform: str, ) -> tuple[str, str]: """ Packages all content and generates a preview/summary. """ import json package = { "platform": platform, "primary_image": primary_img, "alternative_image": alt_img, "video": video, "caption": caption, "hashtags": hashtags, "ready_to_post": all([primary_img, caption]), } preview_html = f"""
9:41 📶 100%
📱 {platform}
your_brand
Just now
{"" if primary_img else "📷 Image Preview"}
your_brand {caption[:150]}{"..." if len(caption) > 150 else ""}
{hashtags}
❤️ 0 💬 0 📤 Share

📦 Content Package Ready!

✅ Primary Image
✅ A/B Test Image
{"✅" if video else "⏳"} Video Content
✅ Caption & Hashtags
""" return json.dumps(package, indent=2), preview_html final_package = FnNode( fn=package_content, inputs={ "primary_img": primary_image.image, "alt_img": alt_image.image, "video": content_video.video, "caption": content_strategy.caption, "hashtags": content_strategy.hashtags, "platform": content_strategy.image_prompt, }, outputs={ "package_json": gr.Code(label="📦 Content Package (JSON)", language="json"), "preview": gr.HTML(label="📱 Post Preview"), }, ) graph = Graph( name="📱 Viral Content Generator", nodes=[ content_strategy, primary_image, alt_image, content_video, final_package, ], ) if __name__ == "__main__": graph.launch() ================================================ FILE: examples/12_ecommerce_product_generator_app.py ================================================ # Showcases e-commerce product content automation: image generation → parallel processing (background removal, enhancement, depth map, object detection) → description → multi-language audio. import gradio as gr from daggr import GradioNode, Graph def ensure_image_path(inputs, key="image"): """Convert image dict to filepath for APIs that expect paths.""" img = inputs.get(key) if isinstance(img, dict) and "path" in img: inputs[key] = img["path"] return inputs def ensure_image_dict(inputs, key="f"): """Convert image path to ImageData dict for APIs that expect dicts.""" img = inputs.get(key) if isinstance(img, str): inputs[key] = { "path": img, "url": None, "size": None, "orig_name": None, "mime_type": None, "is_stream": False, "meta": {}, } return inputs def postprocess_flux(result, seed): """Normalize FLUX output to consistent dict format.""" if isinstance(result, str): return { "path": result, "url": None, "size": None, "orig_name": None, "mime_type": None, "is_stream": False, "meta": {}, }, seed return result, seed # Node 1: Product Image Generation (FLUX.1-schnell) product_image_gen = GradioNode( "black-forest-labs/FLUX.1-schnell", api_name="/infer", inputs={ "prompt": gr.Textbox( label="Product Description", value="Professional product photo of sleek wireless Bluetooth headphones, matte black finish, floating on white background, studio lighting, 8k, commercial photography", lines=3, ), "seed": 0, "randomize_seed": True, "width": 1024, "height": 1024, "num_inference_steps": 4, }, postprocess=postprocess_flux, outputs={ "result": gr.Image(label="Generated Product Image"), "seed": gr.Number(visible=False), }, ) # Node 2: Background Removal for clean product shots bg_removal = GradioNode( "hf-applications/background-removal", api_name="/png", preprocess=lambda x: ensure_image_dict(x, "f"), inputs={"f": product_image_gen.result}, outputs={"output_png_file": gr.File(label="Transparent PNG")}, ) # Node 3: Image Enhancement/Upscaling for high-res product images image_enhance = GradioNode( "finegrain/finegrain-image-enhancer", api_name="/process", preprocess=lambda x: ensure_image_dict(x, "input_image"), inputs={ "input_image": product_image_gen.result, "prompt": "high quality product photo, sharp details, professional lighting", "negative_prompt": "blurry, low quality, noise, artifacts", "seed": 42, "upscale_factor": 2, "controlnet_scale": 0.6, "controlnet_decay": 1.0, "condition_scale": 6, "tile_width": 112, "tile_height": 144, "denoise_strength": 0.35, "num_inference_steps": 18, "solver": "DDIM", }, outputs={"before__after": gr.Image(label="Enhanced Image")}, ) # Node 4: Depth Map Generation for AR/3D product visualization depth_map = GradioNode( "depth-anything/Depth-Anything-V2", api_name="/on_submit", preprocess=lambda x: ensure_image_path(x, "image"), inputs={"image": product_image_gen.result}, outputs={ "depth_map_with_slider_view": gr.Image(label="Depth Map"), "grayscale_depth_map": gr.File(label="Grayscale Depth"), "16bit_raw_output_can_be_considered_as_disparity": gr.File(visible=False), }, ) # Node 5: Object Detection (Florence-2) object_detection = GradioNode( "gokaygokay/Florence-2", api_name="/process_image", preprocess=lambda x: ensure_image_path(x, "image"), inputs={ "image": product_image_gen.result, "task_prompt": "Object Detection", "text_input": None, "model_id": "microsoft/Florence-2-large", }, outputs={ "output_text": gr.Textbox(label="Detected Objects"), "output_image": gr.Image(label="Detection Visualization"), }, ) # Node 6: AI Product Description (Moondream2) product_description = GradioNode( "vikhyatk/moondream2", api_name="/answer_question", preprocess=lambda x: ensure_image_path(x, "img"), inputs={ "img": product_image_gen.result, "prompt": "You are a professional e-commerce copywriter. Describe this product in detail for an online store listing. Include key features, design elements, and potential use cases. Be specific and persuasive.", }, outputs={"response": gr.Textbox(label="Product Description", lines=5)}, ) # Node 7: Short Marketing Caption (Florence-2) marketing_caption = GradioNode( "gokaygokay/Florence-2", api_name="/process_image", preprocess=lambda x: ensure_image_path(x, "image"), inputs={ "image": product_image_gen.result, "task_prompt": "Caption", "text_input": None, "model_id": "microsoft/Florence-2-large", }, outputs={ "output_text": gr.Textbox(label="Marketing Caption"), "output_image": gr.Image(visible=False), }, ) # Node 8: US English Audio (Edge-TTS) audio_us_english = GradioNode( "innoai/Edge-TTS-Text-to-Speech", api_name="/tts_interface", inputs={ "text": product_description.response, "voice": "en-US-AriaNeural - en-US (Female)", "rate": 0, "pitch": 0, }, outputs={ "generated_audio": gr.Audio(label="US English Audio"), "warning": gr.Markdown(visible=False), }, ) # Node 9: UK English Audio (Edge-TTS) audio_uk_english = GradioNode( "innoai/Edge-TTS-Text-to-Speech", api_name="/tts_interface", inputs={ "text": product_description.response, "voice": "en-GB-SoniaNeural - en-GB (Female)", "rate": 0, "pitch": 0, }, outputs={ "generated_audio": gr.Audio(label="UK English Audio"), "warning": gr.Markdown(visible=False), }, ) # Node 10: Spanish Audio for international markets (Edge-TTS) audio_spanish = GradioNode( "innoai/Edge-TTS-Text-to-Speech", api_name="/tts_interface", inputs={ "text": product_description.response, "voice": "es-ES-ElviraNeural - es-ES (Female)", "rate": 0, "pitch": 0, }, outputs={ "generated_audio": gr.Audio(label="Spanish Audio"), "warning": gr.Markdown(visible=False), }, ) graph = Graph( name="E-Commerce Product Content Generator", nodes=[ product_image_gen, bg_removal, image_enhance, depth_map, object_detection, product_description, marketing_caption, audio_us_english, audio_uk_english, audio_spanish, ], ) if __name__ == "__main__": graph.launch() ================================================ FILE: examples/13_accessible_image_description_app.py ================================================ # Showcases accessible content creation: generate an image, describe it with a vision model, then convert to speech for visually impaired users. import gradio as gr from daggr import GradioNode, Graph def postprocess_flux(result, seed): """Normalize FLUX output to consistent dict format.""" if isinstance(result, str): return { "path": result, "url": None, "size": None, "orig_name": None, "mime_type": None, "is_stream": False, "meta": {}, }, seed return result, seed def preprocess_moondream(inputs): """Convert image dict to filepath for Moondream API.""" img = inputs.get("img") if isinstance(img, dict) and "path" in img: inputs["img"] = img["path"] return inputs # Node 1: Image Generation (FLUX.1-schnell) image_generator = GradioNode( "black-forest-labs/FLUX.1-schnell", api_name="/infer", inputs={ "prompt": gr.Textbox( label="Image Prompt", value="A serene Japanese garden with a koi pond and cherry blossoms", lines=2, ), "seed": 0, "randomize_seed": True, "width": 1024, "height": 1024, "num_inference_steps": 4, }, postprocess=postprocess_flux, outputs={ "result": gr.Image(label="Generated Image"), "seed": gr.Number(visible=False), }, ) # Node 2: Image Description (Moondream2 vision-language model) image_describer = GradioNode( "vikhyatk/moondream2", api_name="/answer_question", preprocess=preprocess_moondream, inputs={ "img": image_generator.result, "prompt": "Describe this image in detail, including colors, mood, and composition.", }, outputs={ "response": gr.Textbox(label="Image Description", lines=5), }, ) # Node 3: Text-to-Speech (Edge-TTS) for audio description description_tts = GradioNode( "innoai/Edge-TTS-Text-to-Speech", api_name="/tts_interface", inputs={ "text": image_describer.response, "voice": gr.Dropdown( label="Voice", choices=[ "en-US-AriaNeural - en-US (Female)", "en-US-GuyNeural - en-US (Male)", "en-GB-SoniaNeural - en-GB (Female)", "en-GB-RyanNeural - en-GB (Male)", ], value="en-US-AriaNeural - en-US (Female)", ), "rate": 0, "pitch": 0, }, outputs={ "generated_audio": gr.Audio(label="Audio Description"), "warning": gr.Markdown(visible=False), }, ) graph = Graph( name="Accessible Image Description", nodes=[image_generator, image_describer, description_tts], ) if __name__ == "__main__": graph.launch() ================================================ FILE: examples/14_food_nutrition_analyzer_app.py ================================================ import random import gradio as gr from daggr import FnNode, GradioNode, Graph def ensure_image_path(inputs, key="image"): img = inputs.get(key) if isinstance(img, dict) and "path" in img: inputs[key] = img["path"] return inputs def create_nutrition_report(food_items: str, nutrition_analysis: str) -> str: import datetime report = f""" # 🍽️ FOOD NUTRITION ANALYSIS REPORT **Analyzed:** {datetime.datetime.now().strftime("%B %d, %Y at %I:%M %p")} --- ## 🥗 IDENTIFIED FOODS & DESCRIPTION {food_items} --- ## 📊 NUTRITIONAL ANALYSIS {nutrition_analysis} --- ## 🏷️ DIETARY CLASSIFICATIONS Based on the detected foods, this meal may be: - ✅ **Vegetarian**: Check analysis above - ✅ **Vegan**: Check analysis above - ✅ **Gluten-Free**: Check analysis above - ✅ **Dairy-Free**: Check analysis above - ✅ **Keto-Friendly**: Check analysis above - ✅ **Low-Carb**: Check analysis above - ✅ **High-Protein**: Check analysis above --- ## 📈 HEALTH INSIGHTS ### Nutritional Highlights: - **Estimated Caloric Range**: See detailed analysis above - **Macronutrient Balance**: Carbs/Protein/Fat ratio - **Micronutrients**: Vitamins and minerals present - **Fiber Content**: Digestive health benefits ### Dietary Recommendations: - 💧 Remember to stay hydrated (8 glasses of water daily) - 🥗 Balance your plate (50% vegetables, 25% protein, 25% carbs) - ⏰ Consider portion sizes for your dietary goals - 🏃 Pair with regular physical activity --- ## ⚠️ ALLERGEN WARNINGS Common allergens to check for: - [ ] Nuts and tree nuts - [ ] Dairy products - [ ] Gluten/wheat - [ ] Shellfish/seafood - [ ] Eggs - [ ] Soy products *Note: Always verify ingredients if you have food allergies* --- ## 🎯 MEAL TIMING SUGGESTIONS **Best consumed:** - 🌅 Breakfast: High-protein, complex carbs - 🌞 Lunch: Balanced, moderate portions - 🌙 Dinner: Lighter, easily digestible - 🏋️ Post-Workout: Protein-rich recovery meal --- ## 📱 TRACK YOUR NUTRITION Consider logging this meal in a nutrition tracking app: - MyFitnessPal - Cronometer - Lose It! - Nutritionix **Tip:** Take photos of your meals to maintain a visual food diary! """ return report def extract_calorie_summary(nutrition_analysis: str) -> str: summary = """🔢 **QUICK CALORIE & MACRO SUMMARY** Based on the nutritional analysis: **Total Estimated Calories:** Check detailed analysis **Protein:** Estimate per serving **Carbohydrates:** Estimate per serving **Fats:** Estimate per serving **Fiber:** Estimate per serving 💡 *These are estimates. Actual values depend on portion sizes, cooking methods, and specific ingredients.* 📊 **Calorie Distribution:** - Protein: ~30-35% - Carbs: ~40-45% - Fats: ~20-30% 🎯 **Portion Control Tips:** - Use smaller plates - Measure protein portions (palm-sized) - Fill half your plate with vegetables - Drink water before meals """ return summary generated_food = GradioNode( "hf-applications/Z-Image-Turbo", api_name="/generate_image", inputs={ "prompt": gr.Textbox( label="🎨 Generate Food Image (Optional)", value="Professional food photography of a healthy salmon bowl with quinoa, avocado, cherry tomatoes, and mixed greens, overhead shot, natural lighting, 4k", lines=3, ), "height": 1024, "width": 1024, "seed": random.random, }, outputs={ "image": gr.Image(label="Generated Food Image"), }, ) uploaded_food = gr.Image( label="📸 OR Upload Your Food Photo", type="filepath", value=None, ) food_detection = GradioNode( "gokaygokay/Florence-2", api_name="/process_image", preprocess=lambda x: ensure_image_path(x, "image"), inputs={ "image": generated_food.image, "task_prompt": "Dense Region Caption", "text_input": None, "model_id": "microsoft/Florence-2-large", }, outputs={ "output_text": gr.Textbox(label="🍕 Detected Food Items & Description"), "output_image": gr.Image(label="🔍 Food Analysis View"), }, ) nutrition_analysis = GradioNode( "vikhyatk/moondream2", api_name="/answer_question", preprocess=lambda x: ensure_image_path(x, "img"), inputs={ "img": generated_food.image, "prompt": """You are a certified nutritionist. Analyze this food image: ## FOOD IDENTIFICATION List all visible food items and ingredients ## NUTRITIONAL BREAKDOWN • Estimated calories (per serving and total) • Protein (grams) • Carbohydrates (grams) • Fats (grams) • Key vitamins and minerals ## DIETARY CLASSIFICATION • Vegan/Vegetarian/Omnivore • Gluten-free/Keto/Low-carb status • Allergen warnings ## HEALTH ASSESSMENT • Nutritional rating (1-10) • Portion size evaluation • Best meal timing • Improvement suggestions Provide specific quantities and actionable insights.""", }, outputs={"response": gr.Textbox(label="🥗 Detailed Nutrition Analysis", lines=15)}, ) nutrition_report = FnNode( fn=create_nutrition_report, inputs={ "food_items": food_detection.output_text, "nutrition_analysis": nutrition_analysis.response, }, outputs={ "report": gr.Markdown(label="📄 Complete Nutrition Report"), }, ) calorie_summary = FnNode( fn=extract_calorie_summary, inputs={ "nutrition_analysis": nutrition_analysis.response, }, outputs={ "summary": gr.Textbox(label="⚡ Quick Calorie Info", lines=8), }, ) audio_summary = GradioNode( "innoai/Edge-TTS-Text-to-Speech", api_name="/tts_interface", inputs={ "text": calorie_summary.summary, "voice": "en-US-JennyNeural - en-US (Female)", "rate": 0, "pitch": 0, }, outputs={ "generated_audio": gr.Audio(label="🔊 Audio Nutrition Summary"), "warning": gr.Markdown(visible=False), }, ) graph = Graph( name="🍽️ Food Nutrition & Calorie Analyzer", nodes=[ generated_food, food_detection, nutrition_analysis, nutrition_report, calorie_summary, audio_summary, ], ) if __name__ == "__main__": graph.launch() ================================================ FILE: examples/15_background_removal_with_input_node.py ================================================ # Showcases basic GradioNode chaining: generate an image then remove its background. import random import gradio as gr from daggr import GradioNode, Graph, InputNode parameters = InputNode( "Parameters-Test", ports={ "prompt": gr.Textbox( # An input node is created for the prompt label="Prompt", value="A cheetah in the grassy savanna.", lines=3, ), "height": gr.Slider( label="Height", value=1024, minimum=1024, maximum=4096, step=128 ), "width": gr.Slider( label="Width", value=1024, minimum=1024, maximum=4096, step=128 ), }, ) glm_image = GradioNode( "hf-applications/Z-Image-Turbo", api_name="/generate_image", inputs={ "prompt": parameters.prompt, "height": parameters.height, "width": parameters.width, "seed": random.random, }, outputs={ "image": gr.Image(label="Image"), # Display original image }, ) background_remover = GradioNode( "hf-applications/background-removal", api_name="/image", inputs={ "image": glm_image.image, }, postprocess=lambda _, final: final, outputs={ "image": gr.Image(label="Final Image"), # Display only final image }, ) graph = Graph( name="Transparent Background Image Generator", nodes=[glm_image, background_remover] ) graph.launch() ================================================ FILE: package.json ================================================ { "name": "daggr", "version": "0.1.0", "description": "", "private": true, "scripts": { "ci:version": "pnpm changeset version && node ./.changeset/fix_changelogs.cjs", "ci:tag": "pnpm changeset tag && git push origin --tags" }, "dependencies": { "@changesets/changelog-github": "^0.5.0", "@changesets/cli": "^2.27.1", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/get-github-info": "^0.6.0", "@manypkg/get-packages": "^2.2.1" } } ================================================ FILE: pnpm-workspace.yaml ================================================ packages: - "daggr" ================================================ FILE: pyproject.toml ================================================ [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "daggr" description = "A Python package" authors = [ { name = "Abubakar Abid", email = "abubakar@example.com" }, ] readme = "README.md" requires-python = ">=3.10" dependencies = [ "fastapi>=0.115.0", "gradio>=6.0.0", "networkx>=3.0", "uvicorn[standard]>=0.34.0", ] classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] dynamic = ["version"] [project.urls] homepage = "https://github.com/abidlabs/daggr" repository = "https://github.com/abidlabs/daggr" [project.optional-dependencies] dev = [ "ruff==0.9.3", "pytest>=8.0.0,<9.0.0", "pytest-xdist>=3.0.0", "playwright>=1.40.0", ] [project.scripts] daggr = "daggr.cli:main" [tool.hatch.build.targets.wheel] packages = ["daggr"] artifacts = [ "daggr/frontend/dist/", "daggr/assets/", ] exclude = [ "daggr/canvas-component/", "daggr/frontend/node_modules/", "daggr/frontend/src/", "daggr/frontend/index.html", "daggr/frontend/package.json", "daggr/frontend/package-lock.json", "daggr/frontend/vite.config.ts", "daggr/frontend/svelte.config.js", "daggr/frontend/tsconfig.json", ] [tool.hatch.build.targets.sdist] include = [ "daggr/**/*.py", "daggr/**/*.pyi", "daggr/py.typed", "daggr/package.json", "daggr/assets/*", "daggr/frontend/dist/**/*", "README.md", "LICENSE", ] exclude = [ "daggr/canvas-component/", ] [tool.hatch.version] path = "daggr/package.json" pattern = ".*\"version\":\\s*\"(?P[^\"]+)\"" [tool.pytest.ini_options] addopts = "-n auto" [tool.ruff] # Exclude a variety of commonly ignored directories. exclude = [ ".git", ".mypy_cache", ".ruff_cache", ".venv", "__pycache__", "build", "dist", ] # Same as Black. line-length = 88 indent-width = 4 target-version = "py310" [tool.ruff.lint] # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) select = ["E", "F", "I"] # Ignore line length violations ignore = ["E501"] # Allow autofix for all enabled rules (when `--fix`) is provided. fixable = ["ALL"] # Allow unused variables when underscore-prefixed. dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" [tool.ruff.format] # Use single quotes for strings. quote-style = "double" # Indent with spaces, rather than tabs. indent-style = "space" # Like Black, respect magic trailing commas. skip-magic-trailing-comma = false # Like Black, automatically detect the appropriate line ending. line-ending = "auto" ================================================ FILE: tests/README.md ================================================ # Tests This directory contains Python unit tests which can be run by running `pytest` in the root directory. Add your test files here following the naming convention `test_*.py`. ================================================ FILE: tests/conftest.py ================================================ ================================================ FILE: tests/test_api.py ================================================ import gradio as gr from fastapi.testclient import TestClient from daggr import FnNode, Graph from daggr.server import DaggrServer class TestWorkflowAPI: def test_simple_two_node_workflow_api(self): def double(x): return x * 2 def add_ten(y): return y + 10 node_a = FnNode( double, name="doubler", inputs={"x": gr.Number(label="Input Number", value=5)}, outputs={"result": gr.Number(label="Doubled")}, ) node_b = FnNode( add_ten, name="adder", inputs={"y": node_a.result}, outputs={"result": gr.Number(label="Final Result")}, ) graph = Graph("test_simple", nodes=[node_b], persist_key=False) server = DaggrServer(graph) client = TestClient(server.app) schema_response = client.get("/api/schema") assert schema_response.status_code == 200 schema = schema_response.json() assert len(schema["subgraphs"]) == 1 assert schema["subgraphs"][0]["id"] == "main" assert len(schema["subgraphs"][0]["inputs"]) == 1 assert schema["subgraphs"][0]["inputs"][0]["node"] == "doubler" assert schema["subgraphs"][0]["inputs"][0]["port"] == "x" call_response = client.post( "/api/call", json={"inputs": {"doubler__x": 7}}, ) assert call_response.status_code == 200 outputs = call_response.json()["outputs"] assert "adder" in outputs assert outputs["adder"]["result"] == 24 # (7 * 2) + 10 = 24 def test_multi_node_chain_workflow_api(self): def step1(a, b): return a + b def step2(x): return x * 3 def step3(val): return val - 5 node_a = FnNode( step1, name="adder", inputs={ "a": gr.Number(label="First Number", value=1), "b": gr.Number(label="Second Number", value=2), }, outputs={"result": gr.Number(label="Sum")}, ) node_b = FnNode( step2, name="multiplier", inputs={"x": node_a.result}, outputs={"result": gr.Number(label="Tripled")}, ) node_c = FnNode( step3, name="subtractor", inputs={"val": node_b.result}, outputs={"result": gr.Number(label="Final")}, ) graph = Graph("test_chain", nodes=[node_c], persist_key=False) server = DaggrServer(graph) client = TestClient(server.app) schema_response = client.get("/api/schema") assert schema_response.status_code == 200 schema = schema_response.json() assert len(schema["subgraphs"][0]["inputs"]) == 2 input_ids = {inp["id"] for inp in schema["subgraphs"][0]["inputs"]} assert "adder__a" in input_ids assert "adder__b" in input_ids assert len(schema["subgraphs"][0]["outputs"]) == 1 assert schema["subgraphs"][0]["outputs"][0]["node"] == "subtractor" call_response = client.post( "/api/call", json={"inputs": {"adder__a": 10, "adder__b": 5}}, ) assert call_response.status_code == 200 outputs = call_response.json()["outputs"] assert "subtractor" in outputs assert outputs["subtractor"]["result"] == 40 # ((10 + 5) * 3) - 5 = 40 ================================================ FILE: tests/test_basic.py ================================================ import pytest import daggr from daggr import FnNode, Graph def test_basic(): assert daggr.__version__ def test_edge_api_with_typed_ports(): def step_a(text: str) -> dict: return {"output": text.upper()} def step_b(data: str) -> dict: return {"output": data + "!"} node_a = FnNode(fn=step_a) node_b = FnNode(fn=step_b) assert "text" in dir(node_a) assert "output" in dir(node_a) assert node_a._name == "step_a" assert node_a.text.name == "text" graph = Graph(name="test-edge-api") graph.edge(node_a.output, node_b.data) assert len(graph._edges) == 1 assert graph.get_connections() == [("step_a", "output", "step_b", "data")] def test_port_validation(): def process(text: str) -> dict: return {"output": text} def consume(data: str) -> dict: return {"output": data} node1 = FnNode(fn=process) node2 = FnNode(fn=consume) graph = Graph(name="test-port-validation") graph.edge(node1.nonexistent_port, node2.data) graph.edge(node1.output, node2.missing_input) with pytest.raises(ValueError) as exc_info: graph._validate_edges() error_msg = str(exc_info.value) assert "nonexistent_port" in error_msg assert "missing_input" in error_msg assert "Available outputs: output" in error_msg assert "Available inputs: data" in error_msg ================================================ FILE: tests/test_cache.py ================================================ """Tests for cache directory resolution.""" import importlib import os import tempfile from pathlib import Path from unittest import mock import huggingface_hub.constants from daggr.local_space import _get_spaces_cache_dir from daggr.state import get_daggr_cache_dir def test_cache_directories_respect_hf_home_env_var(): """Test that daggr and spaces cache directories respect HF_HOME env var.""" with tempfile.TemporaryDirectory() as custom_hf_home: with mock.patch.dict(os.environ, {"HF_HOME": custom_hf_home}): importlib.reload(huggingface_hub.constants) daggr_cache = get_daggr_cache_dir() spaces_cache = _get_spaces_cache_dir() hf_home_path = Path(custom_hf_home) assert daggr_cache.is_relative_to(hf_home_path) assert spaces_cache.is_relative_to(hf_home_path) ================================================ FILE: tests/test_executor.py ================================================ from daggr import FnNode, Graph from daggr.executor import SequentialExecutor class TestSequentialExecutor: def test_execute_single_fn_node(self): def double(x): return x * 2 node = FnNode(double, inputs={"x": 5}) graph = Graph("test", nodes=[node]) executor = SequentialExecutor(graph) result = executor.execute_node("double", {}) assert result["output"] == 10 def test_execute_chain(self): def step1(x): return x + 1 def step2(y): return y * 2 n1 = FnNode(step1, inputs={"x": 10}) n2 = FnNode(step2, inputs={"y": n1.output}) graph = Graph("test", nodes=[n2]) executor = SequentialExecutor(graph) executor.execute_node("step1", {}) result = executor.execute_node("step2", {}) assert result["output"] == 22 def test_execute_all(self): def add_one(x): return x + 1 def double(x): return x * 2 n1 = FnNode(add_one, name="add_one", inputs={"x": 3}) n2 = FnNode(double, name="double", inputs={"x": n1.output}) graph = Graph("test", nodes=[n2]) executor = SequentialExecutor(graph) results = executor.execute_all({}) assert results["add_one"]["output"] == 4 assert results["double"]["output"] == 8 def test_fn_result_mapping_tuple(self): def multi_output(x): return (x, x * 2) node = FnNode( multi_output, inputs={"x": 5}, outputs={"first": None, "second": None} ) graph = Graph("test", nodes=[node]) executor = SequentialExecutor(graph) result = executor.execute_node("multi_output", {}) assert result["first"] == 5 assert result["second"] == 10 def test_user_input_override(self): def process(text): return text.upper() node = FnNode(process, inputs={"text": "default"}) graph = Graph("test", nodes=[node]) executor = SequentialExecutor(graph) result = executor.execute_node("process", {"text": "hello"}) assert result["output"] == "HELLO" def test_callable_fixed_input(self): counter = {"value": 0} def get_next(): counter["value"] += 1 return counter["value"] def process(x): return x node = FnNode(process, inputs={"x": get_next}) graph = Graph("test", nodes=[node]) executor = SequentialExecutor(graph) result1 = executor.execute_node("process", {}) result2 = executor.execute_node("process", {}) assert result1["output"] == 1 assert result2["output"] == 2 ================================================ FILE: tests/test_nodes.py ================================================ import pytest from daggr import FnNode, Graph, InteractionNode from daggr.port import ItemList, Port, ScatteredPort class TestComponentTypeWarning: def test_warns_when_type_explicitly_set(self): import gradio as gr with pytest.warns(UserWarning, match="daggr ignores the `type` parameter"): FnNode( lambda image: image, inputs={"image": gr.Image(type="numpy")}, outputs={"output": None}, ) def test_no_warning_when_type_not_set(self): import warnings import gradio as gr with warnings.catch_warnings(): warnings.simplefilter("error") FnNode( lambda image: image, inputs={"image": gr.Image(label="Input")}, outputs={"output": None}, ) class TestFnNode: def test_creates_from_function(self): def my_func(x: str, y: int) -> dict: return {"result": f"{x}-{y}"} node = FnNode(my_func) assert node._name == "my_func" assert "x" in node._input_ports assert "y" in node._input_ports assert "output" in node._output_ports def test_custom_name(self): def process(data): return {"out": data} node = FnNode(process, name="CustomProcessor") assert node._name == "CustomProcessor" def test_explicit_inputs(self): def process(a, b, c): return {"out": a + b + c} node = FnNode(process, inputs={"a": "fixed_value", "b": None}) assert "a" in node._input_ports assert "b" in node._input_ports assert node._fixed_inputs["a"] == "fixed_value" def test_invalid_input_raises_error(self): def process(text): return {"out": text} with pytest.raises(ValueError) as exc: FnNode(process, inputs={"wrong_name": "value"}) assert "wrong_name" in str(exc.value) def test_item_list_output(self): def generate_items(prompt): return {"items": [{"text": "a"}, {"text": "b"}]} node = FnNode(generate_items, outputs={"items": ItemList(text=None)}) assert "items" in node._output_ports assert "items" in node._item_list_schemas assert "text" in node._item_list_schemas["items"] class TestInteractionNode: def test_default_ports(self): node = InteractionNode() assert "input" in node._input_ports assert "output" in node._output_ports def test_custom_interaction_type(self): node = InteractionNode(interaction_type="approve") assert node._interaction_type == "approve" class TestChoiceNodeName: def test_choice_node_uses_custom_name_in_graph(self): def step_a(x): return {"output": x} def step_b(x): return {"output": x} a = FnNode(step_a, name="variant_a") b = FnNode(step_b, name="variant_b") choice = a | b choice.name = "Music generator" graph = Graph("test", nodes=[choice]) assert "Music generator" in graph.nodes assert graph.nodes["Music generator"] is choice class TestPort: def test_port_access(self): def process(x): return {"y": x} node = FnNode(process) port = node.x assert isinstance(port, Port) assert port.name == "x" assert port.node is node def test_scattered_port(self): def process(x): return {"items": [1, 2, 3]} node = FnNode(process, outputs={"items": None}) scattered = node.items.each assert isinstance(scattered, ScatteredPort) assert scattered.name == "items" def test_scattered_port_with_key(self): def process(x): return {"items": [{"a": 1}, {"a": 2}]} node = FnNode(process, outputs={"items": ItemList(a=None)}) scattered = node.items.a assert isinstance(scattered, ScatteredPort) assert scattered.item_key == "a" class TestGraphConstruction: def test_requires_name(self): with pytest.raises(ValueError): Graph(name="") with pytest.raises(ValueError): Graph(name=None) def test_persist_key_derived_from_name(self): graph = Graph(name="My Cool App!") assert graph.persist_key == "my_cool_app" def test_persist_key_disabled(self): graph = Graph(name="Test", persist_key=False) assert graph.persist_key is None def test_persist_key_custom(self): graph = Graph(name="Display Name", persist_key="custom_key") assert graph.persist_key == "custom_key" def test_add_nodes_from_init(self): def step_a(x): return {"output": x} def step_b(y): return {"output": y} n1 = FnNode(step_a) n2 = FnNode(step_b, inputs={"y": n1.output}) graph = Graph("test", nodes=[n2]) assert "step_a" in graph.nodes assert "step_b" in graph.nodes def test_cycle_detection(self): def step_a(x): return {"out": x} def step_b(y): return {"out": y} n1 = FnNode(step_a) n2 = FnNode(step_b) graph = Graph("test", nodes=[n1, n2]) graph.edge(n1.out, n2.y) with pytest.raises(ValueError, match="cycle"): graph.edge(n2.out, n1.x) def test_execution_order(self): def a(x): return {"output": x} def b(y): return {"output": y} def c(z): return {"output": z} n1 = FnNode(a, name="first") n2 = FnNode(b, name="second") n3 = FnNode(c, name="third") graph = Graph("test", nodes=[n1, n2, n3]) graph.edge(n1.output, n2.y) graph.edge(n2.output, n3.z) order = graph.get_execution_order() assert order.index("first") < order.index("second") assert order.index("second") < order.index("third") def test_get_connections(self): def a(x): return {"output": x} def b(y): return {"output": y} n1 = FnNode(a) n2 = FnNode(b, inputs={"y": n1.output}) graph = Graph("test", nodes=[n2]) connections = graph.get_connections() assert len(connections) == 1 assert connections[0] == ("a", "output", "b", "y") ================================================ FILE: tests/test_persistence.py ================================================ import os import tempfile import pytest from daggr.state import SessionState @pytest.fixture def state(): with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: db_path = f.name s = SessionState(db_path=db_path) yield s os.unlink(db_path) def test_create_sheet(state): sheet_id = state.create_sheet("user1", "TestGraph", "My Sheet") sheet = state.get_sheet(sheet_id) assert sheet is not None assert sheet["sheet_id"] == sheet_id assert sheet["user_id"] == "user1" assert sheet["graph_name"] == "TestGraph" assert sheet["name"] == "My Sheet" def test_list_sheets_by_user(state): state.create_sheet("user1", "Graph1", "Sheet A") state.create_sheet("user1", "Graph1", "Sheet B") state.create_sheet("user2", "Graph1", "Sheet C") user1_sheets = state.list_sheets("user1", "Graph1") user2_sheets = state.list_sheets("user2", "Graph1") assert len(user1_sheets) == 2 assert len(user2_sheets) == 1 assert all(s["name"] in ["Sheet A", "Sheet B"] for s in user1_sheets) assert user2_sheets[0]["name"] == "Sheet C" def test_rename_sheet(state): sheet_id = state.create_sheet("user1", "Graph1", "Original Name") success = state.rename_sheet(sheet_id, "New Name") assert success is True sheet = state.get_sheet(sheet_id) assert sheet["name"] == "New Name" def test_save_and_load_inputs(state): sheet_id = state.create_sheet("user1", "Graph1") state.save_input(sheet_id, "node1", "port_a", "hello") state.save_input(sheet_id, "node1", "port_b", 123) state.save_input(sheet_id, "node2", "input", {"key": "value"}) inputs = state.get_inputs(sheet_id) assert inputs["node1"]["port_a"] == "hello" assert inputs["node1"]["port_b"] == 123 assert inputs["node2"]["input"] == {"key": "value"} def test_save_and_load_results(state): sheet_id = state.create_sheet("user1", "Graph1") state.save_result(sheet_id, "node1", {"output": "result1"}) state.save_result(sheet_id, "node1", {"output": "result2"}) latest = state.get_latest_result(sheet_id, "node1") assert latest == {"output": "result2"} first = state.get_result_by_index(sheet_id, "node1", 0) assert first == {"output": "result1"} all_results = state.get_all_results(sheet_id) assert len(all_results["node1"]) == 2 def test_user_isolation(state): sheet_a = state.create_sheet("alice", "Graph1", "Alice's Sheet") sheet_b = state.create_sheet("bob", "Graph1", "Bob's Sheet") state.save_input(sheet_a, "input_node", "value", "alice_data") state.save_input(sheet_b, "input_node", "value", "bob_data") alice_inputs = state.get_inputs(sheet_a) bob_inputs = state.get_inputs(sheet_b) assert alice_inputs["input_node"]["value"] == "alice_data" assert bob_inputs["input_node"]["value"] == "bob_data" alice_sheets = state.list_sheets("alice", "Graph1") bob_sheets = state.list_sheets("bob", "Graph1") assert len(alice_sheets) == 1 assert len(bob_sheets) == 1 assert alice_sheets[0]["sheet_id"] != bob_sheets[0]["sheet_id"] def test_local_user_fallback(state, monkeypatch): monkeypatch.delenv("SPACE_ID", raising=False) user_id = state.get_effective_user_id(None) assert user_id == "local" user_id = state.get_effective_user_id({"username": "myuser"}) assert user_id == "myuser" def test_spaces_requires_login(state, monkeypatch): monkeypatch.setenv("SPACE_ID", "some-space-id") user_id = state.get_effective_user_id(None) assert user_id is None user_id = state.get_effective_user_id({"username": "hf_user"}) assert user_id == "hf_user" def test_delete_sheet(state): sheet_id = state.create_sheet("user1", "Graph1", "To Delete") state.save_input(sheet_id, "node1", "port", "data") state.save_result(sheet_id, "node1", {"output": "result"}) deleted = state.delete_sheet(sheet_id) assert deleted is True assert state.get_sheet(sheet_id) is None assert state.get_inputs(sheet_id) == {} assert state.get_all_results(sheet_id) == {} ================================================ FILE: tests/test_server.py ================================================ from pathlib import Path from unittest.mock import patch import pytest from daggr import Graph from daggr.server import DaggrServer @pytest.fixture def server(): graph = Graph(name="test") return DaggrServer(graph) def test_file_to_url_converts_windows_paths(server): """Would fail on Windows before fix: _file_to_url only checked startswith('/').""" windows_path = "C:\\Users\\Test\\.cache\\image.png" with patch.object(Path, "is_absolute", return_value=True): with patch.object(Path, "exists", return_value=True): result = server._file_to_url(windows_path) assert result == "/file/C:/Users/Test/.cache/image.png" def test_file_to_url_converts_real_file_paths(server, tmp_path): """Verifies real filesystem paths are converted to /file/ URLs.""" test_file = tmp_path / "test.png" test_file.write_bytes(b"test") result = server._file_to_url(str(test_file)) assert result.startswith("/file/") assert "\\" not in result ================================================ FILE: tests/ui/__init__.py ================================================ ================================================ FILE: tests/ui/conftest.py ================================================ import os import tempfile from typing import Generator import pytest from playwright.sync_api import Browser, Page, sync_playwright @pytest.fixture def temp_db(): with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: db_path = f.name yield db_path try: os.unlink(db_path) except OSError: pass @pytest.fixture(scope="session") def browser() -> Generator[Browser, None, None]: with sync_playwright() as p: browser = p.chromium.launch(headless=True) yield browser browser.close() @pytest.fixture def page( browser: Browser, request: pytest.FixtureRequest ) -> Generator[Page, None, None]: video_option = request.config.getoption("--video", default=None) if video_option == "on": context = browser.new_context( record_video_dir="test-results/", record_video_size={"width": 1280, "height": 720}, ) page = context.new_page() else: page = browser.new_page() page.set_default_timeout(15000) yield page page.close() if video_option == "on": context.close() def pytest_addoption(parser: pytest.Parser): parser.addoption( "--video", action="store", default=None, help="Record video: on/off" ) ================================================ FILE: tests/ui/helpers.py ================================================ import os import socket import threading import time import uvicorn from playwright.sync_api import Page from daggr import Graph from daggr.server import DaggrServer def find_available_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] class TestServer(uvicorn.Server): def install_signal_handlers(self): pass def run_in_thread(self): self.thread = threading.Thread(target=self.run, daemon=True) self.thread.start() start = time.time() while not self.started: time.sleep(0.01) if time.time() - start > 10: raise RuntimeError("Server failed to start") def close(self): self.should_exit = True self.thread.join(timeout=5) def launch_daggr_server( graph: Graph, temp_db: str, theme=None ) -> tuple[TestServer, str]: os.environ["DAGGR_DB_PATH"] = temp_db port = find_available_port() server = DaggrServer(graph, theme=theme) config = uvicorn.Config( app=server.app, host="127.0.0.1", port=port, log_level="warning", ) test_server = TestServer(config) test_server.run_in_thread() url = f"http://127.0.0.1:{port}" return test_server, url def wait_for_graph_load(page: Page, timeout: int = 15000): page.wait_for_function( """() => { const status = document.querySelector('.connection-status'); if (status) return false; const nodes = document.querySelectorAll('.node'); return nodes.length > 0; }""", timeout=timeout, ) ================================================ FILE: tests/ui/test_basic.py ================================================ import gradio as gr from playwright.sync_api import Page, expect from daggr import FnNode, Graph from tests.ui.helpers import launch_daggr_server, wait_for_graph_load def test_nodes_and_edges_render(page: Page, temp_db: str): def double(x): return x * 2 def add_ten(y): return y + 10 node_a = FnNode( double, name="doubler", inputs={"x": gr.Number(label="Input Number", value=5)}, outputs={"result": gr.Number(label="Doubled")}, ) node_b = FnNode( add_ten, name="adder", inputs={"y": node_a.result}, outputs={"result": gr.Number(label="Final Result")}, ) graph = Graph("Basic Test", nodes=[node_b], persist_key=False) server, url = launch_daggr_server(graph, temp_db) try: page.goto(url) wait_for_graph_load(page) nodes = page.locator(".node") expect(nodes).to_have_count(3) node_names = page.locator(".node-name") names = [node_names.nth(i).text_content() for i in range(node_names.count())] assert "doubler" in names assert "adder" in names edges = page.locator(".edge-path") expect(edges).to_have_count(2) finally: server.close() def test_run_workflow_produces_output(page: Page, temp_db: str): def greet(name): return f"Hello, {name}!" node = FnNode( greet, name="greeter", inputs={"name": gr.Textbox(label="Name", value="World")}, outputs={"greeting": gr.Textbox(label="Greeting")}, ) graph = Graph("Greeting Test", nodes=[node], persist_key=False) server, url = launch_daggr_server(graph, temp_db) try: page.goto(url) wait_for_graph_load(page) run_btn = page.locator(".run-btn").first expect(run_btn).to_be_visible() run_btn.click() page.wait_for_function( """() => { const inputs = document.querySelectorAll('.embedded-components input[type="text"]'); for (const inp of inputs) { if (inp.value && inp.value.includes('Hello')) { return true; } } return false; }""", timeout=15000, ) finally: server.close() def test_input_node_accepts_value(page: Page, temp_db: str): def process(text): return text.upper() node = FnNode( process, name="uppercaser", inputs={"text": gr.Textbox(label="Input Text", value="test")}, outputs={"result": gr.Textbox(label="Uppercase")}, ) graph = Graph("Input Test", nodes=[node], persist_key=False) server, url = launch_daggr_server(graph, temp_db) try: page.goto(url) wait_for_graph_load(page) input_node = page.locator(".node:has(.type-badge:text('INPUT'))") expect(input_node).to_be_visible() input_field = input_node.locator("input[type='text']").first expect(input_field).to_be_visible() input_field.fill("hello world") run_btn = page.locator(".run-btn").first run_btn.click() page.wait_for_function( """() => { const inputs = document.querySelectorAll('.embedded-components input[type="text"]'); for (const inp of inputs) { if (inp.value && inp.value.includes('HELLO WORLD')) { return true; } } return false; }""", timeout=15000, ) finally: server.close() ================================================ FILE: tests/ui/test_cancel.py ================================================ import time import gradio as gr from playwright.sync_api import Page, expect from daggr import FnNode, Graph from tests.ui.helpers import launch_daggr_server, wait_for_graph_load def test_cancel_running_node(page: Page, temp_db: str): def slow_fn(x): time.sleep(30) return x * 2 node = FnNode( slow_fn, name="slow_node", inputs={"x": gr.Number(label="Input", value=5)}, outputs={"result": gr.Number(label="Result")}, ) graph = Graph("Cancel Test", nodes=[node], persist_key=False) server, url = launch_daggr_server(graph, temp_db) try: page.goto(url) wait_for_graph_load(page) slow_node = page.locator(".node:has(.node-name:text('slow_node'))") expect(slow_node).to_be_visible() run_btn = slow_node.locator(".run-btn") expect(run_btn).to_be_visible() run_btn.click() page.wait_for_function( """() => { const nodes = document.querySelectorAll('.node'); for (const node of nodes) { const name = node.querySelector('.node-name'); if (name && name.textContent === 'slow_node') { const btn = node.querySelector('.run-btn.running'); return btn !== null; } } return false; }""", timeout=10000, ) stop_btn = slow_node.locator(".run-btn.running") expect(stop_btn).to_be_visible() stop_btn.click() page.wait_for_function( """() => { const nodes = document.querySelectorAll('.node'); for (const node of nodes) { const name = node.querySelector('.node-name'); if (name && name.textContent === 'slow_node') { const btn = node.querySelector('.run-btn'); return btn && !btn.classList.contains('running'); } } return false; }""", timeout=5000, ) run_btn_after = slow_node.locator(".run-btn:not(.running)") expect(run_btn_after).to_be_visible() finally: server.close() ================================================ FILE: tests/ui/test_dependency_hash.py ================================================ import os import gradio as gr from playwright.sync_api import Page, expect from daggr import GradioNode, Graph, _client_cache from tests.ui.helpers import launch_daggr_server, wait_for_graph_load def test_dependency_hash_auto_update_on_stale_cache(page: Page, temp_db: str): tts = GradioNode( "mrfakename/MeloTTS", api_name="/synthesize", inputs={ "text": gr.Textbox(label="Text"), "speaker": "EN-US", "speed": 1.0, "language": "EN", }, outputs={"audio": gr.Audio()}, validate=False, ) graph = Graph("Hash Tracking Test", nodes=[tts], persist_key=False) stale_hash = "0" * 40 _client_cache.set_dependency_hash("mrfakename/MeloTTS", stale_hash) assert _client_cache.get_dependency_hash("mrfakename/MeloTTS") == stale_hash os.environ["DAGGR_DEPENDENCY_CHECK"] = "update" try: graph._check_dependency_hashes() finally: os.environ.pop("DAGGR_DEPENDENCY_CHECK", None) updated_hash = _client_cache.get_dependency_hash("mrfakename/MeloTTS") assert updated_hash is not None assert updated_hash != stale_hash, ( "Hash should have been updated from the stale value" ) server, url = launch_daggr_server(graph, temp_db) try: page.goto(url) wait_for_graph_load(page) nodes = page.locator(".node") expect(nodes).to_have_count(2) node_names = page.locator(".node-name") names = [node_names.nth(i).text_content() for i in range(node_names.count())] assert "MeloTTS" in names finally: server.close() ================================================ FILE: tests/ui/test_image_fix.py ================================================ import tempfile from pathlib import Path import gradio as gr from PIL import Image from playwright.sync_api import Page, expect from daggr import FnNode, Graph from tests.ui.helpers import launch_daggr_server, wait_for_graph_load LOGO = str( Path(__file__).resolve().parent.parent.parent / "daggr" / "assets" / "logo_dark.png" ) def test_image_initial_value_and_none_input(page: Page, temp_db: str): def flip_image(image): if image is None: return None img = Image.open(image) img = img.transpose(Image.FLIP_LEFT_RIGHT) out = Path(tempfile.gettempdir()) / "daggr_flip_test.png" img.save(out) return str(out) node = FnNode( flip_image, name="flip", inputs={"image": gr.Image(label="Input Image", value=LOGO)}, outputs={"flipped": gr.Image(label="Flipped Image")}, ) graph = Graph("Image Fix Test", nodes=[node], persist_key=False) server, url = launch_daggr_server(graph, temp_db) try: page.goto(url) wait_for_graph_load(page) page.wait_for_function( """() => { const imgs = document.querySelectorAll('.embedded-components img'); for (const img of imgs) { if (img.src && img.src.includes('/file/') && img.naturalWidth > 0) { return true; } } return false; }""", timeout=15000, ) input_img = page.locator(".embedded-components img[src*='/file/']").first expect(input_img).to_be_visible() run_btn = page.locator(".run-btn").first expect(run_btn).to_be_visible() run_btn.click() page.wait_for_function( """() => { const imgs = document.querySelectorAll('.embedded-components img[src*="/file/"]'); return imgs.length >= 2; }""", timeout=15000, ) output_imgs = page.locator(".embedded-components img[src*='/file/']") expect(output_imgs).to_have_count(2) finally: server.close() ================================================ FILE: tests/ui/test_images.py ================================================ import tempfile from pathlib import Path import gradio as gr from PIL import Image from playwright.sync_api import Page, expect from daggr import FnNode, Graph from tests.ui.helpers import launch_daggr_server, wait_for_graph_load def test_image_output_displays(page: Page, temp_db: str): def generate_image(prompt): img = Image.new("RGB", (100, 100), color=(255, 0, 0)) temp_dir = Path(tempfile.gettempdir()) / "daggr_test_images" temp_dir.mkdir(exist_ok=True) img_path = temp_dir / "test_image.png" img.save(img_path) return str(img_path) node = FnNode( generate_image, name="image_generator", inputs={"prompt": gr.Textbox(label="Prompt", value="red square")}, outputs={"image": gr.Image(label="Generated Image")}, ) graph = Graph("Image Test", nodes=[node], persist_key=False) server, url = launch_daggr_server(graph, temp_db) try: page.goto(url) wait_for_graph_load(page) run_btn = page.locator(".run-btn").first expect(run_btn).to_be_visible() run_btn.click() page.wait_for_function( """() => { const imgs = document.querySelectorAll('.embedded-components img'); for (const img of imgs) { if (img.src && (img.src.includes('/file/') || img.src.startsWith('data:'))) { return true; } } return false; }""", timeout=15000, ) finally: server.close() def test_image_input_and_output(page: Page, temp_db: str): def process_image(image): return image node = FnNode( process_image, name="image_passthrough", inputs={"image": gr.Image(label="Input Image")}, outputs={"output": gr.Image(label="Output Image")}, ) graph = Graph("Image IO Test", nodes=[node], persist_key=False) server, url = launch_daggr_server(graph, temp_db) try: page.goto(url) wait_for_graph_load(page) nodes = page.locator(".node") expect(nodes).to_have_count(2) input_node = page.locator(".node:has(.type-badge:text('INPUT'))") expect(input_node).to_be_visible() finally: server.close() def test_multiple_outputs_with_image(page: Page, temp_db: str): def generate_with_info(prompt): img = Image.new("RGB", (50, 50), color=(0, 255, 0)) temp_dir = Path(tempfile.gettempdir()) / "daggr_test_images" temp_dir.mkdir(exist_ok=True) img_path = temp_dir / "green_image.png" img.save(img_path) return str(img_path), f"Generated from: {prompt}" node = FnNode( generate_with_info, name="multi_output", inputs={"prompt": gr.Textbox(label="Prompt", value="green square")}, outputs={ "image": gr.Image(label="Generated"), "info": gr.Textbox(label="Info"), }, ) graph = Graph("Multi Output Test", nodes=[node], persist_key=False) server, url = launch_daggr_server(graph, temp_db) try: page.goto(url) wait_for_graph_load(page) run_btn = page.locator(".run-btn").first run_btn.click() page.wait_for_function( """() => { const imgs = document.querySelectorAll('.embedded-components img'); const inputs = document.querySelectorAll('.embedded-components input[type="text"]'); let hasImage = false; let hasText = false; for (const img of imgs) { if (img.src && (img.src.includes('/file/') || img.src.startsWith('data:'))) { hasImage = true; } } for (const inp of inputs) { if (inp.value && inp.value.includes('Generated from:')) { hasText = true; } } return hasImage && hasText; }""", timeout=15000, ) finally: server.close() ================================================ FILE: tests/ui/test_run_mode.py ================================================ import re import gradio as gr from playwright.sync_api import Page, expect from daggr import FnNode, Graph from tests.ui.helpers import launch_daggr_server, wait_for_graph_load def test_run_mode_dropdown_and_single_step(page: Page, temp_db: str): def add_one(x): return x + 1 def double(y): return y * 2 node_a = FnNode( add_one, name="add_one", inputs={"x": gr.Number(label="Input", value=5)}, outputs={"result": gr.Number(label="Plus One")}, ) node_b = FnNode( double, name="double", inputs={"y": node_a.result}, outputs={"result": gr.Number(label="Doubled")}, ) graph = Graph("Run Mode Test", nodes=[node_b], persist_key=False) server, url = launch_daggr_server(graph, temp_db) try: page.goto(url) wait_for_graph_load(page) double_node = page.locator(".node:has(.node-name:text('double'))") expect(double_node).to_be_visible() run_controls = double_node.locator(".run-controls") expect(run_controls).to_be_visible() run_mode_toggle = run_controls.locator(".run-mode-toggle") expect(run_mode_toggle).to_be_visible() run_mode_toggle.click() run_mode_menu = page.locator(".run-mode-menu") expect(run_mode_menu).to_be_visible() step_option = run_mode_menu.locator( ".run-mode-option:has-text('Run this step')" ) expect(step_option).to_be_visible() to_here_option = run_mode_menu.locator( ".run-mode-option:has-text('Run to here')" ) expect(to_here_option).to_be_visible() # Default is "Run to here" expect(to_here_option).to_have_class(re.compile(r"active")) # Select "Run this step" and verify icon changes to single play step_option.click() expect(run_mode_menu).to_be_hidden() page.wait_for_function( """() => { const nodes = document.querySelectorAll('.node'); for (const node of nodes) { const name = node.querySelector('.node-name'); if (name && name.textContent === 'double') { const icon = node.querySelector('.run-btn .run-icon-svg'); return icon && !icon.classList.contains('run-icon-double'); } } return false; }""", timeout=5000, ) # Select "Run to here" and verify icon changes back to double play run_mode_toggle.click() expect(run_mode_menu).to_be_visible() to_here_option = page.locator( ".run-mode-menu .run-mode-option:has-text('Run to here')" ) to_here_option.click() page.wait_for_function( """() => { const nodes = document.querySelectorAll('.node'); for (const node of nodes) { const name = node.querySelector('.node-name'); if (name && name.textContent === 'double') { const icon = node.querySelector('.run-btn .run-icon-svg'); return icon && icon.classList.contains('run-icon-double'); } } return false; }""", timeout=5000, ) finally: server.close() ================================================ FILE: tests/ui/test_sheets.py ================================================ import gradio as gr from playwright.sync_api import Page, expect from daggr import FnNode, Graph from tests.ui.helpers import launch_daggr_server, wait_for_graph_load def test_sheets_ui_elements_present(page: Page, temp_db: str): def process(text): return text.upper() node = FnNode( process, name="processor", inputs={"text": gr.Textbox(label="Input", value="test")}, outputs={"result": gr.Textbox(label="Result")}, ) graph = Graph("Sheets Test", nodes=[node], persist_key="sheets_test") server, url = launch_daggr_server(graph, temp_db) try: page.goto(url) wait_for_graph_load(page) sheet_selector = page.locator(".sheet-current") expect(sheet_selector).to_be_visible(timeout=5000) sheet_name = page.locator(".sheet-name") expect(sheet_name).to_be_visible() expect(sheet_name).to_contain_text("Sheet") finally: server.close() def test_create_new_sheet(page: Page, temp_db: str): def echo(text): return text node = FnNode( echo, name="echo", inputs={"text": gr.Textbox(label="Text", value="hello")}, outputs={"result": gr.Textbox(label="Echo")}, ) graph = Graph("New Sheet Test", nodes=[node], persist_key="new_sheet_test") server, url = launch_daggr_server(graph, temp_db) try: page.goto(url) wait_for_graph_load(page) sheet_selector = page.locator(".sheet-current") expect(sheet_selector).to_be_visible(timeout=5000) sheet_selector.click() dropdown = page.locator(".sheet-dropdown") expect(dropdown).to_be_visible() new_sheet_btn = page.locator(".sheet-new") expect(new_sheet_btn).to_be_visible() new_sheet_btn.click() expect(page.locator(".sheet-dropdown")).not_to_be_visible(timeout=5000) sheet_selector.click() sheet_options = page.locator(".sheet-option") expect(sheet_options).to_have_count(2, timeout=5000) finally: server.close() def test_switch_between_sheets(page: Page, temp_db: str): def process(text): return f"Processed: {text}" node = FnNode( process, name="processor", inputs={"text": gr.Textbox(label="Input", value="default")}, outputs={"result": gr.Textbox(label="Result")}, ) graph = Graph("Switch Sheets Test", nodes=[node], persist_key="switch_sheets_test") server, url = launch_daggr_server(graph, temp_db) try: page.goto(url) wait_for_graph_load(page) input_node = page.locator(".node:has(.type-badge:text('INPUT'))") input_field = input_node.locator("input[type='text']").first expect(input_field).to_be_visible() input_field.fill("Sheet 1 Value") run_btn = page.locator(".run-btn").first run_btn.click() page.wait_for_function( """() => { const inputs = document.querySelectorAll('.embedded-components input[type="text"]'); for (const inp of inputs) { if (inp.value && inp.value.includes('Processed:')) { return true; } } return false; }""", timeout=15000, ) sheet_selector = page.locator(".sheet-current") sheet_selector.click() new_sheet_btn = page.locator(".sheet-new") new_sheet_btn.click() page.wait_for_timeout(1500) wait_for_graph_load(page) input_field = page.locator( ".node:has(.type-badge:text('INPUT')) input[type='text']" ).first expect(input_field).to_be_visible() current_value = input_field.input_value() assert current_value == "default" or current_value == "" sheet_selector = page.locator(".sheet-current") sheet_selector.click() first_sheet = page.locator(".sheet-option").first first_sheet.locator(".sheet-option-name").click() page.wait_for_timeout(1000) wait_for_graph_load(page) input_field = page.locator( ".node:has(.type-badge:text('INPUT')) input[type='text']" ).first restored_value = input_field.input_value() assert restored_value == "Sheet 1 Value" finally: server.close() def test_result_persists_on_sheet(page: Page, temp_db: str): def double(x): return x * 2 node = FnNode( double, name="doubler", inputs={"x": gr.Number(label="Number", value=5)}, outputs={"result": gr.Number(label="Doubled")}, ) graph = Graph( "Persist Result Test", nodes=[node], persist_key="persist_result_test" ) server, url = launch_daggr_server(graph, temp_db) try: page.goto(url) wait_for_graph_load(page) run_btn = page.locator(".run-btn").first run_btn.click() page.wait_for_function( """() => { const inputs = document.querySelectorAll('.embedded-components input[type="number"]'); for (const inp of inputs) { if (inp.value === '10') { return true; } } return false; }""", timeout=15000, ) page.reload() wait_for_graph_load(page) page.wait_for_function( """() => { const inputs = document.querySelectorAll('.embedded-components input[type="number"]'); for (const inp of inputs) { if (inp.value === '10') { return true; } } return false; }""", timeout=15000, ) finally: server.close() ================================================ FILE: tests/ui/test_theme.py ================================================ import gradio as gr from playwright.sync_api import Page from daggr import FnNode, Graph from tests.ui.helpers import launch_daggr_server, wait_for_graph_load def test_theme_support(page: Page, temp_db: str): """Test that theme CSS is served and applied correctly.""" def echo(text): return text node = FnNode( echo, name="echo", inputs={"text": gr.Textbox(label="Input")}, outputs={"result": gr.Textbox(label="Output")}, ) graph = Graph("Theme Test", nodes=[node], persist_key=False) server, url = launch_daggr_server(graph, temp_db, theme=gr.themes.Soft()) try: response = page.request.get(f"{url}/theme.css") assert response.ok css_content = response.text() assert "--body-background-fill" in css_content assert "--color-accent" in css_content page.emulate_media(color_scheme="dark") page.goto(url) wait_for_graph_load(page) has_dark_class = page.evaluate("() => document.body.classList.contains('dark')") assert has_dark_class has_accent = page.evaluate(""" () => { const value = getComputedStyle(document.documentElement).getPropertyValue('--color-accent'); return value && value.trim().length > 0; } """) assert has_accent finally: server.close()