Source code for tools.qr_generator

"""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, }, ]