Repository: bwhmather/python-validation

Issue:
Add validation function for validating UUIDs

Discussion:

Patch:
diff --git a/validation/__init__.py b/validation/__init__.py
index 5ca0ab8..0f70a6a 100644
--- a/validation/__init__.py
+++ b/validation/__init__.py
@@ -21,6 +21,8 @@ from .datastructure import (
     validate_tuple,
 )
 
+from .uuid import validate_uuid
+
 from .email import validate_email_address
 
 __all__ = [
@@ -29,6 +31,6 @@ __all__ = [
     'validate_date', 'validate_datetime',
     'validate_list', 'validate_set',
     'validate_mapping', 'validate_structure',
-    'validate_tuple',
+    'validate_tuple', 'validate_uuid',
     'validate_email_address',
 ]
diff --git a/validation/uuid.py b/validation/uuid.py
new file mode 100644
index 0000000..b5fcfca
--- /dev/null
+++ b/validation/uuid.py
@@ -0,0 +1,166 @@
+from __future__ import absolute_import
+
+import uuid
+
+from .core import _validate_bool
+from .number import _validate_int
+from .common import make_optional_argument_default
+
+
+_undefined = make_optional_argument_default()
+
+
+def _variant_to_string(variant):
+    return {
+        uuid.RESERVED_NCS: "RESERVED_NCS",
+        uuid.RFC_4122: "RFC_4122",
+        uuid.RESERVED_MICROSOFT: "RESERVED_MICROSOFT",
+        uuid.RESERVED_FUTURE: "RESERVED_FUTURE",
+    }.get(variant, "unknown")
+
+
+def _validate_uuid(
+    value,
+    variant,
+    version,
+    required
+):
+    if value is None:
+        if required:
+            raise TypeError("required value is None")
+        return
+
+    if not isinstance(value, uuid.UUID):
+        raise TypeError((
+            "expected uuid, but value is of type {cls!r}"
+        ).format(cls=value.__class__.__name__))
+
+    if variant is not None and value.variant != variant:
+        raise ValueError((
+            "expected {expected} variant, but uuid variant is {actual}"
+        ).format(
+            expected=_variant_to_string(variant),
+            actual=_variant_to_string(value.variant)
+        ))
+
+    if version is not None and value.version != version:
+        raise ValueError((
+            "expected UUID{expected}, but received UUID{actual}"
+        ).format(expected=version, actual=version))
+
+
+class _uuid_validator(object):
+    def __init__(
+        self,
+        variant,
+        version,
+        required
+    ):
+        if variant is not None and variant not in (
+            uuid.RESERVED_NCS,
+            uuid.RFC_4122,
+            uuid.RESERVED_MICROSOFT,
+            uuid.RESERVED_FUTURE,
+        ):
+            raise ValueError("unknown variant")
+        self.__variant = variant
+
+        _validate_int(version, required=False)
+        if version is not None:
+            if version not in (1, 3, 4, 5):
+                raise ValueError(
+                    "unknown UUID version: {version}".format(version=version)
+                )
+
+            if variant is None:
+                variant = uuid.RFC_4122
+
+            if variant != uuid.RFC_4122:
+                raise ValueError((
+                    "version is specified, but variant is {variant}"
+                ).format(variant=_variant_to_string(variant)))
+        self.__version = version
+
+        _validate_bool(required)
+        self.__required = required
+
+    def __call__(self, value):
+        _validate_uuid(
+            value,
+            variant=self.__variant,
+            version=self.__version,
+            required=self.__required
+        )
+
+    def __repr__(self):
+        args = []
+        if self.__variant is not None:
+            args.append('variant=uuid.{variant}'.format(
+                variant=_variant_to_string(self.__variant),
+            ))
+
+        if self.__version is not None:
+            args.append('version={version!r}'.format(
+                version=self.__version,
+            ))
+
+        if not self.__required:
+            args.append('required={required!r}'.format(
+                required=self.__required,
+            ))
+
+        return 'validate_uuid({args})'.format(args=', '.join(args))
+
+
+def validate_uuid(
+    value=_undefined,
+    variant=None,
+    version=None,
+    required=True,
+):
+    """
+    Checks that the target value is a valid UUID.
+
+    Parameters can be used to narrow down exactly what sort of UUID is
+    expected.
+
+    .. code:: python
+
+        def do_the_thing(identifier):
+            validate_uuid(
+                identifier,
+                variant=uuid.RFC_4122,
+                version=3,
+            )
+
+            # Do something
+            ...
+
+    :param unicode value:
+        The uuid to be validated.
+    :param int variant:
+        The UUID variant determines the internal layout of the UUID. This must
+        be one of `RESERVED_NCS`, `RFC_4122`, `RESERVED_MICROSOFT`, or
+        `RESERVED_FUTURE` from the `uuid` module.
+    :param int version:
+        Can be 1, 3, 4, or 5.
+    :param bool required:
+        Whether the value can be `None`.  Defaults to `True`.
+
+    :raises TypeError:
+        If the value is not a unicode string , or if it was marked as
+        `required` but `None` was passed in.
+    :raises ValueError:
+        If the value was longer or shorter than expected, or did not match
+        the pattern.
+    """
+    validate = _uuid_validator(
+        variant=variant,
+        version=version,
+        required=required,
+    )
+
+    if value is not _undefined:
+        validate(value)
+    else:
+        return validate
diff --git a/validation/uuid.pyi b/validation/uuid.pyi
new file mode 100644
index 0000000..d2d54aa
--- /dev/null
+++ b/validation/uuid.pyi
@@ -0,0 +1,44 @@
+from typing import Union, overload, Callable, Pattern, Optional
+from uuid import UUID
+import six
+
+
+@overload
+def validate_uuid(
+    value: UUID,
+    *,
+    variant: Optional[str] = None,
+    version: Optional[int] = None,
+) -> None:
+    ...
+
+
+@overload
+def validate_uuid(
+    value: Optional[UUID],
+    *,
+    variant: Optional[str] = None,
+    version: Optional[int] = None,
+    required: bool,
+) -> None:
+    ...
+
+
+@overload
+def validate_uuid(
+    *,
+    variant: Optional[str] = None,
+    version: Optional[int] = None,
+) -> Callable[[UUID], None]:
+    ...
+
+
+@overload
+def validate_uuid(
+    *,
+    variant: Optional[str] = None,
+    version: Optional[int] = None,
+    required: bool,
+) -> Callable[[Optional[UUID]], None]:
+    ...
+


Test patch:
diff --git a/validation/tests/__init__.py b/validation/tests/__init__.py
index 9bfb5d4..192cc58 100644
--- a/validation/tests/__init__.py
+++ b/validation/tests/__init__.py
@@ -15,6 +15,7 @@ from . import (
     test_tuple,
     test_optional_argument,
     test_email,
+    test_uuid,
 )  # noqa:
 
 
@@ -34,4 +35,5 @@ suite = unittest.TestSuite((
     loader.loadTestsFromModule(test_tuple),  # type: ignore
     loader.loadTestsFromModule(test_optional_argument),  # type: ignore
     loader.loadTestsFromModule(test_email),  # type: ignore
+    loader.loadTestsFromModule(test_uuid),  # type: ignore
 ))
diff --git a/validation/tests/test_uuid.py b/validation/tests/test_uuid.py
new file mode 100644
index 0000000..1ffc04f
--- /dev/null
+++ b/validation/tests/test_uuid.py
@@ -0,0 +1,64 @@
+import unittest
+import uuid
+
+from validation import validate_uuid
+
+
+class ValidateUUIDTestCase(unittest.TestCase):
+    def test_uuid1_valid(self):
+        validate_uuid(uuid.uuid1())
+
+    def test_uuid1_expected_valid(self):
+        validate_uuid(uuid.uuid1(), version=1)
+
+    def test_uuid1_expected_invalid(self):
+        with self.assertRaises(ValueError):
+            validate_uuid(uuid.uuid4(), version=1)
+
+    def test_uuid3_valid(self):
+        validate_uuid(uuid.uuid3(uuid.uuid4(), "name"))
+
+    def test_uuid3_expected_valid(self):
+        validate_uuid(uuid.uuid3(uuid.uuid4(), "name"), version=3)
+
+    def test_uuid3_expected_invalid(self):
+        with self.assertRaises(ValueError):
+            validate_uuid(uuid.uuid4(), version=3)
+
+    def test_uuid4_valid(self):
+        validate_uuid(uuid.uuid4())
+
+    def test_uuid5_valid(self):
+        validate_uuid(uuid.uuid5(uuid.uuid4(), "name"))
+
+    def test_rfc4122_valid(self):
+        validate_uuid(uuid.uuid4(), variant=uuid.RFC_4122)
+
+    def test_microsoft_invalid(self):
+        with self.assertRaises(ValueError):
+            validate_uuid(uuid.uuid4(), variant=uuid.RESERVED_MICROSOFT)
+
+    def test_incompatible_variant_version(self):
+        with self.assertRaises(ValueError):
+            validate_uuid(variant=uuid.RESERVED_MICROSOFT, version=4)
+
+    def test_not_required(self):
+        validate_uuid(None, required=False)
+
+    def test_required(self):
+        with self.assertRaises(TypeError):
+            validate_uuid(None)
+
+    def test_repr_required_false(self):
+        validator = validate_uuid(required=False)
+        self.assertEqual(
+            repr(validator),
+            'validate_uuid(required=False)',
+        )
+
+    def test_repr_full(self):
+        validator = validate_uuid(variant=uuid.RFC_4122, version=3)
+        self.assertEqual(
+            repr(validator),
+            'validate_uuid(variant=uuid.RFC_4122, version=3)',
+        )