diff --git a/ChangeLog.md b/ChangeLog.md index 3743479..eaa36b8 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -6,6 +6,8 @@ - Removed the exceptions from the library. ([#10](https://github.com/davep/gophermap/pull/10)) +- Added an optional strict mode (and, in doing so, added an exception back). + ([#12](https://github.com/davep/gophermap/pull/12)) ## v0.2.0 diff --git a/src/gophermap/__init__.py b/src/gophermap/__init__.py index 967250f..776644d 100644 --- a/src/gophermap/__init__.py +++ b/src/gophermap/__init__.py @@ -16,6 +16,7 @@ ############################################################################## # Local imports. +from .exceptions import GopherMapError from .gopher_map import GopherMap from .item import GopherItem from .item_type import ItemType @@ -25,6 +26,7 @@ __all__ = [ "GopherItem", "GopherMap", + "GopherMapError", "ItemType", ] diff --git a/src/gophermap/exceptions.py b/src/gophermap/exceptions.py new file mode 100644 index 0000000..7b7970c --- /dev/null +++ b/src/gophermap/exceptions.py @@ -0,0 +1,24 @@ +"""Provides exceptions for the Gopher map.""" + + +############################################################################## +class GopherMapError(Exception): + """Base class for Gopher map errors.""" + + +############################################################################## +class EmptyMap(GopherMapError): + """Raised when a Gopher map is empty.""" + + +############################################################################## +class NoFields(GopherMapError): + """Raised when a Gopher item has no fields.""" + + +############################################################################## +class UnknownItemType(GopherMapError): + """Raised when a Gopher item has an unknown type.""" + + +### exceptions.py ends here diff --git a/src/gophermap/gopher_map.py b/src/gophermap/gopher_map.py index 2502904..3e64ab7 100644 --- a/src/gophermap/gopher_map.py +++ b/src/gophermap/gopher_map.py @@ -8,6 +8,7 @@ ############################################################################## # Local imports. +from .exceptions import EmptyMap from .item import GopherItem ############################################################################## @@ -19,17 +20,19 @@ class GopherMap: """A class for parsing and holding a Gopher map.""" - def __init__(self, map_text: str) -> None: + def __init__(self, map_text: str, strict: bool = False) -> None: """Initialise the Gopher map. Args: map_text: The text of the Gopher map. + strict: Whether to be strict about parsing the Gopher map. """ self._raw = map_text """The raw text of the Gopher map.""" + self._strict = strict + """Whether to be strict about parsing the Gopher map.""" - @staticmethod - def _parse_map(map_text: str) -> Iterator[GopherItem]: + def _parse_map(self, map_text: str) -> Iterator[GopherItem]: """Parse the Gopher map text into a list of Gopher items. Args: @@ -38,10 +41,12 @@ def _parse_map(map_text: str) -> Iterator[GopherItem]: Yields: Gopher items. """ + if self._strict and not map_text: + raise EmptyMap("Gopher map is empty") for line in map_text.splitlines(): if line == EOF: break - yield GopherItem(line) + yield GopherItem(line, self._strict) @property def raw(self) -> str: @@ -50,7 +55,13 @@ def raw(self) -> str: @cached_property def items(self) -> tuple[GopherItem, ...]: - """The list of Gopher items in the map.""" + """The list of Gopher items in the map. + + Raises: + EmptyMap: If the map is empty and strict mode is enabled. + NoFields: If the line is missing a tab character and strict mode is enabled. + UnknownItemType: If the item type is unknown and strict mode is enabled. + """ return tuple(self._parse_map(self._raw)) diff --git a/src/gophermap/item.py b/src/gophermap/item.py index b677b50..6fc9a46 100644 --- a/src/gophermap/item.py +++ b/src/gophermap/item.py @@ -2,6 +2,7 @@ ############################################################################## # Local imports. +from .exceptions import NoFields, UnknownItemType from .item_type import ItemType @@ -9,11 +10,16 @@ class GopherItem: """A class for holding an item in the Gopher map.""" - def __init__(self, line: str) -> None: + def __init__(self, line: str, strict: bool = False) -> None: """Initialise the Gopher item. Args: line: The line of text from the Gopher map. + strict: Whether to be strict about parsing the Gopher item. + + Raises: + NoFields: If the line is missing a tab character and strict mode is enabled. + UnknownItemType: If the item type is unknown and strict mode is enabled. """ self._raw = line """The raw text of the Gopher item.""" @@ -29,6 +35,12 @@ def __init__(self, line: str) -> None: """The host of the Gopher item.""" self._port = int(fields[3]) if len(fields) > 3 and fields[3].isdigit() else 70 """The port of the Gopher item.""" + # If we're in strict mode, let's do some harsh checks. + if strict: + if "\t" not in line: + raise NoFields(f"Line is missing a tab character: {line!r}") + if self._type is ItemType.UNKNOWN: + raise UnknownItemType(f"Unknown item type: {self._type!r}") @property def raw(self) -> str: diff --git a/tests/test_gopher_map.py b/tests/test_gopher_map.py index 8d04fd6..1741cfd 100644 --- a/tests/test_gopher_map.py +++ b/tests/test_gopher_map.py @@ -1,8 +1,12 @@ """Tests for the GopherMap class.""" +############################################################################## +# Pytest imports. +from pytest import mark, raises + ############################################################################## # Local imports. -from gophermap import GopherMap +from gophermap import GopherMap, GopherMapError from gophermap.item_type import ItemType @@ -67,4 +71,19 @@ def test_allow_lines_without_tabs() -> None: assert gopher_map.items[0].port == 70 +############################################################################## +@mark.parametrize( + "test_map", + [ + "", + "Test\r\n.\r\n", + "!Hello\tworld\tlocalhost\r\n.\r\n", + ], +) +def test_strict_on_bad_map(test_map: str) -> None: + """Test that strict mode raises an error on a bad map.""" + with raises(GopherMapError): + _ = GopherMap(test_map, strict=True).items + + ### test_gopher_map.py ends here