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.
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?
It validates the discovery/signature/documentation contract independently of business execution and often exposes missing dynamic metadata earlier.
What does the port file own?
Only runtime provenance for the disposable server endpoint. It is not Robot variable scope or durable workflow state.
Why use an ephemeral loopback port instead of hard-coding 8270?
It avoids collisions while keeping the endpoint private to the local host. 8270 is the conventional default, not a requirement for a disposable fixture.
What proves the Remote call actually crossed a protocol boundary?
The separately running server PID/URI, XML-RPC metadata probe, Remote Libdoc, server-provided log output, and successful result all correlate.
References and version anchors
- Robot Framework 7.4.2 — Dynamic library API — special methods and metadata
- Robot Framework 7.4.2 — Hybrid library API — direct-callable behavior
- Robot Framework 7.4.2 — Remote protocol — XML-RPC contract
- Python xmlrpc.server — standard-library fixture transport
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.