From 27a1ad04b48146f528ad38ee07a8126d1c3d3375 Mon Sep 17 00:00:00 2001 From: Yesudeep Mangalapilly Date: Sat, 20 Dec 2025 14:51:24 -0800 Subject: [PATCH] feat(py): add support for ai.define_resource --- py/packages/genkit/pyproject.toml | 1 + py/packages/genkit/src/genkit/ai/_registry.py | 21 +- py/packages/genkit/src/genkit/ai/resource.py | 415 ++++++++++++++++++ .../genkit/src/genkit/core/action/types.py | 1 + .../genkit/src/genkit/core/registry.py | 14 + .../genkit/tests/genkit/ai/test_resource.py | 275 ++++++++++++ .../tests/genkit/veneer/resource_test.py | 52 +++ py/samples/evaluator-demo/src/eval_demo.py | 1 - py/uv.lock | 2 +- 9 files changed, 777 insertions(+), 5 deletions(-) create mode 100644 py/packages/genkit/src/genkit/ai/resource.py create mode 100644 py/packages/genkit/tests/genkit/ai/test_resource.py create mode 100644 py/packages/genkit/tests/genkit/veneer/resource_test.py diff --git a/py/packages/genkit/pyproject.toml b/py/packages/genkit/pyproject.toml index ab4f17a288..4f0dd54a90 100644 --- a/py/packages/genkit/pyproject.toml +++ b/py/packages/genkit/pyproject.toml @@ -50,6 +50,7 @@ dependencies = [ "dotpromptz>=0.1.4", "uvicorn>=0.34.0", "anyio>=4.9.0", + ] description = "Genkit AI Framework" license = { text = "Apache-2.0" } diff --git a/py/packages/genkit/src/genkit/ai/_registry.py b/py/packages/genkit/src/genkit/ai/_registry.py index 8d62249981..40651e0693 100644 --- a/py/packages/genkit/src/genkit/ai/_registry.py +++ b/py/packages/genkit/src/genkit/ai/_registry.py @@ -42,11 +42,12 @@ import uuid from collections.abc import AsyncIterator, Callable from functools import wraps -from typing import Any, Type +from typing import Any import structlog from pydantic import BaseModel +from genkit.ai.resource import ResourceFn, ResourceOptions, define_resource from genkit.blocks.embedding import EmbedderFn, EmbedderOptions from genkit.blocks.evaluator import BatchEvaluatorFn, EvaluatorFn from genkit.blocks.formats.types import FormatDef @@ -488,7 +489,7 @@ def define_model( self, name: str, fn: ModelFn, - config_schema: Type[BaseModel] | dict[str, Any] | None = None, + config_schema: type[BaseModel] | dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, info: ModelInfo | None = None, description: str | None = None, @@ -668,13 +669,27 @@ async def prompt( Raises: GenkitError: If the prompt is not found. """ - return await lookup_prompt( registry=self.registry, name=name, variant=variant, ) + def define_resource(self, opts: ResourceOptions, fn: ResourceFn) -> Action: + """Defines a resource and registers it with the registry. + + This creates a resource action that can handle requests for a specific URI + or URI template. + + Args: + opts: Options defining the resource (name, uri, template, etc.). + fn: The function that implements resource content retrieval. + + Returns: + The registered `Action` for the resource. + """ + return define_resource(self.registry, opts, fn) + class FlowWrapper: """A wapper for flow functions to add `stream` method.""" diff --git a/py/packages/genkit/src/genkit/ai/resource.py b/py/packages/genkit/src/genkit/ai/resource.py new file mode 100644 index 0000000000..be2814289f --- /dev/null +++ b/py/packages/genkit/src/genkit/ai/resource.py @@ -0,0 +1,415 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Resource module for defining and managing resources. + +Resources in Genkit represent addressable content or data processing units containing +unstructured data (Post, PDF, etc.) that can be retrieved or generated. They are +identified by URIs (e.g. `file://`, `http://`, `gs://`) and can be static (fixed URI) +or dynamic (using URI templates). + +This module provides tools to define resource actions that can resolve these URIs +and return content (`ResourceOutput`) containing `Part`s. +""" + +import re +from collections.abc import Awaitable, Callable +from typing import Any, Protocol, TypedDict + +from pydantic import BaseModel + +from genkit.core.action import Action, ActionRunContext +from genkit.core.action.types import ActionKind +from genkit.core.registry import Registry +from genkit.core.typing import Metadata, Part + + +class ResourceOptions(TypedDict, total=False): + """Options for defining a resource. + + Attributes: + name: Resource name. If not specified, uri or template will be used as name. + uri: The URI of the resource. Can contain template variables for simple matches, + but `template` is preferred for pattern matching. + template: The URI template (ex. `my://resource/{id}`). See RFC6570 for specification. + Used for matching variable resources. + description: A description of the resource, used for documentation and discovery. + metadata: Arbitrary metadata to attach to the resource action. + """ + + name: str + uri: str + template: str + description: str + metadata: dict[str, Any] + + +class ResourceInput(BaseModel): + """Input structure for a resource request. + + Attributes: + uri: The full URI being requested/resolved. + """ + + uri: str + + +class ResourceOutput(BaseModel): + """Output structure from a resource resolution. + + Attributes: + content: A list of `Part` objects representing the resource content. + """ + + content: list[Part] + + +class ResourceFn(Protocol): + """A function that returns parts for a given resource. + + The function receives the resolved input (including the URI) and context, + and should return a `ResourceOutput` containing the content parts. + """ + + def __call__(self, input: ResourceInput, ctx: ActionRunContext) -> Awaitable[ResourceOutput]: ... + + +ResourceArgument = Action | str + + +async def resolve_resources(registry: Registry, resources: list[ResourceArgument] | None = None) -> list[Action]: + """Resolves a list of resource names or actions into a list of Action objects. + + Args: + registry: The registry to lookup resources in. + resources: A list of resource references, which can be either direct `Action` + objects or strings (names/URIs). + + Returns: + A list of resolved `Action` objects. + + Raises: + ValueError: If a resource reference is invalid or cannot be found. + """ + if not resources: + return [] + + resolved_actions = [] + for ref in resources: + if isinstance(ref, str): + resolved_actions.append(await lookup_resource_by_name(registry, ref)) + elif isinstance(ref, Action): + resolved_actions.append(ref) + else: + raise ValueError('Resources must be strings or actions') + return resolved_actions + + +async def lookup_resource_by_name(registry: Registry, name: str) -> Action: + """Looks up a resource action by name in the registry. + + Tries to resolve the name directly, or with common prefixes like `/resource/` + or `/dynamic-action-provider/`. + + Args: + registry: The registry to search. + name: The name or URI of the resource to lookup. + + Returns: + The found `Action`. + + Raises: + ValueError: If the resource cannot be found. + """ + resource = ( + registry.lookup_action(ActionKind.RESOURCE, name) + or registry.lookup_action(ActionKind.RESOURCE, f'/resource/{name}') + or registry.lookup_action(ActionKind.RESOURCE, f'/dynamic-action-provider/{name}') + ) + if not resource: + raise ValueError(f'Resource {name} not found') + return resource + + +def define_resource(registry: Registry, opts: ResourceOptions, fn: ResourceFn) -> Action: + """Defines a resource and registers it with the given registry. + + This creates a resource action that can handle requests for a specific URI + or URI template. + + Args: + registry: The registry to register the resource with. + opts: Options defining the resource (name, uri, template, etc.). + fn: The function that implements resource content retrieval. + + Returns: + The registered `Action` for the resource. + """ + action = dynamic_resource(opts, fn) + + action.matches = create_matcher(opts.get('uri'), opts.get('template')) + + # Mark as not dynamic since it's being registered + action.metadata['dynamic'] = False + + registry.register_action_from_instance(action) + + # We need to return the registered action from the registry if we want it to be the exact same instance + # but the one created by dynamic_resource is fine too if it has the same properties. + return action + + +def resource(opts: ResourceOptions, fn: ResourceFn) -> Action: + """Defines a dynamic resource action without immediate registration. + + This is an alias for `dynamic_resource`. Useful for defining resources that + might be registered later or used as standalone actions. + + Args: + opts: Options defining the resource. + fn: The resource implementation function. + + Returns: + The created `Action`. + """ + return dynamic_resource(opts, fn) + + +def dynamic_resource(opts: ResourceOptions, fn: ResourceFn) -> Action: + """Defines a dynamic resource action. + + Creates an `Action` of kind `RESOURCE` that wraps the provided function. + The wrapper handles: + 1. Input validation and matching against the URI/Template. + 2. Execution of the resource function. + 3. Post-processing of output to attach metadata (like parent resource info). + + Args: + opts: Options including `uri` or `template` for matching. + fn: The function performing the resource retrieval. + + Returns: + An `Action` configured as a resource. + + Raises: + ValueError: If neither `uri` nor `template` is provided in options. + """ + uri = opts.get('uri') or opts.get('template') + if not uri: + raise ValueError('must specify either uri or template options') + + matcher = create_matcher(opts.get('uri'), opts.get('template')) + + async def wrapped_fn(input_data: ResourceInput, ctx: ActionRunContext) -> ResourceOutput: + if isinstance(input_data, dict): + input_data = ResourceInput(**input_data) + + try: + template_match = matcher(input_data) + if not template_match: + raise ValueError(f'input {input_data} did not match template {uri}') + + parts = await fn(input_data, ctx) + + # Post-processing parts to add metadata + content_list = parts.content if hasattr(parts, 'content') else parts.get('content', []) + + for p in content_list: + if isinstance(p, Part): + p = p.root + + if hasattr(p, 'metadata'): + if p.metadata is None: + p.metadata = {} + + if isinstance(p.metadata, Metadata): + p_metadata = p.metadata.root + else: + p_metadata = p.metadata + + if 'resource' in p_metadata: + if 'parent' not in p_metadata['resource']: + p_metadata['resource']['parent'] = {'uri': input_data.uri} + if opts.get('template'): + p_metadata['resource']['parent']['template'] = opts.get('template') + else: + p_metadata['resource'] = {'uri': input_data.uri} + if opts.get('template'): + p_metadata['resource']['template'] = opts.get('template') + elif isinstance(p, dict): + if 'metadata' not in p or p['metadata'] is None: + p['metadata'] = {} + p_metadata = p['metadata'] + else: + continue + # Ensure we return a serializable dict (handling Pydantic models in list) + if isinstance(parts, BaseModel): + return parts.model_dump() + elif isinstance(parts, dict): + # Verify content items are dicts, if not dump them + if 'content' in parts: + parts['content'] = [p.model_dump() if isinstance(p, BaseModel) else p for p in parts['content']] + return parts + return parts + except Exception as e: + raise e + + # Since p.metadata is a dict in Pydantic RootModel for Metadata usually, assuming it's accessible. + # Part -> ResourcePart | etc. ResourcePart has resource: Resource1. + # But the JS code puts it in metadata.resource. + # In Python typing.py, Metadata is RootModel[dict[str, Any]]. + + if 'resource' in p_metadata: + if 'parent' not in p_metadata['resource']: + p_metadata['resource']['parent'] = {'uri': input_data.uri} + if opts.get('template'): + p_metadata['resource']['parent']['template'] = opts.get('template') + else: + p_metadata['resource'] = {'uri': input_data.uri} + if opts.get('template'): + p_metadata['resource']['template'] = opts.get('template') + + return parts + + name = opts.get('name') or uri + + act = Action( + kind=ActionKind.RESOURCE, + name=name, + fn=wrapped_fn, + description=opts.get('description'), + # input_schema=ResourceInput, + # Action expects schema? Action definition has metadata_fn usually + metadata={ + 'resource': { + 'uri': opts.get('uri'), + 'template': opts.get('template'), + }, + **(opts.get('metadata') or {}), + 'type': 'resource', + 'dynamic': True, + }, + ) + + act.matches = matcher + return act + + +def create_matcher(uri_opt: str | None, template_opt: str | None) -> Callable[[ResourceInput], bool]: + """Creates a matching function for resource inputs. + + Args: + uri_opt: An exact URI string to match. + template_opt: A URI template string to match (e.g. `file://{path}`). + + Returns: + A callable that takes `ResourceInput` and returns `True` if it matches, + `False` otherwise. + """ + # TODO: normalize resource URI + if uri_opt: + return lambda input: input.uri == uri_opt + + if template_opt: + # Improved regex matching to support basic RFC 6570 patterns. + # + # Why not use uritemplate library? + # The python-hyper/uritemplate library matches the RFC 6570 spec well for *expansion* + # (Template + Vars -> URI), but it does not support "reverse matching" or parsing + # (URI -> Vars) which is required here to match an incoming URI against a template. + # + # To patch uritemplate to support this, we would need to: + # 1. Expose the parsed template tokens (literals and expressions). + # 2. Implement a compiler that converts these tokens into a regular expression, + # mapping expression types (e.g. {var}, {+var}) to appropriate regex groups. + # - Simple string expansion {var}: ([^/]+) - stops at reserved characters. + # - Reserved expansion {+var}: (.+) - matches reserved characters like slashes. + # + # Until then, we use a lightweight regex implementation that covers the most common + # use cases in Genkit resources. + + # escaping the template string + pattern_str = re.escape(template_opt) + + # Handle reserved expansion {+var} -> (.+) (matches everything including slashes) + pattern_str = re.sub(r'\\\{\\\+[^}]+\\\}', '(.+)', pattern_str) + + # Handle simple expansion {var} -> ([^/]+) (matches path segment) + pattern_str = re.sub(r'\\\{[^}]+\\\}', '([^/]+)', pattern_str) + + # Ensure full match + pattern = re.compile(f'^{pattern_str}$') + + return lambda input: pattern.match(input.uri) is not None + + return lambda input: False + + +async def find_matching_resource( + registry: Registry, resources: list[Action], input_data: ResourceInput +) -> Action | None: + """Finds a registered or provided resource action that matches the input. + + Searches through the provided `resources` list (first priority) and then + the global registry for any resource action that matches the `input_data.uri`. + + Args: + registry: The registry to search. + resources: A list of explicitly provided actions to check first. + input_data: The input containing the URI to match. + + Returns: + The matching `Action` if found, `None` otherwise. + """ + # First look in any resources explicitly listed + for res in resources: + if hasattr(res, 'matches') and res.matches(input_data): + return res + + # Then search the registry + # In python registry.list_actions returns dict[str, Action] or we can use lookup logic. + # Registry doesn't expose easy iteration over all actions by key pattern directly efficienty except list_actions. + + # We need list_actions but current registry.list_actions expects us to pass a dict to fill? + # Or list_serializable_actions returns dict. + + # We can access _entries but that's private. + # We should add a method to Registry if needed, or use public API. + # registry.list_actions() exists. + + all_actions = registry.list_serializable_actions({ActionKind.RESOURCE}) + if not all_actions: + return None + + for key in all_actions: + action = registry.lookup_action_by_key(key) + if action and hasattr(action, 'matches') and action.matches(input_data): + return action + + return None + + +def is_dynamic_resource_action(obj: Any) -> bool: + """Checks if an object is a dynamic resource action. + + Dynamic resources are actions that haven't been registered yet or are explicitly + marked as dynamic (often matching multiple URIs via template). + + Args: + obj: The object to check. + + Returns: + True if the object is an Action of kind RESOURCE and marked dynamic. + """ + return isinstance(obj, Action) and obj.metadata.get('dynamic') is True diff --git a/py/packages/genkit/src/genkit/core/action/types.py b/py/packages/genkit/src/genkit/core/action/types.py index 9609285499..ecfbec7c3c 100644 --- a/py/packages/genkit/src/genkit/core/action/types.py +++ b/py/packages/genkit/src/genkit/core/action/types.py @@ -58,6 +58,7 @@ class ActionKind(StrEnum): PROMPT = 'prompt' RERANKER = 'reranker' RETRIEVER = 'retriever' + RESOURCE = 'resource' TOOL = 'tool' UTIL = 'util' diff --git a/py/packages/genkit/src/genkit/core/registry.py b/py/packages/genkit/src/genkit/core/registry.py index 316c8c0ba2..455b294c91 100644 --- a/py/packages/genkit/src/genkit/core/registry.py +++ b/py/packages/genkit/src/genkit/core/registry.py @@ -162,6 +162,20 @@ def register_action( self._entries[kind][name] = action return action + def register_action_from_instance(self, action: Action) -> None: + """Register an existing Action instance. + + Allows registering a pre-configured Action object, such as one created via + `dynamic_resource` or other factory methods. + + Args: + action: The action instance to register. + """ + with self._lock: + if action.kind not in self._entries: + self._entries[action.kind] = {} + self._entries[action.kind][action.name] = action + def lookup_action(self, kind: ActionKind, name: str) -> Action | None: """Look up an action by its kind and name. diff --git a/py/packages/genkit/tests/genkit/ai/test_resource.py b/py/packages/genkit/tests/genkit/ai/test_resource.py new file mode 100644 index 0000000000..3cbee2f1db --- /dev/null +++ b/py/packages/genkit/tests/genkit/ai/test_resource.py @@ -0,0 +1,275 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the Genkit Resource API. + +This module verifies the functionality of defining, registering, and resolving resources +in the Genkit framework. It covers static resources, template-based resources, +dynamic resource matching, and metadata handling. +""" + +import asyncio + +from genkit.ai.resource import define_resource, resolve_resources, resource +from genkit.core.registry import Registry +from genkit.core.typing import Part, TextPart + + +def test_define_resource(): + """Verifies that a resource can be defined and registered correctly. + + Checks: + - Resource name matches property. + - Resource is retrievable from the registry by name. + """ + registry = Registry() + + async def my_resource_fn(input, ctx): + return {'content': [Part(TextPart(text=f'Content for {input.uri}'))]} + + act = define_resource(registry, {'uri': 'http://example.com/foo'}, my_resource_fn) + + assert act.name == 'http://example.com/foo' + assert act.metadata['resource']['uri'] == 'http://example.com/foo' + + # Verify lookup logic (mocking lookup_action effectively via direct access or helper) + # Registry lookup for resources usually prepends /resource/ etc. + # but define_resource registers it with name=uri + + looked_up = registry.lookup_action('resource', 'http://example.com/foo') + assert looked_up == act + + +def test_resolve_resources(): + """Verifies resolving resource references into Action objects. + + Checks: + - Resolving by string name works. + - Resolving by Action object passes through. + """ + registry = Registry() + + async def my_resource_fn(input, ctx): + return {'content': [Part(TextPart(text=f'Content for {input.uri}'))]} + + act = define_resource(registry, {'name': 'my-resource', 'uri': 'http://example.com/foo'}, my_resource_fn) + + import asyncio + + # Python 3.10+ compatible run + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + resolved = loop.run_until_complete(resolve_resources(registry, ['my-resource'])) + assert len(resolved) == 1 + assert resolved[0] == act + + resolved_obj = loop.run_until_complete(resolve_resources(registry, [act])) + assert len(resolved_obj) == 1 + assert resolved_obj[0] == act + + finally: + loop.close() + + +def test_find_matching_resource(): + """Verifies the logic for finding a matching resource given an input URI. + + Checks: + - Exact match against registered static resources. + - Template match against registered template resources. + - Matching against a provided list of dynamic resource actions for override/adhoc usage. + - Returns None when no match is found. + """ + registry = Registry() + + # Static resource + async def static_fn(input, ctx): + return {'content': []} + + static_res = define_resource(registry, {'uri': 'bar://baz', 'name': 'staticRes'}, static_fn) + + # Template resource + async def template_fn(input, ctx): + return {'content': []} + + template_res = define_resource(registry, {'template': 'foo://bar/{baz}', 'name': 'templateRes'}, template_fn) + + # Dynamic resource list + async def dynamic_fn(input, ctx): + return {'content': []} + + dynamic_res = resource({'uri': 'baz://qux'}, dynamic_fn) + + from genkit.ai.resource import ResourceInput, find_matching_resource + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + # Match static from registry + res = loop.run_until_complete(find_matching_resource(registry, [], ResourceInput(uri='bar://baz'))) + assert res == static_res + + # Match template from registry + res = loop.run_until_complete(find_matching_resource(registry, [], ResourceInput(uri='foo://bar/something'))) + assert res == template_res + + # Match dynamic from list + res = loop.run_until_complete(find_matching_resource(registry, [dynamic_res], ResourceInput(uri='baz://qux'))) + assert res == dynamic_res + + # No match + res = loop.run_until_complete(find_matching_resource(registry, [], ResourceInput(uri='unknown://uri'))) + assert res is None + + finally: + loop.close() + + +def test_is_dynamic_resource_action(): + """Verifies identifying dynamic vs registered resource actions. + + Checks: + - Unregistered resources created with `resource()` are dynamic. + - Registered resources created with `define_resource()` are not dynamic. + """ + from genkit.ai.resource import is_dynamic_resource_action + + async def fn(input, ctx): + return {'content': []} + + dynamic = resource({'uri': 'bar://baz'}, fn) + assert is_dynamic_resource_action(dynamic) + + # Registered action (define_resource sets dynamic=False) + async def static_fn(input, ctx): + return {'content': []} + + static = define_resource(Registry(), {'uri': 'foo://bar'}, static_fn) + assert not is_dynamic_resource_action(static) + + +def test_parent_metadata(): + """Verifies that parent metadata is correctly attached to output items. + + When a resource is resolved via a template (e.g. `file://{id}`), the output parts + should contain metadata referencing the parent resource URI and template. + + Checks: + - Parent URI and template presence in output part metadata. + """ + registry = Registry() + + async def fn(input, ctx): + return {'content': [Part(TextPart(text='sub1', metadata={'resource': {'uri': f'{input.uri}/sub1.txt'}}))]} + + res = define_resource(registry, {'template': 'file://{id}'}, fn) + + # Emulate execution? Action.run/arun logic wrapper handles calling fn. + # Resource wrapper was wrapped_fn. + # We should call res.arun or similar. + + import asyncio + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + output = loop.run_until_complete(res.arun({'uri': 'file://dir'})) + # output is ActionResponse + # content is in output.response['content'] because wrapped_fn ensures serialization + + part = output.response['content'][0] + # Check metadata + assert part['metadata']['resource']['parent']['uri'] == 'file://dir' + assert part['metadata']['resource']['parent']['template'] == 'file://{id}' + assert part['metadata']['resource']['uri'] == 'file://dir/sub1.txt' + + finally: + loop.close() + + +def test_dynamic_resource_matching(): + """Verifies the matching logic for a simple static URI dynamic resource.""" + + async def my_resource_fn(input, ctx): + return {'content': [Part(TextPart(text='Match'))]} + + res = resource({'uri': 'http://example.com/foo'}, my_resource_fn) + + class MockInput: + uri = 'http://example.com/foo' + + assert res.matches(MockInput()) + + class MockInputBad: + uri = 'http://example.com/bar' + + assert not res.matches(MockInputBad()) + + +def test_template_matching(): + """Verifies URI template pattern matching. + + Checks: + - Matches correct URI structure. + - Fails on paths extending beyond the template structure (strict matching). + """ + + async def my_resource_fn(input, ctx): + return {'content': []} + + res = resource({'template': 'http://example.com/items/{id}'}, my_resource_fn) + + class MockInput: + uri = 'http://example.com/items/123' + + assert res.matches(MockInput()) + + class MockInputBad: + uri = 'http://example.com/items/123/details' + + # Should not match because of strict end anchor or slash handling in our regex + assert not res.matches(MockInputBad()) + + +def test_reserved_expansion_matching(): + """Verifies RFC 6570 reserved expansion {+var} pattern matching. + + Checks: + - Matches correct URI structure with slashes (reserved chars). + """ + from genkit.ai.resource import resource + + async def my_resource_fn(input, ctx): + return {'content': []} + + # Template with reserved expansion {+path} (matches slashes) + res = resource({'template': 'http://example.com/files/{+path}'}, my_resource_fn) + + class MockInput: + uri = 'http://example.com/files/foo/bar/baz.txt' + + assert res.matches(MockInput()) + + # Regular template {path} regex ([^/]+) should NOT match slashes + res_simple = resource({'template': 'http://example.com/items/{id}'}, my_resource_fn) + class MockInputComplex: + uri = 'http://example.com/items/foo/bar' + + assert not res_simple.matches(MockInputComplex()) diff --git a/py/packages/genkit/tests/genkit/veneer/resource_test.py b/py/packages/genkit/tests/genkit/veneer/resource_test.py new file mode 100644 index 0000000000..445aae199c --- /dev/null +++ b/py/packages/genkit/tests/genkit/veneer/resource_test.py @@ -0,0 +1,52 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the Genkit Resource API via the Genkit class (Veneer). + +This test file verifies that `ai.define_resource` works correctly, mirroring the +JS SDK's `ai.defineResource`. +""" + +from genkit.ai import Genkit +from genkit.core.typing import Part, TextPart + + +def test_define_resource_veneer(): + """Verifies ai.define_resource registers a resource correctly.""" + ai = Genkit(plugins=[]) + + async def my_resource_fn(input, ctx): + return {'content': [Part(TextPart(text=f'Content for {input.uri}'))]} + + act = ai.define_resource({'uri': 'http://example.com/foo'}, my_resource_fn) + + assert act.name == 'http://example.com/foo' + assert act.metadata['resource']['uri'] == 'http://example.com/foo' + + # Verify lookup via global registry (contained in ai.registry) + looked_up = ai.registry.lookup_action('resource', 'http://example.com/foo') + assert looked_up == act + + import asyncio + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + # Verify execution + output = loop.run_until_complete(act.arun({'uri': 'http://example.com/foo'})) + assert 'Content for http://example.com/foo' in output.response['content'][0]['text'] + finally: + loop.close() diff --git a/py/samples/evaluator-demo/src/eval_demo.py b/py/samples/evaluator-demo/src/eval_demo.py index cbcec2556e..9fe4bd9445 100644 --- a/py/samples/evaluator-demo/src/eval_demo.py +++ b/py/samples/evaluator-demo/src/eval_demo.py @@ -54,7 +54,6 @@ async def substring_match(datapoint: BaseDataPoint, options: Any | None): ) - # Define a flow that programmatically runs the evaluation @ai.flow() async def run_eval_demo(dataset_input: List[BaseDataPoint] | None = None): diff --git a/py/uv.lock b/py/uv.lock index e2da3fbba3..6ac815a35b 100644 --- a/py/uv.lock +++ b/py/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14'",