Chapter 21Lesson 02240–300 min

Dynamic and Hybrid Libraries, Remote Libraries, and Extension Architecture: Guided Hands-On Workflow

Build static, hybrid, and dynamic providers around one synthetic operation, inspect their metadata with Libdoc, then exercise Robot Framework’s real Remote protocol against a loopback-only standard-library XML-RPC fixture.

Local extension labLibdoc metadataLoopback RemoteProcess lifecycleSerialization

Learning objectives

  • Create equivalent static, hybrid, and dynamic providers over one pure Python operation.
  • Use Libdoc to inspect discovery metadata before execution.
  • Run a loopback-only Remote protocol fixture without exposing a public service.
  • Record local/remote results, process lifecycle, serialized values, and cleanup evidence.
  • Choose the simplest provider layer for a new requirement rather than copying architecture mechanically.

Current compatibility baseline — verified 2026-08-31. Robot Framework 7.4.2 is the stable course baseline and requires Python 3.8+. The mandatory examples use Robot Framework's current static, dynamic, hybrid, Libdoc, and built-in Remote interfaces. Dynamic libraries require get_keyword_names and run_keyword; metadata getters are optional but strongly recommended when the proxy knows signatures, types, tags, documentation, or source. Starting with Robot Framework 7.0, dynamic special methods may also be asynchronous, but this chapter keeps the learning path synchronous. Hybrid libraries perform dynamic name discovery but Robot calls Python methods directly. Remote is a dynamic proxy over XML-RPC and does not itself add authentication or transport security. The mandatory Remote exercise therefore binds a disposable protocol fixture only to 127.0.0.1, uses a bounded client timeout, and does not expose a remote-stop operation. The separately maintained robotremoteserver package is optional: its latest published 1.1.1 release explicitly documents Python support through 3.11, so it is not required for this course's Python 3.8+ local path.

1. Lab scenario and ownership model

The lab normalizes synthetic identifiers such as ab 12 → REF:AB-12. That trivial domain operation is intentional: it keeps the business rule constant while the extension architecture changes around it. You will prove the same outcome through static, hybrid, dynamic, and Remote providers.

Create everything under a disposable rf21-extension-lab directory. The Remote server listens only on an operating-system-selected loopback port and writes that port to an evidence file. No external service, container, credential, browser, database, SSH host, or paid platform is involved.

2. Preflight

python --version
python -m robot --version
python -m robot.libdoc --version

# From the lab root after the files below exist:
python -c "import socket; s=socket.socket(); s.bind(('127.0.0.1', 0)); print('ephemeral-port-ok', s.getsockname()[1]); s.close()"

Expected baseline: one Python interpreter, Robot 7.4.2, and the ability to bind an ephemeral loopback port. If corporate endpoint policy blocks local listening sockets, complete the local static/hybrid/dynamic path and use the protocol simulation discussion instead of weakening host firewall policy.

3. Build a small project with explicit layers

rf21-extension-lab/
├─ src/
│  └─ rf21_ext/
│     ├─ __init__.py
│     ├─ domain.py
│     ├─ static_catalog.py
│     ├─ dynamic_catalog.py
│     └─ hybrid_catalog.py
├─ remote_server.py
├─ suites/
│  ├─ local_providers.robot
│  └─ remote_provider.robot
└─ evidence/

domain.py owns the rule. Provider modules own Robot-facing contracts. remote_server.py owns only the loopback protocol process. evidence/ owns generated observations and must not be imported by production code.

4. Create the framework-independent domain function

from __future__ import annotations


def normalize_token(token: str, prefix: str = "ID") -> str:
    """Pure Python domain function shared by every provider."""
    value = token.strip().upper().replace(" ", "-")
    if not value:
        raise ValueError("token must not be empty")
    if not value.replace("-", "").isalnum():
        raise ValueError(f"unsupported token characters: {token!r}")
    clean_prefix = prefix.strip().upper()
    if not clean_prefix.isalpha():
        raise ValueError("prefix must contain letters only")
    return f"{clean_prefix}:{value}"

Test this function with ordinary Python if desired. Nothing in it imports Robot Framework, opens a socket, or knows which provider architecture calls it. That separation is what makes the later comparison meaningful.

5. Add static, dynamic, and hybrid adapters

5.1 Static baseline

from robot.api.deco import keyword, library

from .domain import normalize_token


@library(scope="TEST", version="1.0.0")
class StaticCatalog:
    """Known-at-import keyword surface: the default architecture."""

    @keyword("Normalize Token")
    def normalize_token_keyword(self, token: str, prefix: str = "ID") -> str:
        return normalize_token(token, prefix)

    @keyword("Describe Provider")
    def describe_provider(self) -> str:
        return "static"

This is the preferred baseline. Keyword names and signatures are visible through normal Python reflection.

5.2 Dynamic provider

from __future__ import annotations

from robot.api import logger

from .domain import normalize_token


class DynamicCatalog:
    """Runtime-discovered provider used only to demonstrate the dynamic API."""

    def get_keyword_names(self):
        return ["Normalize Token", "Describe Provider"]

    def get_keyword_arguments(self, name):
        specs = {
            "Normalize Token": ["token", "prefix=ID"],
            "Describe Provider": [],
        }
        return specs[name]

    def get_keyword_types(self, name):
        types = {
            "Normalize Token": {"token": "str", "prefix": "str"},
            "Describe Provider": {},
        }
        return types[name]

    def get_keyword_tags(self, name):
        return ["rf21", "dynamic"]

    def get_keyword_documentation(self, name):
        docs = {
            "__intro__": "Synthetic dynamic provider for Chapter 21.",
            "__init__": "No import arguments.",
            "Normalize Token": "Normalize a synthetic token and return prefix:value.",
            "Describe Provider": "Return the provider architecture name.",
        }
        return docs.get(name, "")

    def run_keyword(self, name, args, kwargs):
        logger.info(f"dynamic dispatch keyword={name}")
        if name == "Normalize Token":
            values = {"token": None, "prefix": "ID"}
            if len(args) > 2:
                raise AssertionError("Normalize Token accepts at most two positional arguments")
            for key, value in zip(("token", "prefix"), args):
                values[key] = value
            values.update(kwargs)
            return normalize_token(values["token"], values["prefix"])
        if name == "Describe Provider":
            return "dynamic"
        raise AssertionError(f"Unknown dynamic keyword: {name}")

Robot learns names and metadata from special methods and sends every call through run_keyword. The dynamic dispatch... log entry gives you proof that this path was actually used.

5.3 Hybrid provider

from .domain import normalize_token


class HybridCatalog:
    """Dynamic discovery with normal Python callables for execution."""

    def get_keyword_names(self):
        # Important: direct method names must be returned in the same form.
        return ["normalize_token", "describe_provider"]

    def normalize_token(self, token: str, prefix: str = "ID") -> str:
        """Normalize a synthetic token."""
        return normalize_token(token, prefix)

    def describe_provider(self) -> str:
        """Return the provider architecture name."""
        return "hybrid"

Only the name list is dynamic. Actual execution uses the normal methods and their annotations/docstrings.

6. Inspect the API surface with Libdoc before running tests

mkdir -p evidence/libdoc

python -m robot.libdoc -P src \
  rf21_ext.static_catalog.StaticCatalog \
  evidence/libdoc/static.html

python -m robot.libdoc -P src \
  rf21_ext.dynamic_catalog.DynamicCatalog \
  evidence/libdoc/dynamic.html

python -m robot.libdoc -P src \
  rf21_ext.hybrid_catalog.HybridCatalog \
  evidence/libdoc/hybrid.html

python -m robot.libdoc -P src rf21_ext.dynamic_catalog.DynamicCatalog list

Before any keyword mutates anything, verify that all three providers advertise Normalize Token and Describe Provider. The dynamic Libdoc should show the argument list and types supplied by metadata. If it accepts “anything” unexpectedly, fix metadata before relying on runtime checks inside run_keyword.

7. Execute all in-process providers through Robot

*** Settings ***
Library    rf21_ext.static_catalog.StaticCatalog    AS    Static
Library    rf21_ext.dynamic_catalog.DynamicCatalog    AS    Dynamic
Library    rf21_ext.hybrid_catalog.HybridCatalog    AS    Hybrid

*** Test Cases ***
Equivalent Providers Return Same Domain Value
    ${static}=     Static.Normalize Token     ab 12    REF
    ${dynamic}=    Dynamic.Normalize Token    ab 12    REF
    ${hybrid}=     Hybrid.Normalize Token     ab 12    REF
    Should Be Equal    ${static}    REF:AB-12
    Should Be Equal    ${dynamic}    ${static}
    Should Be Equal    ${hybrid}     ${static}

Provider Identity Is Observable
    ${static_name}=     Static.Describe Provider
    ${dynamic_name}=    Dynamic.Describe Provider
    ${hybrid_name}=     Hybrid.Describe Provider
    Should Be Equal    ${static_name}     static
    Should Be Equal    ${dynamic_name}    dynamic
    Should Be Equal    ${hybrid_name}     hybrid
mkdir -p evidence/local
python -m robot --pythonpath src \
  --outputdir evidence/local \
  suites/local_providers.robot

Expected evidence: both tests PASS, each provider returns the same normalized value, and the Robot log for the dynamic path contains the explicit dispatch message. No network listener exists yet.

8. Create the loopback Remote protocol fixture

from __future__ import annotations

import argparse
import signal
import threading
from pathlib import Path
from xmlrpc.server import SimpleXMLRPCServer


def normalize_token(token: str, prefix: str = "ID") -> str:
    value = token.strip().upper().replace(" ", "-")
    if not value:
        raise ValueError("token must not be empty")
    if not value.replace("-", "").isalnum():
        raise ValueError(f"unsupported token characters: {token!r}")
    clean_prefix = prefix.strip().upper()
    if not clean_prefix.isalpha():
        raise ValueError("prefix must contain letters only")
    return f"{clean_prefix}:{value}"


def get_keyword_names():
    return ["Normalize Token", "Describe Provider"]


def get_keyword_arguments(name):
    return {
        "Normalize Token": ["token", "prefix=ID"],
        "Describe Provider": [],
    }[name]


def get_keyword_types(name):
    return {
        "Normalize Token": {"token": "str", "prefix": "str"},
        "Describe Provider": {},
    }[name]


def get_keyword_tags(name):
    return ["rf21", "remote", "loopback"]


def get_keyword_documentation(name):
    return {
        "__intro__": "Loopback-only Chapter 21 Remote protocol fixture.",
        "__init__": "The server is started outside Robot and bound to 127.0.0.1.",
        "Normalize Token": "Normalize a synthetic token through XML-RPC.",
        "Describe Provider": "Return the remote provider architecture name.",
    }.get(name, "")


def run_keyword(name, args, kwargs=None):
    kwargs = kwargs or {}
    try:
        if name == "Normalize Token":
            values = {"token": None, "prefix": "ID"}
            for key, value in zip(("token", "prefix"), args):
                values[key] = value
            values.update(kwargs)
            result = normalize_token(values["token"], values["prefix"])
        elif name == "Describe Provider":
            result = "remote"
        else:
            raise ValueError(f"unknown remote keyword: {name}")
        return {
            "status": "PASS",
            "return": result,
            "output": f"*INFO* loopback remote executed {name}\n",
        }
    except Exception as exc:
        return {
            "status": "FAIL",
            "error": f"{type(exc).__name__}: {exc}",
            "traceback": "Synthetic fixture omits implementation traceback.",
        }


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--port-file", required=True)
    args = parser.parse_args()
    port_file = Path(args.port_file)
    port_file.parent.mkdir(parents=True, exist_ok=True)

    # SECURITY BOUNDARY — DO NOT USE 0.0.0.0 in this lab. Bind loopback only.
    server = SimpleXMLRPCServer(("127.0.0.1", 0), allow_none=False, logRequests=False)
    for func in (
        get_keyword_names,
        get_keyword_arguments,
        get_keyword_types,
        get_keyword_tags,
        get_keyword_documentation,
        run_keyword,
    ):
        server.register_function(func)

    port_file.write_text(str(server.server_address[1]), encoding="utf-8")
    print(f"RF21_REMOTE_READY http://127.0.0.1:{server.server_address[1]}", flush=True)

    def request_shutdown(*_):
        threading.Thread(target=server.shutdown, daemon=True).start()

    if hasattr(signal, "SIGTERM"):
        signal.signal(signal.SIGTERM, request_shutdown)
    signal.signal(signal.SIGINT, request_shutdown)
    try:
        server.serve_forever()
    finally:
        server.server_close()


if __name__ == "__main__":
    main()

The fixture intentionally implements the older keyword-specific metadata getters rather than get_library_information. Robot's current Remote client first tries the newer bulk method and falls back to these getters if unavailable. This makes the protocol behavior visible without implementing a production server framework.

Security-sensitive operation. The server is hard-coded to 127.0.0.1 and an ephemeral port. Do not “make it work remotely” by changing the bind address to 0.0.0.0. Public/private network exposure is an architecture decision requiring authentication, authorization, transport protection, firewalling, ownership, and monitoring.

9. Start the server and inspect it before Robot connects

Bash / zsh

mkdir -p evidence/remote
rm -f evidence/remote/port.txt
python remote_server.py --port-file evidence/remote/port.txt \
  > evidence/remote/server.log 2>&1 &
SERVER_PID=$!

for i in 1 2 3 4 5 6 7 8 9 10; do
  test -s evidence/remote/port.txt && break
  sleep 0.2
done
PORT=$(cat evidence/remote/port.txt)
REMOTE_URI="http://127.0.0.1:${PORT}"
printf 'pid=%s\nuri=%s\n' "$SERVER_PID" "$REMOTE_URI" | tee evidence/remote/process.txt
python -c "from xmlrpc.client import ServerProxy; import sys; print(ServerProxy(sys.argv[1]).get_keyword_names())" "$REMOTE_URI"

PowerShell

New-Item -ItemType Directory -Force evidence/remote | Out-Null
Remove-Item evidence/remote/port.txt -ErrorAction SilentlyContinue
$server = Start-Process python `
  -ArgumentList 'remote_server.py','--port-file','evidence/remote/port.txt' `
  -PassThru `
  -RedirectStandardOutput evidence/remote/server.log `
  -RedirectStandardError evidence/remote/server.err

for ($i=0; $i -lt 20 -and -not (Test-Path evidence/remote/port.txt); $i++) { Start-Sleep -Milliseconds 200 }
$port = (Get-Content evidence/remote/port.txt -Raw).Trim()
$remoteUri = "http://127.0.0.1:$port"
"pid=$($server.Id)`nuri=$remoteUri" | Set-Content evidence/remote/process.txt
python -c "from xmlrpc.client import ServerProxy; import sys; print(ServerProxy(sys.argv[1]).get_keyword_names())" $remoteUri

The read-only XML-RPC call should return the two keyword names. Record the exact PID and URI. The port file is configuration provenance, not durable application state.

10. Inspect Remote metadata through Robot tooling

# Bash example after REMOTE_URI is set:
python -m robot.libdoc "Remote::${REMOTE_URI}::5s" evidence/remote/remote-libdoc.html
# PowerShell example after $remoteUri is set:
python -m robot.libdoc "Remote::$remoteUri::5s" evidence/remote/remote-libdoc.html

Libdoc now crosses the XML-RPC boundary. It should show the same two keyword names and their remotely supplied argument/type/tag/documentation metadata. If Libdoc hangs or fails, diagnose the endpoint before running the Robot suite.

11. Execute the Remote provider

*** Settings ***
Library    Remote    ${REMOTE_URI}    5s    AS    LoopbackRemote

*** Variables ***
${REMOTE_URI}    http://127.0.0.1:8270

*** Test Cases ***
Remote Provider Matches Local Contract
    ${value}=    LoopbackRemote.Normalize Token    ab 12    REF
    Should Be Equal    ${value}    REF:AB-12
    ${provider}=    LoopbackRemote.Describe Provider
    Should Be Equal    ${provider}    remote

Bash

python -m robot \
  --variable "REMOTE_URI:${REMOTE_URI}" \
  --outputdir evidence/remote/robot \
  suites/remote_provider.robot

PowerShell

python -m robot `
  --variable "REMOTE_URI:$remoteUri" `
  --outputdir evidence/remote/robot `
  suites/remote_provider.robot

Expected: the Robot suite passes, output.xml records the Remote library call, and the log contains the server-provided INFO message. The value REF:AB-12 crossed XML-RPC as a string; no Python function object or instance state crossed the boundary.

12. Inspect serialized behavior explicitly

from xmlrpc.client import ServerProxy
import sys

server = ServerProxy(sys.argv[1])
print(server.run_keyword("Normalize Token", ["xy 9", "LAB"], {}))

Run that script with the current loopback URI. The response is a plain XML-RPC-compatible dictionary containing status, return, and output. This is the transport contract Robot consumes.

13. Stop the server and prove cleanup

Bash

kill "$SERVER_PID"
wait "$SERVER_PID" 2>/dev/null || true
python -c "from xmlrpc.client import ServerProxy; import sys; ServerProxy(sys.argv[1]).get_keyword_names()" "$REMOTE_URI" \
  && echo "UNEXPECTED: server still reachable" \
  || echo "expected: loopback server stopped"

PowerShell

Stop-Process -Id $server.Id -ErrorAction SilentlyContinue
$server.WaitForExit()
"stopped=$($server.HasExited)" | Add-Content evidence/remote/process.txt

Do not leave a test server listening after the exercise. Process cleanup is part of the lab outcome, not an optional housekeeping step.

14. Challenge: choose the correct layer

Your team wants to load a known set of ten Python keywords from a wheel. A second team wants to expose commands discovered from a device plugin registry at startup. A third must call a .NET implementation owned by another service team. Choose the smallest architecture for each and justify it using discovery, process/language isolation, metadata, security, and operations—not “advanced features.”

A defensible answer is normally static for the known Python wheel, hybrid or dynamic for true plugin discovery depending on dispatch needs, and Remote only for the cross-process/language service if that isolation is genuinely required.

15. Evidence checklist

Evidence Expected observation
Three Libdoc files Equivalent public keyword intent; dynamic metadata explicit
Local Robot output/log/report Static, dynamic, hybrid return same value
Dynamic log message Proves run_keyword dispatch occurred
Port/process record URI begins http://127.0.0.1: and PID is known
Remote Libdoc Metadata obtained across XML-RPC
Remote Robot output/log/report Remote value and server log message preserved
Server log Ready marker only; no public interface bind
Cleanup proof Recorded process stopped after the run

Knowledge check

Why run Libdoc before the Robot suite?

What does the port file own?

Why use an ephemeral loopback port instead of hard-coding 8270?

What proves the Remote call actually crossed a protocol boundary?

Next lesson

Dynamic and Hybrid Libraries, Remote Libraries, and Extension Architecture: Configuration, Design Patterns, and Trade-Offs

Continue with Dynamic and Hybrid Libraries, Remote Libraries, and Extension Architecture: Configuration, Design Patterns, and Trade-Offs. It builds directly on the state, evidence, and operating assumptions established here, so carry those constraints forward rather than treating the next page as an isolated topic.

References and version anchors

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.