Repository: coverahealth/dataspec

Issue:
`Spec.with_conformer` never really replaces the default conformer
Builtin `Spec` types often apply default conformers using the `Spec._default_conform` method, which makes it impossible to override the builtin conformer. Furthermore, it's unclear from the existing `Spec.with_conformer` method that you are not able to replace these builtin conformers. I think `dataspec` should make default conformers behave the same way as user-provided confomers and should provide tools to either replace the existing conformers or compose them with an additional function.

In most cases, the `conformer` keyword argument allows users to compose their conformer with the default conformer. While this is perhaps frustrating behavior, it seems like it's the most obvious and desirable behavior, since users will likely want default conformers to apply before they further modify the data.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7339d07..e961abe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   the value spec tags are derived automatically from the corresponding dict keys (#52)
 - Add documentation built using Sphinx and hosted on ReadTheDocs (#9)
 - Add a `regex` validator to the `s.bytes` factory (#37)
+- Added `Spec.compose_conformer` to allow composition of new conformers with existing
+  conformers (#65)
+
+### Changed
+- **Breaking** `Spec.with_conformer` will now replace the default conformer applied
+  to a Spec instance. Previously, most default conformers were applied using the
+  private `Spec._default_conform` method. To emulate the previous behavior, you
+  can use `Spec.compose_conformer`, which will compose your conformer after any
+  existing conformers on a Spec instance and return a copy with that composition.
+  (#65)
 
 ### Fixed
 - Fixed a bug where `s(None)` is not a valid alias for `s(type(None))` (#61)
diff --git a/docs/concepts_and_patterns.rst b/docs/concepts_and_patterns.rst
index fc385be..8392cbf 100644
--- a/docs/concepts_and_patterns.rst
+++ b/docs/concepts_and_patterns.rst
@@ -19,7 +19,7 @@ Composition
 Specs are designed to be composed, so each of the builtin spec types can serve as the
 base for more complex data definitions. For collection, mapping, and tuple type Specs,
 Specs will be recursively created for child elements if they are types understood
-by :py:func:`dataspec.s` .
+by :py:func:`dataspec.s`.
 
 .. _predicates:
 
diff --git a/docs/getting_started.rst b/docs/getting_started.rst
index af5a806..4264be8 100644
--- a/docs/getting_started.rst
+++ b/docs/getting_started.rst
@@ -12,7 +12,7 @@ What are Specs?
 ---------------
 
 Specs are declarative data specifications written in pure Python code. Specs can be
-created using the generic Spec constructor function :py:func:`dataspec.s` . Specs
+created using the generic Spec constructor function :py:func:`dataspec.s`. Specs
 provide two useful and related functions. The first is to evaluate whether an arbitrary
 data structure satisfies the specification. The second function is to conform (or
 normalize) valid data structures into a canonical format.
@@ -148,7 +148,7 @@ also supports using Python's ``Enum`` objects for defining enumerated types.
 
 We did declare two separate Specs and pass both to ``s`` directly. However, we could
 have declared the entire Spec inline and ``s`` would have converted each child value
-into a Spec automatically: ``s([{"CA", "GA", "NY"}, {"kind": list}])`` .
+into a Spec automatically: ``s([{"CA", "GA", "NY"}, {"kind": list}])``.
 
 Building on the previous example, let's suppose we want to validate a simplified
 version of that physician profile object. Spec is great for validating data at your
diff --git a/docs/usage.rst b/docs/usage.rst
index b25cdc8..ae5a43b 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -21,12 +21,12 @@ object:
 :py:func:`dataspec.s` is a generic Spec constructor, which can be called to generate
 new Specs from a variety of sources. It is a singleton instance of
 :py:class:`dataspec.SpecAPI` and nearly all of the factory or convenience methods
-below are available as static methods on :py:func:`dataspec.s` .
+below are available as static methods on :py:func:`dataspec.s`.
 
 Specs are designed to be composed, so each of the spec types below can serve as the
 base for more complex data definitions. For collection, mapping, and tuple type Specs,
 Specs will be recursively created for child elements if they are types understood
-by :py:func:`dataspec.s` .
+by :py:func:`dataspec.s`.
 
 Specs may also optionally be created with :ref:`tags`, which are just string names
 provided in :py:class:`dataspec.ErrorDetails` objects emitted by Spec instance
@@ -101,7 +101,7 @@ by simply passing a Python type directly to the ``s`` constructor:
 
 .. note::
 
-   ``s(None)`` is a shortcut for ``s(type(None))`` .
+   ``s(None)`` is a shortcut for ``s(type(None))``.
 
 .. _factories_usage:
 
@@ -121,7 +121,7 @@ are supplied as `s.is_{type}` on ``s``.
 String Specs
 ^^^^^^^^^^^^
 
-You can create a spec which validates strings with :py:meth:`dataspec.SpecAPI.str` .
+You can create a spec which validates strings with :py:meth:`dataspec.SpecAPI.str`.
 Common string validations can be specified as keyword arguments, such as the min/max
 length or a matching regex. If you are only interested in validating that a value is
 a string without any further validations, spec features the predefined spec
@@ -208,7 +208,7 @@ Email Address and URL Specs
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
 ``dataspec`` features Spec factories for validating email addresses using
-:py:meth:`dataspec.SpecAPI.email` and URLs using :py:meth:`dataspec.SpecAPI.url` .
+:py:meth:`dataspec.SpecAPI.email` and URLs using :py:meth:`dataspec.SpecAPI.url`.
 
 Email addresses are validated using Python's builtin ``email.headerregistry.Address``
 class to parse email addresses into username and domain. For each of ``username`` and
@@ -227,10 +227,10 @@ No more than one keyword filter may be supplied for either of ``username`` or
 
 URLs are validated using Python's builtin ``urllib`` module to parse URLs into their
 constituent components: ``scheme`` , ``netloc`` , ``path`` , ``params`` , ``fragment`` ,
-``username`` , ``password`` , ``hostname``, and ``port`` . URL Specs may optionally
+``username`` , ``password`` , ``hostname``, and ``port``. URL Specs may optionally
 provide a Spec for the ``dict`` created by parsing the query-string (if present) for
 the URL. Specs for each of the components of a URL allow the same filters as described
-above for email addresses. For more information, see :py:meth:`dataspec.SpecAPI.url` .
+above for email addresses. For more information, see :py:meth:`dataspec.SpecAPI.url`.
 
 .. _enumeration_specs:
 
@@ -302,7 +302,7 @@ Specs can be defined for mapping/associative types and objects. To define a spec
 mapping type, pass a dictionary of specs to ``s``. The keys should be the expected key
 value (most often a string) and the value should be the spec for values located in that
 key. If a mapping spec contains a key, the spec considers that key *required*. To
-specify an *optional* key in the spec, wrap the key in :py:meth:`dataspec.SpecAPI.opt` .
+specify an *optional* key in the spec, wrap the key in :py:meth:`dataspec.SpecAPI.opt`.
 Optional keys will be validated if they are present, but allow the map to exclude those
 keys without being considered invalid.
 
@@ -331,7 +331,7 @@ be one of ``"CA"``, ``"GA"``, or ``"NY"``.
 
    To apply the mapping Spec key as the tag of the value Spec, use
    :py:meth:`dataspec.SpecAPI.dict_tag` to construct your mapping Spec. For more
-   precise control over the value Spec tags, prefer :py:func:`dataspec.s` .
+   precise control over the value Spec tags, prefer :py:func:`dataspec.s`.
 
 Mapping specs conform input dictionaries by applying each field's conformer(s) to
 the fields of the input map to return a new dictionary. As a consequence, the value
@@ -415,7 +415,7 @@ since they occur so commonly, ``dataspec`` features a couple of utility Specs fo
 quickly defining these cases. For cases where ``None`` is a valid value, you can wrap
 your Spec with :py:meth:`dataspec.SpecAPI.nilable`. If you are dealing with strings and
 need to allow a blank value (as is often the case when handling CSVs), you can wrap
-your Spec with :py:meth:`dataspec.SpecAPI.blankable` .
+your Spec with :py:meth:`dataspec.SpecAPI.blankable`.
 
 .. code-block:: python
 
@@ -434,7 +434,7 @@ your Spec with :py:meth:`dataspec.SpecAPI.blankable` .
 In certain cases, you may be willing to accept invalid data and overwrite it with a
 default value during conformation. For such cases, you can specify a default value
 whenever the input value does not pass validation for another spec using
-:py:meth:`dataspec.SpecAPI.default` . The value supplied to the ``default`` keyword
+:py:meth:`dataspec.SpecAPI.default`. The value supplied to the ``default`` keyword
 argument will be provided by the conformer if the inner Spec does not validate.
 
 .. code-block:: python
@@ -466,23 +466,33 @@ Conformation
 
 Data validation is only one half of the value proposition for using ``dataspec``. After
 you've validated that data is valid, the next step is to normalize it into a canonical
-format. Conformation is the component of ``dataspec`` that helps you normalize data.
+format. Conformers are functions of one argument that can accept a validated value and
+emit a canonical representation of that value. Conformation is the component of
+``dataspec`` that helps you normalize data.
 
 Every Spec value comes with a default conformer. For most Specs, that conformer simply
-returns the value it was passed, though a few builtin Specs do provide a richer or
+returns the value it was passed, though a few builtin Specs do provide a richer,
 canonicalized version of the input data. For example, :py:meth:`dataspec.SpecAPI.date`
 conforms a date (possibly from a ``strptime`` format string) into a ``date`` object.
-Note that **no** builtin Spec conformers ever modify the data they are passed.
+Note that **none** of the builtin Spec conformers ever modify the data they are passed.
 ``dataspec`` conformers always create new data structures and return the conformed
 values. Custom conformers can modify their data in-flight, but that is not recommended
 since it will be harder reason about failures (in particular, if a mutating conformer
-appeared in the middle of ``s.all(...)`` Spec and a later Spec errored out.
-
-When you create Specs, you can always provide a conformer using the ``conformer``
-keyword argument. This function will be called any time you call
-:py:meth:`dataspec.Spec.conform` on your Spec or any Spec your Spec is a part of.
+appeared in the middle of ``s.all(...)`` Spec and a later Spec produced an error).
 
 Most common Spec workflows will involve validating that your data is, in fact, valid
 using :py:meth:`dataspec.Spec.is_valid` or :py:meth:`dataspec.Spec.validate` for richer
 error details and then calling :py:meth:`dataspec.Spec.conform_valid` if it is valid
-or dealing with the error if not.
\ No newline at end of file
+or dealing with the error if not.
+
+User Provided Conformers
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+When you create Specs, you can always provide a conformer using the ``conformer``
+keyword argument. This function will be called any time you call
+:py:meth:`dataspec.Spec.conform` on your Spec or any Spec your Spec is a part of. The
+``conformer`` keyword argument for :py:func:`dataspec.s` and other builtin factories
+will always apply your conformer as by :py:meth:`dataspec.Spec.compose_conformer` ,
+rather than replacing the default conformer. To have your conformer *completely*
+replace the default conformer (if one is provided), you can use the
+:py:meth:`dataspec.Spec.with_conformer` method on the returned Spec.
\ No newline at end of file
diff --git a/src/dataspec/base.py b/src/dataspec/base.py
index 42e4b74..65ac3fe 100644
--- a/src/dataspec/base.py
+++ b/src/dataspec/base.py
@@ -137,7 +137,7 @@ class Spec(ABC):
     """
     The abstract base class of all Specs.
 
-    Any Spec returned from :py:data:`dataspec.s` will conform to this interface.
+    All Specs returned by :py:data:`dataspec.s` conform to this interface.
     """
 
     @property
@@ -150,39 +150,43 @@ class Spec(ABC):
         """
         raise NotImplementedError
 
-    @property
-    @abstractmethod
-    def _conformer(self) -> Optional[Conformer]:  # pragma: no cover
-        """Return the custom conformer for this Spec."""
-        raise NotImplementedError
-
-    @property
-    def conformer(self) -> Conformer:
-        """Return the conformer attached to this Spec."""
-        return self._conformer or self._default_conform
-
     @abstractmethod
     def validate(self, v: Any) -> Iterator[ErrorDetails]:  # pragma: no cover
         """
-        Validate the value ``v`` against the Spec.
+        Validate the value ``v`` against the Spec, yielding successive Spec failures as
+        :py:class:`dataspec.ErrorDetails` instances, if any.
 
-        Yields an iterable of Spec failures, if any.
+        By definition, if ``next(spec.validate(v))`` raises ``StopIteration``, the
+        first time it is called, the value is considered valid according to the Spec.
+
+        :param v: a value to validate
+        :return: an iterator of Spec failures as :py:class:`dataspec.ErrorDetails`
+            instances, if any
         """
         raise NotImplementedError
 
-    def validate_ex(self, v: Any):
+    def validate_ex(self, v: Any) -> None:
         """
-        Validate the value ``v`` against the Spec.
+        Validate the value ``v`` against the Spec, throwing a
+        :py:class:`dataspec.ValidationError` containing a list of all of the Spec
+        failures for ``v`` , if any. Returns :py:obj:`None` otherwise.
 
-        Throws a ``ValidationError`` with a list of Spec failures, if any.
+        :param v: a value to validate
+        :return: :py:obj:`None`
         """
         errors = list(self.validate(v))
         if errors:
             raise ValidationError(errors)
 
     def is_valid(self, v: Any) -> bool:
-        """Returns True if ``v`` is valid according to the Spec, otherwise returns
-        False."""
+        """
+        Returns :py:obj:`True` if ``v`` is valid according to the Spec, otherwise
+        returns :py:obj:`False`.
+
+        :param v: a value to validate
+        :return: :py:obj:`True` if the value is valid according to the Spec, otherwise
+            :py:obj:`False`
+        """
         try:
             next(self.validate(v))
         except StopIteration:
@@ -190,37 +194,103 @@ class Spec(ABC):
         else:
             return False
 
-    def _default_conform(self, v):
+    @property
+    def conformer(self) -> Optional[Conformer]:  # pragma: no cover
+        """Return the custom conformer attached to this Spec, if one is defined."""
+        return None
+
+    def conform(self, v: Any):
         """
-        Default conformer for the Spec.
+        Conform ``v`` to the Spec, returning the possibly conformed value or an
+        instance of :py:class:`dataspec.Invalid` if the value is invalid cannot
+        be conformed.
+
+        Exceptions arising from calling :py:attr:`dataspec.Spec.conformer` with ``v``
+        will be raised from this method.
 
-        If no custom conformer is specified, this conformer will be invoked.
+        :param v: a value to conform
+        :return: a conformed value or a :py:class:`dataspec.Invalid` instance if the
+            input value could not be conformed
         """
-        return v
+        if self.is_valid(v):
+            return self.conform_valid(v)  # pylint: disable=not-callable
+        else:
+            return INVALID
 
-    def conform_valid(self, v):
+    def conform_valid(self, v: Any):
         """
         Conform ``v`` to the Spec without checking if v is valid first and return the
         possibly conformed value or ``INVALID`` if the value cannot be conformed.
 
         This function should be used only if ``v`` has already been check for validity.
+
+        Exceptions arising from calling :py:attr:`dataspec.Spec.conformer` with ``v``
+        will be raised from this method.
+
+        :param v: a *validated* value to conform
+        :return: a conformed value or a :py:class:`dataspec.Invalid` instance if the
+            input value could not be conformed
         """
-        return self.conformer(v)
+        if self.conformer is None:
+            return v
+        return self.conformer(v)  # pylint: disable=not-callable
 
-    def conform(self, v: Any):
-        """Conform ``v`` to the Spec, first checking if v is valid, and return the
-        possibly conformed value or ``INVALID`` if the value cannot be conformed."""
-        if self.is_valid(v):
-            return self.conform_valid(v)
-        else:
-            return INVALID
+    def compose_conformer(self, conformer: Conformer) -> "Spec":
+        """
+        Return a new Spec instance with a new conformer which is the composition of the
+        ``conformer`` and the current conformer for this Spec instance.
+
+        If the current Spec instance has a custom conformer, this is equivalent to
+        calling ``spec.with_conformer(lambda v: conformer(spec.conformer(v)))``.
+        If the current Spec instance has no custom conformer, this is equivalent to
+        calling :py:meth:`dataspec.Spec.with_conformer` with ``conformer``.
 
-    def with_conformer(self, conformer: Conformer) -> "Spec":
-        """Return a new Spec instance with the new conformer."""
+        To completely replace the conformer for this Spec instance, use
+        :py:meth:`dataspec.Spec.with_conformer`.
+
+        This method does not modify the current Spec instance.
+
+        :param conformer: a conformer to compose with the conformer of the current
+            Spec instance
+        :return: a copy of the current Spec instance with the new composed conformer
+        """
+        existing_conformer = self.conformer
+
+        if existing_conformer is None:
+            return self.with_conformer(conformer)
+
+        def conform_spec(v: T) -> Union[V, Invalid]:
+            assert existing_conformer is not None
+            return conformer(existing_conformer(v))  # pylint: disable=not-callable
+
+        return self.with_conformer(conform_spec)
+
+    def with_conformer(self, conformer: Optional[Conformer]) -> "Spec":
+        """
+        Return a new Spec instance with the new conformer, replacing any custom
+        conformers.
+
+        If ``conformer`` is :py:obj:`None` , the returned Spec will have no custom
+        conformer.
+
+        To return a copy of the current Spec with a composition of the current
+        Spec instance, use :py:meth:`dataspec.Spec.compose_conformer`.
+
+        :param conformer: a conformer to replace the conformer of the current Spec
+            instance or :py:obj:`None` to remove the conformer associated with this
+        :return: a copy of the current Spec instance with new conformer
+        """
         return attr.evolve(self, conformer=conformer)
 
     def with_tag(self, tag: Tag) -> "Spec":
-        """Return a new Spec instance with the new tag applied."""
+        """
+        Return a new Spec instance with the new tag applied.
+
+        This method does not modify the current Spec instance.
+
+        :param tag: a new tag to use for the new Spec
+        :return: a copy of the current Spec instance with the new tag applied
+        """
         return attr.evolve(self, tag=tag)
 
 
@@ -232,7 +302,7 @@ class ValidatorSpec(Spec):
 
     tag: Tag
     _validate: ValidatorFn
-    _conformer: Optional[Conformer] = None
+    conformer: Optional[Conformer] = None
 
     def validate(self, v) -> Iterator[ErrorDetails]:
         try:
@@ -291,7 +361,7 @@ class PredicateSpec(Spec):
 
     tag: Tag
     _pred: PredicateFn
-    _conformer: Optional[Conformer] = None
+    conformer: Optional[Conformer] = None
 
     def validate(self, v) -> Iterator[ErrorDetails]:
         try:
@@ -312,7 +382,7 @@ class PredicateSpec(Spec):
 class CollSpec(Spec):
     tag: Tag
     _spec: Spec
-    _conformer: Optional[Conformer] = None
+    conformer: Optional[Conformer] = None
     _out_type: Optional[Type] = None
     _validate_coll: Optional[ValidatorSpec] = None
 
@@ -427,10 +497,13 @@ class CollSpec(Spec):
         if validators:
             validate_coll = ValidatorSpec.from_validators("coll", *validators)
 
+        def conform_coll(v: Iterable) -> Iterable:
+            return (out_type or type(v))(spec.conform(e) for e in v)  # type: ignore[call-arg]  # noqa
+
         return cls(
             tag or "coll",
             spec=spec,
-            conformer=conformer,
+            conformer=compose_conformers(conform_coll, *filter(None, (conformer,))),
             out_type=out_type,
             validate_coll=validate_coll,
         )
@@ -442,9 +515,6 @@ class CollSpec(Spec):
         for i, e in enumerate(v):
             yield from _enrich_errors(self._spec.validate(e), self.tag, i)
 
-    def _default_conform(self, v):
-        return (self._out_type or type(v))(self._spec.conform(e) for e in v)
-
 
 # In Python 3.6, you cannot inherit directly from Generic with slotted classes:
 # https://github.com/python-attrs/attrs/issues/313
@@ -458,14 +528,14 @@ class DictSpec(Spec):
     tag: Tag
     _reqkeyspecs: Mapping[Any, Spec] = attr.ib(factory=dict)
     _optkeyspecs: Mapping[Any, Spec] = attr.ib(factory=dict)
-    _conformer: Optional[Conformer] = None
+    conformer: Optional[Conformer] = None
 
     @classmethod
     def from_val(
         cls,
         tag: Optional[Tag],
         kvspec: Mapping[str, SpecPredicate],
-        conformer: Conformer = None,
+        conformer: Optional[Conformer] = None,
     ):
         reqkeys = {}
         optkeys: MutableMapping[Any, Spec] = {}
@@ -475,8 +545,22 @@ class DictSpec(Spec):
             else:
                 reqkeys[k] = make_spec(v)
 
+        def conform_mapping(d: Mapping) -> Mapping:
+            conformed_d = {}
+            for k, spec in reqkeys.items():
+                conformed_d[k] = spec.conform(d[k])
+
+            for k, spec in optkeys.items():
+                if k in d:
+                    conformed_d[k] = spec.conform(d[k])
+
+            return conformed_d
+
         return cls(
-            tag or "map", reqkeyspecs=reqkeys, optkeyspecs=optkeys, conformer=conformer
+            tag or "map",
+            reqkeyspecs=reqkeys,
+            optkeyspecs=optkeys,
+            conformer=compose_conformers(conform_mapping, *filter(None, (conformer,))),
         )
 
     def validate(self, d) -> Iterator[ErrorDetails]:  # pylint: disable=arguments-differ
@@ -505,19 +589,23 @@ class DictSpec(Spec):
             if k in d:
                 yield from _enrich_errors(vspec.validate(d[k]), self.tag, k)
 
-    def _default_conform(self, d):  # pylint: disable=arguments-differ
-        conformed_d = {}
-        for k, spec in self._reqkeyspecs.items():
-            conformed_d[k] = spec.conform(d[k])
 
-        for k, spec in self._optkeyspecs.items():
-            if k in d:
-                conformed_d[k] = spec.conform(d[k])
-
-        return conformed_d
+class ObjectSpec(DictSpec):
+    @classmethod
+    def from_val(
+        cls,
+        tag: Optional[Tag],
+        kvspec: Mapping[str, SpecPredicate],
+        conformer: Optional[Conformer] = None,
+    ):
+        """
+        Return a Spec for an arbitrary object instance.
 
+        Overwrite the default conformer provided for ``DictSpec`` s, since it does not
+        make sense for objects.
+        """
+        return super().from_val(tag, kvspec).with_conformer(conformer)
 
-class ObjectSpec(DictSpec):
     def validate(self, o) -> Iterator[ErrorDetails]:  # pylint: disable=arguments-differ
         for k, vspec in self._reqkeyspecs.items():
             if hasattr(o, k):
@@ -535,9 +623,6 @@ class ObjectSpec(DictSpec):
             if hasattr(o, k):
                 yield from _enrich_errors(vspec.validate(getattr(o, k)), self.tag, k)
 
-    def _default_conform(self, o):  # pylint: disable=arguments-differ
-        raise TypeError("Cannot use a default conformer for an Object")
-
 
 def _enum_conformer(e: EnumMeta) -> Conformer:
     """Create a conformer for Enum types which accepts Enum instances, Enum values,
@@ -559,7 +644,7 @@ def _enum_conformer(e: EnumMeta) -> Conformer:
 class SetSpec(Spec):
     tag: Tag
     _values: Union[Set, FrozenSet]
-    _conformer: Optional[Conformer] = None
+    conformer: Optional[Conformer] = None
 
     def validate(self, v) -> Iterator[ErrorDetails]:
         if v not in self._values:
@@ -570,13 +655,29 @@ class SetSpec(Spec):
                 via=[self.tag],
             )
 
+    @classmethod
+    def from_enum(
+        cls, tag: Optional[Tag], pred: EnumMeta, conformer: Optional[Conformer] = None
+    ):
+        return cls(
+            tag or pred.__name__,
+            frozenset(
+                chain.from_iterable(
+                    [mem, mem.name, mem.value] for mem in pred  # type: ignore[var-annotated]  # noqa
+                )
+            ),
+            conformer=compose_conformers(
+                _enum_conformer(pred), *filter(None, (conformer,)),
+            ),
+        )
+
 
 @attr.s(auto_attribs=True, frozen=True, slots=True)
 class TupleSpec(Spec):
     tag: Tag
     _pred: Tuple[SpecPredicate, ...]
     _specs: Tuple[Spec, ...]
-    _conformer: Optional[Conformer] = None
+    conformer: Optional[Conformer] = None
     _namedtuple: Optional[Type[NamedTuple]] = None
 
     @classmethod
@@ -596,11 +697,16 @@ class TupleSpec(Spec):
         else:
             namedtuple_type = None  # type: ignore
 
+        def conform_tuple(v) -> Union[Tuple, NamedTuple]:
+            return ((namedtuple_type and namedtuple_type._make) or tuple)(
+                spec.conform(v) for spec, v in zip(specs, v)
+            )
+
         return cls(
             tag or "tuple",
             pred=pred,
             specs=specs,
-            conformer=conformer,
+            conformer=compose_conformers(conform_tuple, *filter(None, (conformer,))),
             namedtuple=namedtuple_type,  # type: ignore
         )
 
@@ -622,11 +728,6 @@ class TupleSpec(Spec):
                 message=f"Value is not a tuple type", pred=self, value=t, via=[self.tag]
             )
 
-    def _default_conform(self, v):
-        return ((self._namedtuple and self._namedtuple._make) or tuple)(
-            spec.conform(v) for spec, v in zip(self._specs, v)
-        )
-
 
 def _enrich_errors(
     errors: Iterable[ErrorDetails], tag: Tag, loc: Any = NO_ERROR_PATH
@@ -657,7 +758,7 @@ def compose_conformers(*conformers: Conformer) -> Conformer:
         conformed_v = v
         for conform in conformers:
             conformed_v = conform(conformed_v)
-            if conformed_v is INVALID:
+            if conformed_v is INVALID or isinstance(conformed_v, Invalid):
                 break
         return conformed_v
 
@@ -673,17 +774,14 @@ def compose_spec_conformers(
 
     Apply the ``conform_final`` conformer on the final return from the composition, if
     any.
-    """
 
-    def do_conform(v):
-        conformed_v = v
-        for spec in specs:
-            conformed_v = spec.conform(conformed_v)
-            if conformed_v is INVALID:
-                break
-        return conformed_v if conform_final is None else conform_final(conformed_v)
+    This function bypasses the :py:meth:`dataspec.Spec.conform` method and accesses
+    :py:attr:`dataspec.Spec.conformer` directly.
+    """
 
-    return do_conform
+    return compose_conformers(
+        *filter(None, chain((spec.conformer for spec in specs), (conform_final,)))
+    )
 
 
 _MUNGE_NAMES = re.compile(r"[\s|-]")
@@ -785,7 +883,7 @@ def make_spec(  # pylint: disable=inconsistent-return-statements  # noqa: MC0001
 
     Specs may be created from Python types, in which case a Spec will be produced
     that performs an :py:func:`isinstance` check. :py:obj:`None` may be provided as
-    a shortcut for ``type(None)`` . To specify a nilable value, you should use
+    a shortcut for ``type(None)``. To specify a nilable value, you should use
     :py:meth:`dataspec.SpecAPI.nilable` instead.
 
     Specs may be created for enumerated types using a Python ``set`` or ``frozenset``
@@ -816,6 +914,14 @@ def make_spec(  # pylint: disable=inconsistent-return-statements  # noqa: MC0001
     the input tuple being validated and conformed to the corresponding element in the
     Spec.
 
+    Specs may be be created from existing Specs. If an existing :py:class:`datspec.Spec`
+    instance is given, that Spec will be returned without modification. If a tag is
+    given, a new Spec will be created from the existing Spec with the new tag. If a
+    conformer is given, a new Spec will be created from the existing Spec with the new
+    conformer (*replacing* any conformer on the existing Spec, rather than composing).
+    If both a new tag and conformer are given, a new Spec will be returned with both
+    the new tag and conformer.
+
     :param tag: an optional :py:data:`dataspec.Tag` for the resulting spec
     :param pred: a value which can be be converted into a :py:class:`dataspec.Spec`
     :param conformer: an optional :py:data:`dataspec.Conformer` for the value
@@ -831,11 +937,7 @@ def make_spec(  # pylint: disable=inconsistent-return-statements  # noqa: MC0001
     if isinstance(pred, (frozenset, set)):
         return SetSpec(tag or "set", pred, conformer=conformer)
     elif isinstance(pred, EnumMeta):
-        return SetSpec(
-            tag or pred.__name__,
-            frozenset(chain.from_iterable([mem, mem.name, mem.value] for mem in pred)),
-            conformer=conformer or _enum_conformer(pred),
-        )
+        return SetSpec.from_enum(tag, pred, conformer=conformer)
     elif isinstance(pred, tuple):
         return TupleSpec.from_val(tag, pred, conformer=conformer)
     elif isinstance(pred, list):


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
index af391e7..873962e 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -2,7 +2,7 @@ from typing import Iterator
 
 import pytest
 
-from dataspec import ErrorDetails, ValidationError, s
+from dataspec import INVALID, ErrorDetails, Spec, ValidationError, s
 from dataspec.base import PredicateSpec, ValidatorSpec, pred_to_validator
 
 
@@ -40,6 +40,48 @@ class TestSpecConstructor:
     def test_no_signature_for_builtins(self):
         s.all(s.str(), str.istitle)
 
+    def test_no_conformer_default(self):
+        assert s(str).conformer is None
+
+    def test_construct_with_existing_spec_replaces_conformer(self):
+        spec = s(str, conformer=str.upper)
+        assert spec.conformer is str.upper
+        new_spec = s(spec, conformer=str.lower)
+        assert new_spec.conformer is str.lower
+        assert spec.conformer is str.upper
+
+    def test_predicate_exception(self):
+        assert not s(lambda v: int(v) > 0).is_valid("something")
+
+
+class TestSpecConformerComposition:
+    def test_spec_compose_conformer_with_no_default_conformer(self):
+        spec = s.str(regex=r"\d+")
+        assert "1" == spec.conform("1")
+        assert INVALID is spec.conform("abc")
+        spec_w_conformer = spec.compose_conformer(int)
+        assert 1 == spec_w_conformer.conform("1")
+        assert INVALID is spec_w_conformer.conform("abc")
+
+    def test_spec_compose_conformer_with_default_conformer(self):
+        spec = s.str(regex=r"\d+", conformer=int)
+        assert 1 == spec.conform("1")
+        assert INVALID is spec.conform("abc")
+        spec_w_conformer = spec.compose_conformer(lambda x: x * 2)
+        assert 2 == spec_w_conformer.conform("1")
+        assert INVALID is spec_w_conformer.conform("abc")
+
+    @pytest.fixture
+    def coll_spec_with_conformer_kwarg(self) -> Spec:
+        # Test composition with the `s` constructor `conformer` keyword
+        return s([s.str(regex=r"\d+", conformer=int)], conformer=sum)
+
+    def test_coll_spec_with_outer_conformer_from_kwarg(
+        self, coll_spec_with_conformer_kwarg: Spec
+    ):
+        assert 6 == coll_spec_with_conformer_kwarg.conform(["1", "2", "3"])
+        assert INVALID is coll_spec_with_conformer_kwarg.conform(["1", 2, "3"])
+
 
 class TestFunctionSpecs:
     def test_arg_specs(self):
diff --git a/tests/test_base.py b/tests/test_base.py
index 4dc09d6..5acc4f5 100644
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -23,6 +23,22 @@ class TestCollSpecValidation:
             err = e.errors[0]
             assert path == err.path
 
+    @pytest.mark.parametrize("kwargs", [(), [], ("into", "set")])
+    def test_coll_spec_kwargs(self, kwargs):
+        with pytest.raises(TypeError):
+            s([s.str(length=2, conformer=str.upper), kwargs])
+
+    class TestAllowStr:
+        @pytest.fixture
+        def allow_str_spec(self) -> Spec:
+            return s([str.isupper, {"allow_str": True}])
+
+        def test_allow_str_validation(self, allow_str_spec: Spec):
+            assert allow_str_spec.is_valid("ABC")
+
+        def test_kind_validation_failure(self, allow_str_spec: Spec):
+            assert not allow_str_spec.is_valid("ABc")
+
     class TestMinlengthValidation:
         @pytest.fixture
         def minlength_spec(self) -> Spec:
@@ -324,6 +340,7 @@ class TestDictSpecValidation:
     )
     def test_dict_spec_failure(self, dict_spec: Spec, d):
         assert not dict_spec.is_valid(d)
+        assert list(dict_spec.validate(d))
 
     @pytest.mark.parametrize(
         "v,path",
@@ -487,6 +504,14 @@ class TestObjectSpecValidation:
     def test_obj_spec_failure(self, obj_spec: Spec, o):
         assert not obj_spec.is_valid(o)
 
+    def test_missing_and_optional_attrs(self):
+        @attr.s(auto_attribs=True, frozen=True, slots=True)
+        class Spam:
+            id: int
+
+        assert not s.obj({"id": int, "name": str}).is_valid(Spam(15))
+        assert s.obj({"id": int, s.opt("name"): str}).is_valid(Spam(15))
+
 
 class TestSetSpec:
     @pytest.fixture