Skip to content

Protocol API

API reference for Gopher protocol types.

ItemType

ItemType

Bases: str, Enum

Gopher item types.

Each item in a Gopher directory has a single-character type that indicates what kind of resource it represents. The canonical types are defined in RFC 1436, with additional types added by common extensions.

TEXT class-attribute instance-attribute

TEXT = '0'

Text file (item is a file that may be displayed).

DIRECTORY class-attribute instance-attribute

DIRECTORY = '1'

Gopher directory (item leads to another menu).

CSO class-attribute instance-attribute

CSO = '2'

CSO phone-book server (item refers to a name server).

ERROR class-attribute instance-attribute

ERROR = '3'

Error (item is an error message).

BINHEX class-attribute instance-attribute

BINHEX = '4'

BinHexed Macintosh file.

DOS_BINARY class-attribute instance-attribute

DOS_BINARY = '5'

DOS binary archive (e.g., .zip files).

UUENCODED class-attribute instance-attribute

UUENCODED = '6'

UNIX uuencoded file.

SEARCH class-attribute instance-attribute

SEARCH = '7'

Index-Search server (item refers to a search engine).

TELNET class-attribute instance-attribute

TELNET = '8'

Text-based Telnet session.

BINARY class-attribute instance-attribute

BINARY = '9'

Binary file (client must read until connection closes).

MIRROR class-attribute instance-attribute

MIRROR = '+'

Redundant server / mirror.

GIF class-attribute instance-attribute

GIF = 'g'

GIF image.

IMAGE class-attribute instance-attribute

IMAGE = 'I'

Image file (other than GIF).

TN3270 class-attribute instance-attribute

TN3270 = 'T'

Text-based TN3270 session.

INFO class-attribute instance-attribute

INFO = 'i'

Informational message (not selectable).

HTML class-attribute instance-attribute

HTML = 'h'

HTML file (commonly used for web links).

is_text property

is_text: bool

Check if this item type represents text content.

is_binary property

is_binary: bool

Check if this item type represents binary content.

is_directory property

is_directory: bool

Check if this item type represents a directory listing.

is_search: bool

Check if this item type represents a search service.

is_external property

is_external: bool

Check if this item type refers to an external session.

is_informational property

is_informational: bool

Check if this item type is informational (not selectable).

from_char classmethod

from_char(char: str) -> ItemType

Get ItemType from a single character.

Parameters:

Name Type Description Default
char str

Single character representing the item type.

required

Returns:

Type Description
ItemType

The corresponding ItemType.

Raises:

Type Description
ValueError

If the character is not a known item type.

Source code in src/mototli/protocol/item_types.py
@classmethod
def from_char(cls, char: str) -> "ItemType":
    """Get ItemType from a single character.

    Args:
        char: Single character representing the item type.

    Returns:
        The corresponding ItemType.

    Raises:
        ValueError: If the character is not a known item type.
    """
    for item_type in cls:
        if item_type.value == char:
            return item_type
    raise ValueError(f"Unknown item type: {char!r}")

GopherRequest

GopherRequest dataclass

GopherRequest(
    selector: str,
    search_query: str | None = None,
    request_type: RequestType = RequestType.STANDARD,
    view_type: str | None = None,
    client_ip: str | None = None,
)

Represents a Gopher protocol request.

A Gopher request consists of: - A selector string (path to the resource) - Optional search query (for type 7 search servers) - Optional Gopher+ modifier

The wire format is: selector[TAB search][TAB modifier]CRLF

Attributes:

Name Type Description
selector str

The resource selector (path).

search_query str | None

Optional search query for type 7 servers.

request_type RequestType

Gopher+ request type modifier.

view_type str | None

Gopher+ specific view type to request.

client_ip str | None

IP address of the client (server-side only).

Examples:

>>> request = GopherRequest.from_line(b"/about")
>>> request.selector
'/about'
>>> request = GopherRequest.from_line(b"/search\tpython")
>>> request.search_query
'python'

is_gopher_plus property

is_gopher_plus: bool

Check if this is a Gopher+ request.

__str__

__str__() -> str

Return a human-readable string representation of the request.

Source code in src/mototli/protocol/request.py
def __str__(self) -> str:
    """Return a human-readable string representation of the request."""
    parts = [f"Selector: {self.selector}"]
    if self.search_query:
        parts.append(f"Search: {self.search_query}")
    if self.is_gopher_plus:
        parts.append(f"Type: {self.request_type.name}")
    if self.view_type:
        parts.append(f"View: {self.view_type}")
    return " | ".join(parts)

from_line classmethod

from_line(line: bytes) -> GopherRequest

Parse a Gopher request from a request line.

Parameters:

Name Type Description Default
line bytes

The request line (without trailing CRLF).

required

Returns:

Type Description
GopherRequest

A GopherRequest instance.

Raises:

Type Description
ValueError

If the request line is invalid or too long.

Examples:

>>> request = GopherRequest.from_line(b"/")
>>> request.selector
'/'
>>> request = GopherRequest.from_line(b"/search\tquery\t+")
>>> request.request_type
<RequestType.PLUS: '+'>
Source code in src/mototli/protocol/request.py
@classmethod
def from_line(cls, line: bytes) -> "GopherRequest":
    """Parse a Gopher request from a request line.

    Args:
        line: The request line (without trailing CRLF).

    Returns:
        A GopherRequest instance.

    Raises:
        ValueError: If the request line is invalid or too long.

    Examples:
        >>> request = GopherRequest.from_line(b"/")
        >>> request.selector
        '/'

        >>> request = GopherRequest.from_line(b"/search\\tquery\\t+")
        >>> request.request_type
        <RequestType.PLUS: '+'>
    """
    # Strip any trailing CRLF if present
    line = line.rstrip(CRLF)

    # Check size limit
    if len(line) > MAX_SELECTOR_SIZE:
        raise ValueError(
            f"Request exceeds maximum size: {len(line)} > {MAX_SELECTOR_SIZE}"
        )

    # Decode to string
    try:
        decoded = line.decode("utf-8")
    except UnicodeDecodeError:
        # Fall back to latin-1 for compatibility
        decoded = line.decode("latin-1")

    # Split by tabs
    parts = decoded.split("\t")

    selector = parts[0] if parts else ""
    search_query: str | None = None
    request_type = RequestType.STANDARD
    view_type: str | None = None

    if len(parts) >= 2:
        second = parts[1]
        # Check if it's a Gopher+ modifier or search query
        if second in ("", "+", "!", "$"):
            request_type = RequestType(second) if second else RequestType.STANDARD
        elif second.startswith("+") and len(second) > 1:
            # View type request: +viewtype
            request_type = RequestType.PLUS
            view_type = second[1:]
        else:
            search_query = second

    if len(parts) >= 3:
        third = parts[2]
        if third in ("+", "!", "$"):
            request_type = RequestType(third)
        elif third.startswith("+") and len(third) > 1:
            request_type = RequestType.PLUS
            view_type = third[1:]

    return cls(
        selector=selector,
        search_query=search_query,
        request_type=request_type,
        view_type=view_type,
    )

to_bytes

to_bytes() -> bytes

Serialize the request to bytes for transmission.

Returns:

Type Description
bytes

The request as bytes, ready to send over the network.

Examples:

>>> request = GopherRequest(selector="/about")
>>> request.to_bytes()
b'/about\r\n'
Source code in src/mototli/protocol/request.py
def to_bytes(self) -> bytes:
    """Serialize the request to bytes for transmission.

    Returns:
        The request as bytes, ready to send over the network.

    Examples:
        >>> request = GopherRequest(selector="/about")
        >>> request.to_bytes()
        b'/about\\r\\n'
    """
    parts = [self.selector]

    if self.search_query is not None:
        parts.append(self.search_query)

    if self.request_type != RequestType.STANDARD:
        if self.view_type:
            parts.append(f"+{self.view_type}")
        else:
            parts.append(self.request_type.value)

    return TAB.join(p.encode("utf-8") for p in parts) + CRLF

RequestType

RequestType

Bases: str, Enum

Gopher+ request type modifiers.

These modifiers change the behavior of Gopher+ requests: - STANDARD: Normal Gopher request (no modifier) - PLUS: Request with Gopher+ attributes and content - ATTRIBUTES: Request Gopher+ attributes only (no content) - DIRECTORY: Request directory entry for the item

ATTRIBUTES class-attribute instance-attribute

ATTRIBUTES = '!'

Request Gopher+ attributes only.

DIRECTORY class-attribute instance-attribute

DIRECTORY = '$'

Request directory entry for item.

PLUS class-attribute instance-attribute

PLUS = '+'

Gopher+ request with content and attributes.

STANDARD class-attribute instance-attribute

STANDARD = ''

Normal Gopher request.

GopherResponse

GopherResponse dataclass

GopherResponse(
    items: list[GopherItem] = list(),
    raw_body: bytes | None = None,
    is_directory: bool = True,
    attributes: GopherAttributes | None = None,
)

Represents a complete Gopher protocol response.

A Gopher response can be either: - A directory listing (list of GopherItems) - Raw content (text file, binary, etc.)

Attributes:

Name Type Description
items list[GopherItem]

List of items for directory responses.

raw_body bytes | None

Raw response body for non-directory responses.

is_directory bool

Whether this is a directory listing.

attributes GopherAttributes | None

Gopher+ attributes (if requested).

Examples:

>>> response = GopherResponse(items=[
...     GopherItem(ItemType.TEXT, "Hello", "/hello", "localhost"),
... ])
>>> response.is_directory
True

text property

text: str | None

Get the response body as text (for non-directory responses).

Returns:

Type Description
str | None

The body decoded as UTF-8, or None for directory responses.

__str__

__str__() -> str

Return a human-readable representation.

Source code in src/mototli/protocol/response.py
def __str__(self) -> str:
    """Return a human-readable representation."""
    if self.is_directory:
        return f"GopherDirectory({len(self.items)} items)"
    body_len = len(self.raw_body) if self.raw_body else 0
    return f"GopherResponse({body_len} bytes)"

from_bytes classmethod

from_bytes(
    data: bytes, is_directory: bool = True
) -> GopherResponse

Parse a Gopher response from raw bytes.

Parameters:

Name Type Description Default
data bytes

The raw response data.

required
is_directory bool

Whether to parse as a directory listing.

True

Returns:

Type Description
GopherResponse

A GopherResponse instance.

Examples:

>>> data = b"0Hello\t/hello\tlocalhost\t70\r\n.\r\n"
>>> response = GopherResponse.from_bytes(data)
>>> len(response.items)
1
Source code in src/mototli/protocol/response.py
@classmethod
def from_bytes(cls, data: bytes, is_directory: bool = True) -> GopherResponse:
    """Parse a Gopher response from raw bytes.

    Args:
        data: The raw response data.
        is_directory: Whether to parse as a directory listing.

    Returns:
        A GopherResponse instance.

    Examples:
        >>> data = b"0Hello\\t/hello\\tlocalhost\\t70\\r\\n.\\r\\n"
        >>> response = GopherResponse.from_bytes(data)
        >>> len(response.items)
        1
    """
    if not is_directory:
        return cls(raw_body=data, is_directory=False)

    items: list[GopherItem] = []

    # Split by lines
    lines = data.split(CRLF)

    for line in lines:
        # Skip empty lines and terminator
        if not line or line == b".":
            continue

        try:
            item = GopherItem.from_line(line)
            items.append(item)
        except ValueError:
            # Skip malformed lines
            continue

    return cls(items=items, is_directory=True)

to_bytes

to_bytes() -> bytes

Serialize this response to bytes for transmission.

Returns:

Type Description
bytes

The response as bytes, ready to send over the network.

Source code in src/mototli/protocol/response.py
def to_bytes(self) -> bytes:
    """Serialize this response to bytes for transmission.

    Returns:
        The response as bytes, ready to send over the network.
    """
    if not self.is_directory:
        return self.raw_body or b""

    parts = [item.to_line() for item in self.items]
    return b"".join(parts) + GOPHER_TERMINATOR

GopherItem

GopherItem dataclass

GopherItem(
    item_type: ItemType,
    display_text: str,
    selector: str,
    hostname: str,
    port: int = DEFAULT_PORT,
    gopher_plus: bool = False,
)

Represents a single item in a Gopher directory listing.

Each line in a Gopher directory follows this format: TYPE DISPLAY_TEXT TAB SELECTOR TAB HOSTNAME TAB PORT CRLF

Where TYPE is a single character indicating the item type.

Attributes:

Name Type Description
item_type ItemType

The type of this item (text, directory, etc.).

display_text str

Human-readable text shown to the user.

selector str

Path/selector to request this item.

hostname str

Server hostname where this item is located.

port int

Server port (default 70).

gopher_plus bool

Whether this server supports Gopher+.

Examples:

>>> item = GopherItem(
...     item_type=ItemType.TEXT,
...     display_text="About this server",
...     selector="/about",
...     hostname="example.com",
... )
>>> item.to_line()
b'0About this server\t/about\texample.com\t70\r\n'

is_selectable property

is_selectable: bool

Check if this item can be selected/followed.

__str__

__str__() -> str

Return a human-readable representation.

Source code in src/mototli/protocol/response.py
def __str__(self) -> str:
    """Return a human-readable representation."""
    return f"[{self.item_type.value}] {self.display_text}"

from_line classmethod

from_line(line: bytes) -> GopherItem

Parse a Gopher item from a directory listing line.

Parameters:

Name Type Description Default
line bytes

A single line from a Gopher directory listing.

required

Returns:

Type Description
GopherItem

A GopherItem instance.

Raises:

Type Description
ValueError

If the line is malformed.

Examples:

>>> item = GopherItem.from_line(
...     b"0About\t/about\texample.com\t70\r\n"
... )
>>> item.item_type
<ItemType.TEXT: '0'>
Source code in src/mototli/protocol/response.py
@classmethod
def from_line(cls, line: bytes) -> GopherItem:
    """Parse a Gopher item from a directory listing line.

    Args:
        line: A single line from a Gopher directory listing.

    Returns:
        A GopherItem instance.

    Raises:
        ValueError: If the line is malformed.

    Examples:
        >>> item = GopherItem.from_line(
        ...     b"0About\\t/about\\texample.com\\t70\\r\\n"
        ... )
        >>> item.item_type
        <ItemType.TEXT: '0'>
    """
    # Strip trailing CRLF
    line = line.rstrip(CRLF)

    if not line:
        raise ValueError("Empty line")

    # Decode to string
    try:
        decoded = line.decode("utf-8")
    except UnicodeDecodeError:
        decoded = line.decode("latin-1")

    # First character is the item type
    type_char = decoded[0]
    rest = decoded[1:]

    try:
        item_type = ItemType.from_char(type_char)
    except ValueError:
        # Unknown type - treat as info for compatibility
        item_type = ItemType.INFO

    # Split remaining by tabs
    parts = rest.split("\t")

    if len(parts) < 3:
        # Malformed line - might be info line without full fields
        return cls(
            item_type=item_type,
            display_text=parts[0] if parts else "",
            selector="",
            hostname="",
            port=DEFAULT_PORT,
        )

    display_text = parts[0]
    selector = parts[1]
    hostname = parts[2]

    # Port is optional, default to 70
    port = DEFAULT_PORT
    if len(parts) >= 4 and parts[3]:
        try:
            port = int(parts[3].rstrip("+"))
        except ValueError:
            pass

    # Check for Gopher+ indicator (trailing +)
    gopher_plus = len(parts) >= 4 and parts[3].endswith("+")

    return cls(
        item_type=item_type,
        display_text=display_text,
        selector=selector,
        hostname=hostname,
        port=port,
        gopher_plus=gopher_plus,
    )

to_line

to_line() -> bytes

Serialize this item to a directory listing line.

Returns:

Type Description
bytes

The item as bytes, suitable for a Gopher response.

Source code in src/mototli/protocol/response.py
def to_line(self) -> bytes:
    """Serialize this item to a directory listing line.

    Returns:
        The item as bytes, suitable for a Gopher response.
    """
    port_str = str(self.port)
    if self.gopher_plus:
        port_str += "+"

    parts = [
        f"{self.item_type.value}{self.display_text}",
        self.selector,
        self.hostname,
        port_str,
    ]

    return TAB.join(p.encode("utf-8") for p in parts) + CRLF

GopherAttributes

GopherAttributes dataclass

GopherAttributes(
    info: GopherItem | None = None,
    admin: str | None = None,
    admin_email: str | None = None,
    mod_date: datetime | None = None,
    creation_date: datetime | None = None,
    views: list[ViewInfo] = list(),
    abstract: str | None = None,
    ask_fields: list[AskField] = list(),
    raw: str = "",
)

Gopher+ attribute block.

Contains metadata about a Gopher+ item including administrative information, available views, and descriptions.

Attributes:

Name Type Description
info GopherItem | None

The +INFO line (parsed as GopherItem).

admin str | None

Administrator name/description.

admin_email str | None

Administrator email address.

mod_date datetime | None

Last modification date.

creation_date datetime | None

Creation date.

views list[ViewInfo]

Available views/representations.

abstract str | None

Item description/abstract.

ask_fields list[AskField]

ASK block fields for forms.

raw str

The raw attribute block text.

Examples:

>>> attrs = GopherAttributes.parse('''
... +INFO: 0About this server\t/about\texample.com\t70
... +ADMIN:
...  Admin: John Doe <john@example.com>
... +ABSTRACT:
...  Information about this Gopher server.
... ''')
>>> attrs.abstract
'Information about this Gopher server.'

parse classmethod

parse(block: str) -> GopherAttributes

Parse a Gopher+ attribute block.

Parameters:

Name Type Description Default
block str

The raw attribute block text.

required

Returns:

Type Description
GopherAttributes

A GopherAttributes instance.

Source code in src/mototli/protocol/attributes.py
@classmethod
def parse(cls, block: str) -> GopherAttributes:
    """Parse a Gopher+ attribute block.

    Args:
        block: The raw attribute block text.

    Returns:
        A GopherAttributes instance.
    """
    # Import here to avoid circular import
    from .response import GopherItem

    attrs = cls(raw=block)

    current_section: str | None = None
    section_lines: list[str] = []

    lines = block.split("\n")

    for line in lines:
        # Check for section headers
        if line.startswith("+"):
            # Process previous section
            if current_section:
                _process_section(attrs, current_section, section_lines)

            # Start new section
            if line.startswith(ATTR_INFO):
                current_section = "INFO"
                # INFO line contains data inline
                info_data = line[len(ATTR_INFO) :].strip()
                if info_data:
                    try:
                        attrs.info = GopherItem.from_line(info_data.encode("utf-8"))
                    except ValueError:
                        pass
                section_lines = []
            elif line.startswith(ATTR_ADMIN):
                current_section = "ADMIN"
                section_lines = []
            elif line.startswith(ATTR_VIEWS):
                current_section = "VIEWS"
                section_lines = []
            elif line.startswith(ATTR_ABSTRACT):
                current_section = "ABSTRACT"
                section_lines = []
            elif line.startswith(ATTR_ASK):
                current_section = "ASK"
                section_lines = []
            else:
                # Unknown section
                current_section = None
                section_lines = []
        elif current_section and line.startswith(" "):
            # Continuation line (indented)
            section_lines.append(line[1:])  # Remove leading space

    # Process final section
    if current_section:
        _process_section(attrs, current_section, section_lines)

    return attrs

to_string

to_string() -> str

Serialize this attribute block to a string.

Returns:

Type Description
str

The attribute block as a string.

Source code in src/mototli/protocol/attributes.py
def to_string(self) -> str:
    """Serialize this attribute block to a string.

    Returns:
        The attribute block as a string.
    """
    lines: list[str] = []

    if self.info:
        info_line = self.info.to_line().decode("utf-8").rstrip()
        lines.append(f"{ATTR_INFO} {info_line}")

    if self.admin or self.admin_email:
        lines.append(ATTR_ADMIN)
        if self.admin:
            if self.admin_email:
                lines.append(f" Admin: {self.admin} <{self.admin_email}>")
            else:
                lines.append(f" Admin: {self.admin}")
        if self.mod_date:
            lines.append(f" Mod-Date: {self.mod_date.isoformat()}")

    if self.views:
        lines.append(ATTR_VIEWS)
        for view in self.views:
            lines.append(f" {view.to_string()}")

    if self.abstract:
        lines.append(ATTR_ABSTRACT)
        for abstract_line in self.abstract.split("\n"):
            lines.append(f" {abstract_line}")

    return "\n".join(lines)

ViewInfo

ViewInfo dataclass

ViewInfo(
    mime_type: str,
    language: str | None = None,
    size: str | None = None,
    size_bytes: int | None = None,
)

Represents a single view/representation in Gopher+.

Attributes:

Name Type Description
mime_type str

The MIME type of this view.

language str | None

Optional language code.

size str | None

Optional size in bytes (as string with units like "<10k>").

size_bytes int | None

Parsed size in bytes (if available).

parse classmethod

parse(line: str) -> ViewInfo

Parse a view line from Gopher+ VIEWS block.

Format: MIME/type [language]:

Parameters:

Name Type Description Default
line str

A single view specification line.

required

Returns:

Type Description
ViewInfo

A ViewInfo instance.

Examples:

>>> view = ViewInfo.parse("text/plain: <10k>")
>>> view.mime_type
'text/plain'
Source code in src/mototli/protocol/attributes.py
@classmethod
def parse(cls, line: str) -> ViewInfo:
    """Parse a view line from Gopher+ VIEWS block.

    Format: MIME/type [language]: <size>

    Args:
        line: A single view specification line.

    Returns:
        A ViewInfo instance.

    Examples:
        >>> view = ViewInfo.parse("text/plain: <10k>")
        >>> view.mime_type
        'text/plain'
    """
    line = line.strip()

    # Extract size if present
    size: str | None = None
    size_bytes: int | None = None
    size_match = re.search(r"<([^>]+)>", line)
    if size_match:
        size = size_match.group(1)
        size_bytes = _parse_size(size)
        line = line[: size_match.start()].strip()

    # Split MIME type and language
    parts = line.split()
    mime_type = parts[0].rstrip(":") if parts else "application/octet-stream"
    language = parts[1].rstrip(":") if len(parts) > 1 else None

    return cls(
        mime_type=mime_type,
        language=language,
        size=size,
        size_bytes=size_bytes,
    )

to_string

to_string() -> str

Serialize this view to a string.

Source code in src/mototli/protocol/attributes.py
def to_string(self) -> str:
    """Serialize this view to a string."""
    parts = [self.mime_type]
    if self.language:
        parts.append(self.language)
    result = " ".join(parts) + ":"
    if self.size:
        result += f" <{self.size}>"
    return result

Usage Examples

Working with ItemType

from mototli.protocol import ItemType

# Parse from character
item_type = ItemType.from_char("0")  # ItemType.TEXT
item_type = ItemType.from_char("1")  # ItemType.DIRECTORY

# Check categories
if item_type.is_text:
    print("This is a text file")
elif item_type.is_binary:
    print("This is a binary file")
elif item_type.is_directory:
    print("This is a directory")

# Get character value
print(item_type.value)  # "0", "1", etc.

Working with GopherRequest

from mototli.protocol import GopherRequest, RequestType

# Parse from wire format
request = GopherRequest.from_line(b"/gopher\r\n")
print(request.selector)      # "/gopher"
print(request.request_type)  # RequestType.NORMAL

# Gopher+ requests
request = GopherRequest.from_line(b"/gopher$\r\n")
print(request.request_type)  # RequestType.ATTRIBUTE_ONLY

# Serialize to wire format
data = request.to_bytes()

Working with GopherResponse

from mototli.protocol import GopherResponse

# Parse directory response
data = b"0Text file\t/file.txt\tlocalhost\t70\r\n.\r\n"
response = GopherResponse.from_bytes(data, is_directory=True)

for item in response.items:
    print(f"[{item.item_type.value}] {item.display_text}")
    print(f"  Selector: {item.selector}")
    print(f"  Host: {item.host}:{item.port}")

Working with GopherItem

from mototli.protocol import GopherItem, ItemType

# Create an item
item = GopherItem(
    item_type=ItemType.TEXT,
    display_text="My Text File",
    selector="/file.txt",
    host="localhost",
    port=70
)

# Serialize to gophermap format
line = item.to_line()

Working with Attributes

from mototli.protocol import GopherAttributes

# Parse attribute block
block = b"+INFO: 0file.txt\t/file.txt\tlocalhost\t70\t+\r\n"
block += b"+ADMIN:\r\n Admin: Name <email>\r\n"
attrs = GopherAttributes.parse(block)

print(attrs.info.display_text)
print(attrs.admin.name)
print(attrs.admin.email)

Helper Functions

from mototli.protocol import create_info_item, create_error_item

# Create info line for gophermap
info = create_info_item("Welcome to my site!")

# Create error response
error = create_error_item("File not found")

Constants

from mototli.protocol import (
    DEFAULT_PORT,      # 70
    CRLF,              # b"\r\n"
    GOPHER_TERMINATOR, # b".\r\n"
)

See Also