"""QR Code Generator Tools.
Generates QR codes for URLs, text, contacts (vCard), WiFi credentials,
email, and batch generation. Supports PNG, SVG, and ASCII output.
"""
from __future__ import annotations
import asyncio
import jsonutil as json
import logging
import io
import tempfile
import os
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from tool_context import ToolContext
try:
import qrcode
from qrcode.image import svg
import qrcode.constants
except ImportError:
qrcode = None
logger = logging.getLogger(__name__)
[docs]
class QRCodeGenerator:
"""Synchronous engine that encodes payloads into QR codes.
Wraps the third-party ``qrcode`` library to turn arbitrary strings into PNG
files, SVG markup, or ASCII art, and builds the structured payload strings
(vCard, WiFi, mailto) that the contact, WiFi, and email tools encode. All
methods here are blocking CPU/IO work, so the async tool handlers run them
off the event loop via ``asyncio.to_thread``.
A single module-level instance ``_qr_gen`` is created at import time when
``qrcode`` is available, and every ``_generate_*`` handler in this file
calls through it. Touches the local filesystem only for PNG output, writing
into a fresh temp directory.
"""
[docs]
def __init__(self):
"""Verify the optional ``qrcode`` dependency is importable.
Guards construction so a ``QRCodeGenerator`` only exists when the
backing library is present; the module-level ``_qr_gen`` singleton is
otherwise left as ``None`` and the tool handlers return a
library-missing error instead.
Raises:
ImportError: If the ``qrcode`` library is not installed.
"""
if qrcode is None:
raise ImportError("qrcode library is required but not installed")
[docs]
def generate_qr_code(
self,
data,
size=10,
border=4,
error_correction="M",
fill_color="black",
back_color="white",
format_type="PNG",
):
"""Encode a string into a QR code in the requested output format.
The single rendering primitive behind every QR tool: it builds a
``qrcode.QRCode`` with an auto-fitted version and the chosen
error-correction level, then emits one of three formats. ASCII returns a
block-character grid inline, SVG returns vector markup inline, and PNG is
written to a freshly created temp directory and returned as a file path
so the caller can attach it.
Runs synchronously and is invoked off the event loop via
``asyncio.to_thread`` by the module-level ``_generate_*`` handlers (for
example ``_generate_qr_code`` and ``_batch_generate_qr``). For PNG output
it touches the filesystem, creating a temp directory and writing
``qrcode.png`` into it; ASCII and SVG stay in memory.
Args:
data: The text, URL, or structured payload to encode.
size: Pixel size of each QR box (``box_size``).
border: Quiet-zone width in boxes around the code.
error_correction: One of ``L``, ``M``, ``Q``, or ``H``;
case-insensitive, defaulting to ``M`` when unrecognized.
fill_color: Foreground color for PNG output.
back_color: Background color for PNG output.
format_type: Output format -- ``PNG``, ``SVG``, or ``ASCII``
(case-insensitive).
Returns:
A result dict with ``success`` true plus the encoded output (one of
``qr_code_ascii``, ``qr_code_svg``, or a ``file_path``), the module
count, error-correction level, format, and ``mime_type``; on failure
a dict with ``success`` false, the ``error`` text, and the original
``data``.
"""
try:
ec_levels = {
"L": qrcode.constants.ERROR_CORRECT_L,
"M": qrcode.constants.ERROR_CORRECT_M,
"Q": qrcode.constants.ERROR_CORRECT_Q,
"H": qrcode.constants.ERROR_CORRECT_H,
}
qr = qrcode.QRCode(
version=None,
error_correction=ec_levels.get(
error_correction.upper(), ec_levels["M"]
),
box_size=size,
border=border,
)
qr.add_data(data)
qr.make(fit=True)
result = {
"success": True,
"data": data,
"size": qr.modules_count,
"error_correction": error_correction.upper(),
"format": format_type.upper(),
}
if format_type.upper() == "ASCII":
lines = []
for row in qr.get_matrix():
lines.append("".join("██" if cell else " " for cell in row))
result["qr_code_ascii"] = "\n".join(lines)
result["mime_type"] = "text/plain"
elif format_type.upper() == "SVG":
img = qr.make_image(image_factory=svg.SvgImage)
buffer = io.BytesIO()
img.save(buffer)
buffer.seek(0)
result["qr_code_svg"] = buffer.getvalue().decode("utf-8")
result["mime_type"] = "image/svg+xml"
else:
img = qr.make_image(fill_color=fill_color, back_color=back_color)
buffer = io.BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
tmp_dir = tempfile.mkdtemp()
filepath = os.path.join(tmp_dir, "qrcode.png")
with open(filepath, "wb") as f:
f.write(buffer.getvalue())
result["file_path"] = filepath
result["mime_type"] = "image/png"
return result
except Exception as e:
logger.error(f"Error generating QR code: {e}")
return {"success": False, "error": str(e), "data": data}
[docs]
def create_vcard_qr(self, contact_info):
"""Build a vCard 3.0 payload string from contact fields.
Assembles the multi-line ``BEGIN:VCARD`` / ``END:VCARD`` block that a
phone's camera recognizes as a saveable contact, emitting only the lines
for fields that are present. Phone and email accept either a single
value or a list, producing one ``TEL`` or ``EMAIL`` line each.
Pure string construction with no IO. Called by ``_generate_contact_qr``,
which feeds the returned vCard into ``generate_qr_code``.
Args:
contact_info: Dict of optional contact fields -- ``first_name``,
``last_name``, ``display_name``, ``phone``, ``email``,
``organization``, ``title``, ``website``.
Returns:
The assembled vCard text as a single newline-joined string.
"""
vcard_lines = ["BEGIN:VCARD", "VERSION:3.0"]
if "first_name" in contact_info or "last_name" in contact_info:
vcard_lines.append(
f"N:{contact_info.get('last_name', '')};{contact_info.get('first_name', '')};;"
)
if "display_name" in contact_info:
vcard_lines.append(f"FN:{contact_info['display_name']}")
for field, prefix in [("phone", "TEL"), ("email", "EMAIL")]:
if field in contact_info:
items = (
contact_info[field]
if isinstance(contact_info[field], list)
else [contact_info[field]]
)
for item in items:
vcard_lines.append(f"{prefix}:{item}")
if "organization" in contact_info:
vcard_lines.append(f"ORG:{contact_info['organization']}")
if "title" in contact_info:
vcard_lines.append(f"TITLE:{contact_info['title']}")
if "website" in contact_info:
vcard_lines.append(f"URL:{contact_info['website']}")
vcard_lines.append("END:VCARD")
return "\n".join(vcard_lines)
[docs]
def create_wifi_qr(self, wifi_info):
"""Build a ``WIFI:`` join-credential payload string.
Produces the standard ``WIFI:T:...;S:...;P:...;;`` string that lets a
phone join a network by scanning the resulting code, filling in the
security type, SSID, and password from the supplied dict. The SSID is
mandatory; everything else has sensible defaults.
Pure string construction with no IO. Called by ``_generate_wifi_qr``,
which encodes the returned string via ``generate_qr_code``.
Args:
wifi_info: Dict with ``ssid`` (required), and optional ``password``
and ``security`` (for example ``WPA``, ``WEP``, ``nopass``).
Returns:
The WIFI credential string, or an ``Error: ...`` message when the
SSID is missing.
"""
ssid = wifi_info.get("ssid", "")
if not ssid:
return "Error: SSID is required for WiFi QR code"
return f"WIFI:T:{wifi_info.get('security', 'WPA').upper()};S:{ssid};P:{wifi_info.get('password', '')};;"
[docs]
def create_email_qr(self, email_info):
"""Build a ``mailto:`` URL with optional subject and body.
Produces the ``mailto:`` link that opens a pre-addressed draft when the
resulting code is scanned, appending URL-encoded ``subject`` and
``body`` query parameters when provided (spaces are escaped to
``%20``). The recipient is mandatory.
Pure string construction with no IO. Called by ``_generate_email_qr``,
which encodes the returned URL via ``generate_qr_code``.
Args:
email_info: Dict with ``to`` (required) and optional ``subject`` and
``body``.
Returns:
The assembled ``mailto:`` URL, or an ``Error: ...`` message when the
recipient is missing.
"""
to = email_info.get("to", "")
if not to:
return "Error: Recipient email address is required"
mailto_url = f"mailto:{to}"
params = []
if email_info.get("subject"):
params.append(f"subject={email_info['subject'].replace(' ', '%20')}")
if email_info.get("body"):
params.append(f"body={email_info['body'].replace(' ', '%20')}")
if params:
mailto_url += "?" + "&".join(params)
return mailto_url
_qr_gen = QRCodeGenerator() if qrcode else None
async def _generate_qr_code(
data: str,
size: int = 10,
border: int = 4,
error_correction: str = "M",
fill_color: str = "black",
back_color: str = "white",
format_type: str = "PNG",
ctx: ToolContext | None = None,
) -> str:
"""Encode arbitrary text into a QR code as a JSON tool result.
The async handler behind the ``generate_qr_code`` tool: it validates that
the library is present and the data is non-empty, clamps the size and border
to safe ranges, and offloads the blocking render to
``QRCodeGenerator.generate_qr_code`` via ``asyncio.to_thread`` so the event
loop is never blocked. For PNG output the underlying call writes a file and
the returned JSON carries its path.
Calls the module-level ``_qr_gen`` singleton; touches the filesystem only
indirectly through that call's PNG path. Dispatched by the tool loader as
the ``generate_qr_code`` handler; not called directly elsewhere in the repo.
Args:
data (str): The text, URL, or payload to encode; whitespace is stripped
and an empty value is rejected.
size (int): QR box size, clamped to 1-40 (default 10).
border (int): Quiet-zone width in boxes, floored at 0 (default 4).
error_correction (str): Level ``L``, ``M``, ``Q``, or ``H`` (default
``M``).
fill_color (str): Foreground color for PNG output (default black).
back_color (str): Background color for PNG output (default white).
format_type (str): Output format ``PNG``, ``SVG``, or ``ASCII`` (default
PNG).
ctx (ToolContext | None): Tool execution context; unused here but part
of the handler signature.
Returns:
str: A JSON string with the render result, or an error JSON when the
library is missing or the data is empty.
"""
if qrcode is None:
return json.dumps({"error": "QR code library not installed"})
if not data or not data.strip():
return json.dumps({"error": "Data cannot be empty"})
result = await asyncio.to_thread(
_qr_gen.generate_qr_code,
data.strip(),
max(1, min(40, size or 10)),
max(0, border or 4),
error_correction or "M",
fill_color or "black",
back_color or "white",
format_type or "PNG",
)
return json.dumps(result, indent=2)
async def _generate_contact_qr(
contact_info: str,
size: int = 10,
format_type: str = "PNG",
ctx: ToolContext | None = None,
) -> str:
"""Encode contact details into a scannable vCard QR code.
The async handler behind the ``generate_contact_qr`` tool: it parses the
JSON contact blob, builds a vCard string via
``QRCodeGenerator.create_vcard_qr``, then renders that string with
``generate_qr_code`` off the event loop. The vCard text is echoed back in
the result alongside the encoded image so the caller can verify it.
Calls the module-level ``_qr_gen`` singleton; touches the filesystem only
indirectly through the PNG render path. Dispatched by the tool loader as the
``generate_contact_qr`` handler; not called directly elsewhere in the repo.
Args:
contact_info (str): JSON object of contact fields (for example
``first_name``, ``email``, ``organization``).
size (int): QR box size, clamped to 1-40 (default 10).
format_type (str): Output format ``PNG``, ``SVG``, or ``ASCII`` (default
PNG).
ctx (ToolContext | None): Tool execution context; unused here but part
of the handler signature.
Returns:
str: A JSON string with the render result and the ``vcard_data`` text,
or an error JSON when the library is missing or the input is not valid
JSON.
"""
if qrcode is None:
return json.dumps({"error": "QR code library not installed"})
try:
contact_data = json.loads(contact_info)
vcard_data = _qr_gen.create_vcard_qr(contact_data)
if vcard_data.startswith("Error"):
return json.dumps({"error": vcard_data})
result = await asyncio.to_thread(
_qr_gen.generate_qr_code,
vcard_data,
max(1, min(40, size or 10)),
format_type=format_type or "PNG",
)
result["vcard_data"] = vcard_data
return json.dumps(result, indent=2)
except json.JSONDecodeError as e:
return json.dumps({"error": f"Invalid JSON: {e}"})
async def _generate_wifi_qr(
wifi_info: str,
size: int = 10,
format_type: str = "PNG",
ctx: ToolContext | None = None,
) -> str:
"""Encode WiFi join credentials into a scannable QR code.
The async handler behind the ``generate_wifi_qr`` tool: it parses the JSON
WiFi blob, builds the ``WIFI:`` credential string via
``QRCodeGenerator.create_wifi_qr``, then renders it with ``generate_qr_code``
off the event loop. The credential string is echoed back in the result so
the caller can confirm what was encoded.
Calls the module-level ``_qr_gen`` singleton; touches the filesystem only
indirectly through the PNG render path. Dispatched by the tool loader as the
``generate_wifi_qr`` handler; not called directly elsewhere in the repo.
Args:
wifi_info (str): JSON object with ``ssid`` (required) and optional
``password`` and ``security``.
size (int): QR box size, clamped to 1-40 (default 10).
format_type (str): Output format ``PNG``, ``SVG``, or ``ASCII`` (default
PNG).
ctx (ToolContext | None): Tool execution context; unused here but part
of the handler signature.
Returns:
str: A JSON string with the render result and the ``wifi_string``, or an
error JSON when the library is missing, the SSID is absent, or the input
is not valid JSON.
"""
if qrcode is None:
return json.dumps({"error": "QR code library not installed"})
try:
wifi_data = json.loads(wifi_info)
wifi_string = _qr_gen.create_wifi_qr(wifi_data)
if wifi_string.startswith("Error"):
return json.dumps({"error": wifi_string})
result = await asyncio.to_thread(
_qr_gen.generate_qr_code,
wifi_string,
max(1, min(40, size or 10)),
format_type=format_type or "PNG",
)
result["wifi_string"] = wifi_string
return json.dumps(result, indent=2)
except json.JSONDecodeError as e:
return json.dumps({"error": f"Invalid JSON: {e}"})
async def _generate_email_qr(
email_info: str,
size: int = 10,
format_type: str = "PNG",
ctx: ToolContext | None = None,
) -> str:
"""Encode a pre-filled email draft into a scannable QR code.
The async handler behind the ``generate_email_qr`` tool: it parses the JSON
email blob, builds a ``mailto:`` URL via
``QRCodeGenerator.create_email_qr``, then renders it with
``generate_qr_code`` off the event loop. The resulting URL is echoed back in
the result so the caller can verify the draft target.
Calls the module-level ``_qr_gen`` singleton; touches the filesystem only
indirectly through the PNG render path. Dispatched by the tool loader as the
``generate_email_qr`` handler; not called directly elsewhere in the repo.
Args:
email_info (str): JSON object with ``to`` (required) and optional
``subject`` and ``body``.
size (int): QR box size, clamped to 1-40 (default 10).
format_type (str): Output format ``PNG``, ``SVG``, or ``ASCII`` (default
PNG).
ctx (ToolContext | None): Tool execution context; unused here but part
of the handler signature.
Returns:
str: A JSON string with the render result and the ``mailto_url``, or an
error JSON when the library is missing, the recipient is absent, or the
input is not valid JSON.
"""
if qrcode is None:
return json.dumps({"error": "QR code library not installed"})
try:
email_data = json.loads(email_info)
mailto_url = _qr_gen.create_email_qr(email_data)
if mailto_url.startswith("Error"):
return json.dumps({"error": mailto_url})
result = await asyncio.to_thread(
_qr_gen.generate_qr_code,
mailto_url,
max(1, min(40, size or 10)),
format_type=format_type or "PNG",
)
result["mailto_url"] = mailto_url
return json.dumps(result, indent=2)
except json.JSONDecodeError as e:
return json.dumps({"error": f"Invalid JSON: {e}"})
async def _batch_generate_qr(
data_list: str,
size: int = 10,
format_type: str = "PNG",
ctx: ToolContext | None = None,
) -> str:
"""Encode a list of strings into many QR codes in one call.
The async handler behind the ``batch_generate_qr`` tool: it parses a JSON
array of strings and renders each one with ``QRCodeGenerator.generate_qr_code``
off the event loop, tagging every result with its ``index`` and skipping
blank or non-string entries with a per-item error rather than failing the
whole batch.
Calls the module-level ``_qr_gen`` singleton once per item; touches the
filesystem indirectly through each PNG render. Dispatched by the tool loader
as the ``batch_generate_qr`` handler; not called directly elsewhere in the
repo.
Args:
data_list (str): JSON array of strings to encode.
size (int): QR box size, clamped to 1-40 (default 10).
format_type (str): Output format ``PNG``, ``SVG``, or ``ASCII`` (default
PNG).
ctx (ToolContext | None): Tool execution context; unused here but part
of the handler signature.
Returns:
str: A JSON string with the per-item results and a ``count``, or an
error JSON when the library is missing, the payload is not a JSON array,
or the input is not valid JSON.
"""
if qrcode is None:
return json.dumps({"error": "QR code library not installed"})
try:
data_items = json.loads(data_list)
if not isinstance(data_items, list):
return json.dumps({"error": "data_list must be a JSON array"})
results = []
for i, data in enumerate(data_items):
if isinstance(data, str) and data.strip():
result = await asyncio.to_thread(
_qr_gen.generate_qr_code,
data.strip(),
max(1, min(40, size or 10)),
format_type=format_type or "PNG",
)
result["index"] = i
results.append(result)
else:
results.append(
{"index": i, "success": False, "error": "Invalid or empty data"}
)
return json.dumps(
{"success": True, "count": len(results), "results": results}, indent=2
)
except json.JSONDecodeError as e:
return json.dumps({"error": f"Invalid JSON: {e}"})
TOOLS = [
{
"name": "generate_qr_code",
"description": "Generate a QR code from text data with customizable size, colors, error correction, and output format (PNG file, SVG, or ASCII).",
"parameters": {
"type": "object",
"properties": {
"data": {
"type": "string",
"description": "The text/URL/data to encode in the QR code.",
},
"size": {
"type": "integer",
"description": "Size of QR code boxes (1-40, default 10).",
},
"border": {
"type": "integer",
"description": "Border width in boxes (default 4).",
},
"error_correction": {
"type": "string",
"description": "Error correction level: L, M, Q, H (default M).",
},
"fill_color": {
"type": "string",
"description": "Foreground color (default black).",
},
"back_color": {
"type": "string",
"description": "Background color (default white).",
},
"format_type": {
"type": "string",
"description": "Output format: PNG, SVG, or ASCII (default PNG).",
},
},
"required": ["data"],
},
"handler": _generate_qr_code,
},
{
"name": "generate_contact_qr",
"description": "Generate a QR code for contact information using vCard format.",
"parameters": {
"type": "object",
"properties": {
"contact_info": {
"type": "string",
"description": "JSON with contact fields: first_name, last_name, display_name, phone, email, organization, title, website.",
},
"size": {
"type": "integer",
"description": "Size of QR code boxes (1-40).",
},
"format_type": {
"type": "string",
"description": "Output format: PNG, SVG, or ASCII.",
},
},
"required": ["contact_info"],
},
"handler": _generate_contact_qr,
},
{
"name": "generate_wifi_qr",
"description": "Generate a QR code for WiFi network connection.",
"parameters": {
"type": "object",
"properties": {
"wifi_info": {
"type": "string",
"description": "JSON with ssid, password, security (WPA/WEP/nopass).",
},
"size": {
"type": "integer",
"description": "Size of QR code boxes (1-40).",
},
"format_type": {
"type": "string",
"description": "Output format: PNG, SVG, or ASCII.",
},
},
"required": ["wifi_info"],
},
"handler": _generate_wifi_qr,
},
{
"name": "generate_email_qr",
"description": "Generate a QR code that opens email client with pre-filled information.",
"parameters": {
"type": "object",
"properties": {
"email_info": {
"type": "string",
"description": "JSON with to, subject, body.",
},
"size": {
"type": "integer",
"description": "Size of QR code boxes (1-40).",
},
"format_type": {
"type": "string",
"description": "Output format: PNG, SVG, or ASCII.",
},
},
"required": ["email_info"],
},
"handler": _generate_email_qr,
},
{
"name": "batch_generate_qr",
"description": "Generate multiple QR codes from a JSON array of data strings.",
"parameters": {
"type": "object",
"properties": {
"data_list": {
"type": "string",
"description": "JSON array of strings to encode.",
},
"size": {
"type": "integer",
"description": "Size of QR code boxes (1-40).",
},
"format_type": {
"type": "string",
"description": "Output format: PNG, SVG, or ASCII.",
},
},
"required": ["data_list"],
},
"handler": _batch_generate_qr,
},
]