diff --git a/.github/workflows/scheduled-integration-tests.yml b/.github/workflows/scheduled-integration-tests.yml index 49a9725..02a6a32 100644 --- a/.github/workflows/scheduled-integration-tests.yml +++ b/.github/workflows/scheduled-integration-tests.yml @@ -9,6 +9,8 @@ on: jobs: scheduled-integration-tests: runs-on: ubuntu-latest + env: + HAS_NUTRIENT_API_KEY: ${{ secrets.NUTRIENT_API_KEY != '' }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -24,7 +26,7 @@ jobs: pip install ".[dev]" - name: Run all integration tests - if: secrets.NUTRIENT_API_KEY != '' + if: env.HAS_NUTRIENT_API_KEY == 'true' env: NUTRIENT_API_KEY: ${{ secrets.NUTRIENT_API_KEY }} run: | @@ -35,7 +37,7 @@ jobs: id: test-run - name: Skip scheduled tests (no API key) - if: secrets.NUTRIENT_API_KEY == '' + if: env.HAS_NUTRIENT_API_KEY != 'true' run: | echo "⏭️ Skipping scheduled integration tests - NUTRIENT_API_KEY not available" echo "Configure NUTRIENT_API_KEY secret to enable scheduled API validation" diff --git a/CHANGELOG.md b/CHANGELOG.md index b3500fb..b157413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Load example credentials from `.env` before client creation, attach a working DWS extraction tool to CrewAI, and keep LangChain redaction results in `output/redacted.pdf` instead of returning PDF bytes to the model. + ## [3.1.0] - 2026-05-27 - Added `client.parse()` for the Data Extraction API (`/extraction/parse`) diff --git a/README.md b/README.md index 3bdf9f2..1ec3181 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,22 @@ from nutrient_dws import NutrientClient client = NutrientClient(api_key='your_api_key') ``` +## Framework Quickstarts + +Framework-oriented snippets are available in `examples/src/`: + +- `framework_openai_agents.py` +- `framework_langchain.py` +- `framework_crewai.py` + +Syntax-check commands: + +```bash +python -m py_compile examples/src/framework_openai_agents.py +python -m py_compile examples/src/framework_langchain.py +python -m py_compile examples/src/framework_crewai.py +``` + ## Direct Methods The client provides numerous async methods for document processing: diff --git a/examples/README.md b/examples/README.md index 31f96f8..6922b70 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,6 +8,9 @@ This example project demonstrates how to use the Nutrient DWS Python Client for - `src/` - Contains Python source files - `direct_method.py` - Examples using direct method calls - `workflow.py` - Examples using the workflow builder pattern + - `framework_openai_agents.py` - OpenAI Agents SDK integration sketch + - `framework_langchain.py` - LangChain/LangGraph integration sketch + - `framework_crewai.py` - CrewAI integration sketch - `output/` - Directory where processed files will be saved - `.env.example` - Example environment variables file @@ -122,6 +125,57 @@ This will: 3. Extract text with JSON output 4. Execute a complex multi-step workflow +### Framework Examples + +These examples are intentionally minimal and focus on framework wiring: + +Set both `NUTRIENT_API_KEY` and `OPENAI_API_KEY` in `examples/.env` before running them. Each example loads `.env` and fails clearly when either key is missing. The examples use `gpt-6-luna` and can make billable DWS and model calls when run normally. CrewAI attaches a real text-extraction tool before planning subsequent processing. The LangChain tool saves the redacted PDF to `output/redacted.pdf` and returns only its path and byte count to the model. + +Choose **one framework per fresh virtual environment**. Their OpenAI dependency ranges can differ, so installing all three together can cause lengthy dependency backtracking. From `examples/`, follow only the option you want; use Python 3.10–3.13 for CrewAI. Activation commands below are for macOS/Linux; on Windows use the environment's `Scripts\activate`. + +OpenAI Agents: + +```bash +python -m venv example_venv_agents +source example_venv_agents/bin/activate +pip install -e '..[dev]' openai-agents +python src/framework_openai_agents.py +``` + +LangChain/LangGraph, in a separate environment: + +```bash +python -m venv example_venv_langchain +source example_venv_langchain/bin/activate +pip install -e '..[dev]' langchain langchain-openai langgraph +python src/framework_langchain.py +``` + +CrewAI, in a separate environment: + +```bash +python -m venv example_venv_crewai +source example_venv_crewai/bin/activate +pip install -e '..[dev]' crewai +python src/framework_crewai.py +``` + +Syntax-check only: + +```bash +python -m py_compile src/framework_openai_agents.py +python -m py_compile src/framework_langchain.py +python -m py_compile src/framework_crewai.py +``` + +From the repository root, after installing the optional frameworks and development dependencies, run the offline framework smoke tests: + +```bash +python -m pytest tests/unit/test_framework_examples.py -v +``` + +These tests register and invoke the actual framework tools with a controlled Nutrient client. They block network connections and do not run an LLM or upload a document. Framework-specific cases skip if their optional dependency is absent. + ## Output All processed files will be saved to the `output/` directory. You can examine these files to see the results of the document processing operations. diff --git a/examples/src/framework_crewai.py b/examples/src/framework_crewai.py new file mode 100644 index 0000000..0a0395b --- /dev/null +++ b/examples/src/framework_crewai.py @@ -0,0 +1,66 @@ +import os + +from crewai import Agent, Crew, Task +from crewai.tools import tool +from dotenv import load_dotenv + +from nutrient_dws import NutrientClient + +load_dotenv() +api_key = os.getenv("NUTRIENT_API_KEY") +if not api_key: + raise SystemExit("NUTRIENT_API_KEY is required in your environment or .env file.") +if not os.getenv("OPENAI_API_KEY"): + raise SystemExit("OPENAI_API_KEY is required in your environment or .env file.") + +client = NutrientClient(api_key=api_key) + + +@tool("extract_document_text") +async def extract_document_text(input_path: str) -> str: + """Extract document text with Nutrient DWS before planning further processing.""" + result = await client.extract_text(input_path) + return str(result) + + +document_planner = Agent( + role="Document Workflow Planner", + goal="Design a robust Nutrient DWS processing pipeline for incoming files.", + backstory="You optimize OCR, extraction, and redaction workflows for accuracy.", + tools=[extract_document_text], + llm="gpt-6-luna", +) + +document_reviewer = Agent( + role="Document QA Reviewer", + goal="Review proposed workflow plans for failure modes and compliance risks.", + backstory="You specialize in validating document automation quality.", + llm="gpt-6-luna", +) + +plan_task = Task( + description=( + "Use your extraction tool on ./assets/sample.pdf, then design a pipeline " + "that redacts emails and exports a cleaned PDF." + ), + expected_output="Extracted text and a numbered plan for the remaining processing.", + agent=document_planner, +) + +review_task = Task( + description=( + "Review the proposed pipeline. Flag risks and add fallback handling steps." + ), + expected_output="Risk review with concrete mitigations.", + agent=document_reviewer, +) + +crew = Crew( + agents=[document_planner, document_reviewer], + tasks=[plan_task, review_task], + verbose=True, +) + + +if __name__ == "__main__": + print(crew.kickoff()) diff --git a/examples/src/framework_langchain.py b/examples/src/framework_langchain.py new file mode 100644 index 0000000..ccd3a1c --- /dev/null +++ b/examples/src/framework_langchain.py @@ -0,0 +1,54 @@ +import asyncio +import os +from pathlib import Path + +from dotenv import load_dotenv +from langchain_core.tools import StructuredTool +from langchain_openai import ChatOpenAI +from langgraph.prebuilt import create_react_agent + +from nutrient_dws import NutrientClient + +load_dotenv() +api_key = os.getenv("NUTRIENT_API_KEY") +if not api_key: + raise SystemExit("NUTRIENT_API_KEY is required in your environment or .env file.") +if not os.getenv("OPENAI_API_KEY"): + raise SystemExit("OPENAI_API_KEY is required in your environment or .env file.") + +client = NutrientClient(api_key=api_key) + + +async def redact_emails(path: str) -> str: + result = await client.create_redactions_ai( + path, + criteria="Redact all email addresses.", + redaction_state="apply", + ) + output_path = Path("output") / "redacted.pdf" + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(result["buffer"]) + return f"Saved redacted PDF to {output_path} ({len(result['buffer'])} bytes)." + + +tool = StructuredTool.from_function( + coroutine=redact_emails, + name="redact_emails", + description="Redact email addresses from a document using Nutrient DWS.", +) + + +async def main() -> None: + model = ChatOpenAI( + model="gpt-6-luna", + api_key=os.environ["OPENAI_API_KEY"], + ) + agent = create_react_agent(model, [tool]) + state = await agent.ainvoke( + {"messages": [("user", "Redact emails from ./assets/sample.pdf.")]} + ) + print(state["messages"][-1].content) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/src/framework_openai_agents.py b/examples/src/framework_openai_agents.py new file mode 100644 index 0000000..776e745 --- /dev/null +++ b/examples/src/framework_openai_agents.py @@ -0,0 +1,43 @@ +import asyncio +import os + +from agents import Agent, Runner, function_tool +from dotenv import load_dotenv + +from nutrient_dws import NutrientClient + +load_dotenv() +api_key = os.getenv("NUTRIENT_API_KEY") +if not api_key: + raise SystemExit("NUTRIENT_API_KEY is required in your environment or .env file.") +if not os.getenv("OPENAI_API_KEY"): + raise SystemExit("OPENAI_API_KEY is required in your environment or .env file.") + +client = NutrientClient(api_key=api_key) + + +@function_tool +async def extract_text(input_path: str) -> str: + """Extract text from a document using Nutrient DWS.""" + result = await client.extract_text(input_path) + return str(result) + + +assistant = Agent( + name="nutrient-openai-agents-demo", + model="gpt-6-luna", + instructions="Help users extract text and summarize document workflows.", + tools=[extract_text], +) + + +async def main() -> None: + run = await Runner.run( + assistant, + "Extract text from ./assets/sample.pdf and summarize it in three bullets.", + ) + print(run.final_output) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/nutrient_dws/client.py b/src/nutrient_dws/client.py index 5d6fcd5..e9dc938 100644 --- a/src/nutrient_dws/client.py +++ b/src/nutrient_dws/client.py @@ -586,7 +586,7 @@ async def convert( result = await builder.output_markdown().execute() elif target_format in ["png", "jpeg", "jpg", "webp"]: result = await builder.output_image( - cast("Literal['png', 'jpeg', 'jpg', 'webp']", target_format), + target_format, {"dpi": 300}, ).execute() else: diff --git a/src/nutrient_dws/types/instant_json/form_field_value.py b/src/nutrient_dws/types/instant_json/form_field_value.py index 29e90f0..4114291 100644 --- a/src/nutrient_dws/types/instant_json/form_field_value.py +++ b/src/nutrient_dws/types/instant_json/form_field_value.py @@ -5,7 +5,7 @@ class FormFieldValue(TypedDict): name: str - value: NotRequired[str | None | list[str]] + value: NotRequired[str | list[str] | None] type: Literal["pspdfkit/form-field-value"] v: Literal[1] optionIndexes: NotRequired[list[int]] diff --git a/tests/unit/test_framework_examples.py b/tests/unit/test_framework_examples.py new file mode 100644 index 0000000..b944d48 --- /dev/null +++ b/tests/unit/test_framework_examples.py @@ -0,0 +1,217 @@ +"""Optional-framework smoke tests: real tools, controlled DWS I/O, no model runs.""" + +import asyncio +import importlib.util +import json +import socket +from ipaddress import ip_address +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import dotenv.main +import pytest + +import nutrient_dws + +EXAMPLES = Path(__file__).resolve().parents[2] / "examples" / "src" +FRAMEWORKS = { + "openai_agents": ("agents",), + "langchain": ("langchain_core", "langchain_openai", "langgraph"), + "crewai": ("crewai",), +} + + +def is_local_socket_address(family, address): + if family == getattr(socket, "AF_UNIX", -1): + return True + if family not in (socket.AF_INET, socket.AF_INET6) or not isinstance( + address, tuple + ): + return False + try: + return ip_address(address[0]).is_loopback + except (IndexError, TypeError, ValueError): + return False + + +def local_only_connect(original_connect): + def guarded_connect(connection, address): + if not is_local_socket_address(connection.family, address): + raise AssertionError( + "Framework smoke tests must not use nonlocal connections" + ) + return original_connect(connection, address) + + return guarded_connect + + +@pytest.fixture +def example_environment(monkeypatch, tmp_path): + for key in ("NUTRIENT_API_KEY", "OPENAI_API_KEY"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("OPENAI_AGENTS_DISABLE_TRACING", "1") + monkeypatch.setenv("OTEL_SDK_DISABLED", "true") + monkeypatch.setenv("CREWAI_TELEMETRY_DISABLED", "true") + monkeypatch.setenv("CREWAI_STORAGE_DIR", str(tmp_path / "crewai")) + if importlib.util.find_spec("crewai_core") is not None: + from crewai_core.token_manager import TokenManager + + token_directory = tmp_path / "crewai-tokens" + token_directory.mkdir(mode=0o700) + monkeypatch.setattr( + TokenManager, + "_get_secure_storage_path", + staticmethod(lambda: token_directory), + ) + monkeypatch.setattr( + dotenv.main, "find_dotenv", lambda *args, **kwargs: str(tmp_path / ".env") + ) + monkeypatch.chdir(tmp_path) + client = SimpleNamespace( + extract_text=AsyncMock( + return_value={"data": {"pages": [{"plainText": "Owned fixture text"}]}} + ), + create_redactions_ai=AsyncMock( + return_value={ + "buffer": b"%PDF-owned-fixture", + "mimeType": "application/pdf", + "filename": "../../untrusted.pdf", + } + ), + ) + constructor = Mock(return_value=client) + monkeypatch.setattr(nutrient_dws, "NutrientClient", constructor) + + monkeypatch.setattr( + socket.socket, "connect", local_only_connect(socket.socket.connect) + ) + return client, constructor + + +def test_network_guard_allows_socketpair(example_environment): + left, right = socket.socketpair() + with left, right: + left.settimeout(2) + right.settimeout(2) + left.sendall(b"owned socketpair") + assert right.recv(32) == b"owned socketpair" + + +def test_network_guard_allows_numeric_loopback_pair(example_environment): + # Exercise the TCP connection used by Windows' socketpair fallback on every OS. + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.settimeout(2) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client: + client.settimeout(2) + client.connect(listener.getsockname()) + peer, _ = listener.accept() + with peer: + peer.settimeout(2) + client.sendall(b"owned loopback") + assert peer.recv(32) == b"owned loopback" + + +@pytest.mark.parametrize( + ("family", "address"), + [ + (socket.AF_INET, ("192.0.2.1", 443)), + (socket.AF_INET, ("provider.example", 443)), + (socket.AF_INET6, ("2001:db8::1", 443, 0, 0)), + ], +) +def test_network_guard_rejects_nonlocal_without_connecting(family, address): + original_connect = Mock() + guarded_connect = local_only_connect(original_connect) + with pytest.raises(AssertionError, match="nonlocal"): + guarded_connect(SimpleNamespace(family=family), address) + original_connect.assert_not_called() + + +def load_example(framework): + for dependency in FRAMEWORKS[framework]: + pytest.importorskip(dependency) + spec = importlib.util.spec_from_file_location( + f"framework_{framework}", EXAMPLES / f"framework_{framework}.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize("framework", FRAMEWORKS) +@pytest.mark.parametrize("missing_key", ["NUTRIENT_API_KEY", "OPENAI_API_KEY"]) +def test_missing_keys_fail_before_client_construction( + framework, missing_key, example_environment, monkeypatch +): + _, constructor = example_environment + other_key = ( + "OPENAI_API_KEY" if missing_key == "NUTRIENT_API_KEY" else "NUTRIENT_API_KEY" + ) + monkeypatch.setenv(other_key, "owned-fixture-key") + with pytest.raises(SystemExit, match=missing_key): + load_example(framework) + constructor.assert_not_called() + + +@pytest.mark.parametrize("framework", FRAMEWORKS) +def test_dotenv_loaded_before_client_construction( + framework, example_environment, tmp_path +): + _, constructor = example_environment + (tmp_path / ".env").write_text( + "NUTRIENT_API_KEY=owned-dws-key\nOPENAI_API_KEY=owned-openai-key\n" + ) + load_example(framework) + constructor.assert_called_once_with(api_key="owned-dws-key") + + +@pytest.mark.parametrize("framework", FRAMEWORKS) +async def test_registered_framework_tools_call_dws( + framework, example_environment, monkeypatch, tmp_path +): + client, _ = example_environment + monkeypatch.setenv("NUTRIENT_API_KEY", "owned-dws-key") + monkeypatch.setenv("OPENAI_API_KEY", "owned-openai-key") + module = load_example(framework) + if framework == "openai_agents": + from agents.tool_context import ToolContext + + registered = module.assistant.tools[0] + arguments = json.dumps({"input_path": "./assets/sample.pdf"}) + context = ToolContext( + context=None, + tool_name=registered.name, + tool_call_id="owned-fixture-call", + tool_arguments=arguments, + ) + result = await registered.on_invoke_tool(context, arguments) + client.extract_text.assert_awaited_once_with("./assets/sample.pdf") + assert "Owned fixture text" in result + elif framework == "langchain": + result = await module.tool.ainvoke({"path": "./assets/sample.pdf"}) + client.create_redactions_ai.assert_awaited_once_with( + "./assets/sample.pdf", + criteria="Redact all email addresses.", + redaction_state="apply", + ) + assert ( + tmp_path / "output" / "redacted.pdf" + ).read_bytes() == b"%PDF-owned-fixture" + assert "redacted.pdf" in result + assert "%PDF" not in result + assert len(result) < 500 + assert not (tmp_path.parent / "untrusted.pdf").exists() + else: + registered = module.document_planner.tools[0] + result = await asyncio.to_thread( + registered.run, input_path="./assets/sample.pdf" + ) + assert isinstance(result, str), ( + "CrewAI must resolve the async DWS call, not return a coroutine" + ) + client.extract_text.assert_awaited_once_with("./assets/sample.pdf") + assert "Owned fixture text" in result + assert "./examples/assets/" not in module.plan_task.description