Repository: coverahealth/dataspec

Issue:
`ErrorDetails.via` often includes unexpected values
With builtin Spec factories, the value of `ErrorDetails.via` often includes inner Spec tags that users may not be expecting:

```
>>> s.str("numstr", regex="\d+").validate_all("hey")
[
    ErrorDetails(
        message="String 'hey' does match regex '\\d+'",
        pred=<function str_spec.<locals>.str_matches_regex at 0x1011cd378>,
        value='hey',
        via=['numstr', 'str_matches_regex'],
        path=[]
    )
]
```

The user may have expected `"numstr"` but probably not `"str_matches_regex"`. 

It seems maybe these superfluous tags should be suppressed from `via`, since they are not publicly known and callers cannot react to values they do not know.

Discussion:

Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 68e7d61..ccf5ef0 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -7,7 +7,7 @@ jobs:
     steps:
       - checkout
       - restore_cache:
-          key: deps-v1-{{ .Branch }}-{{ checksum "requirements.txt" }}
+          key: deps-v2-{{ .Branch }}-{{ checksum "requirements.txt" }}
       - run:
           name: Install tox
           shell: /bin/bash -leo pipefail
@@ -23,7 +23,7 @@ jobs:
           command: |
             tox -p $TOX_NUM_CORES
       - save_cache:
-          key: deps-v1-{{ .Branch }}-{{ checksum "requirements.txt" }}
+          key: deps-v2-{{ .Branch }}-{{ checksum "requirements.txt" }}
           paths:
             - "/home/pyenv/.tox"
             - "/usr/local/bin"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 13c48db..1a66036 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   (#65)
 - `s.all` and `s.and` now return the result of calling `s(tag, pred, conformer)` if
   passed only one predicate (#72)
+- **Breaking** `ErrorDetails.via` now only includes user-defined (or default) tags.
+  Previously, Spec factories such as `s.str` would inject tags for child validators
+  such as `str_matches_regex` into `via`, making it difficult to programmatically
+  determine which Spec the input value violated (#78)
 
 ### Fixed
 - Fixed a bug where `s(None)` is not a valid alias for `s(type(None))` (#61)
@@ -33,6 +37,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Fixed a bug where string Spec factory error message for values which do not match
   a regex incorrectly indicates that the string _does_ match the regex (#77)
 
+### Removed
+- **Breaking** Removed `register_str_format_spec`; use `register_str_format` to
+  register new string formats for `s.str(format_="...")` (#78)
+
 ## [v0.2.5] - 2020-04-10
 ### Added
 - Add `SpecPredicate` and `tag_maybe` to the public interface (#49)
diff --git a/docs/api.rst b/docs/api.rst
index 93c074e..3d0c870 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -82,4 +82,4 @@ Utilities
 ---------
 
 .. automodule:: dataspec
-   :members: pred_to_validator, register_str_format, register_str_format_spec, tag_maybe
\ No newline at end of file
+   :members: pred_to_validator, register_str_format, tag_maybe
\ No newline at end of file
diff --git a/docs/concepts_and_patterns.rst b/docs/concepts_and_patterns.rst
index 8392cbf..96321d4 100644
--- a/docs/concepts_and_patterns.rst
+++ b/docs/concepts_and_patterns.rst
@@ -19,7 +19,11 @@ 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:`s() <dataspec.s>`. Specs can be composed using boolean logic with
+:py:func:`s.all() <dataspec.SpecAPI.all>` and :py:func:`s.any() <dataspec.SpecAPI.any>`.
+Many of the builtin factories accept existing specs or values which can be coerced to
+specs. With Dataspec, you can easily start speccing out your code and gradually add
+new specs and build off of existing specs as your app evolves.
 
 .. _predicates:
 
@@ -34,8 +38,8 @@ to encode these predicates in functions which can be combined using lambdas or
 partials to be reused. Spec encourages that functional paradigm and benefits
 directly from it.
 
-Predicate functions should satisfy the :py:data:`PredicateFn` type and can be wrapped
-in the ``PredicateSpec`` spec type.
+Predicate functions should satisfy the :py:data:`dataspec.PredicateFn` type and will be
+wrapped in the ``PredicateSpec`` spec type.
 
 .. _validators:
 
@@ -49,8 +53,8 @@ predicate. Validators are functions of one argument which return 0 or more
 :py:class:`ErrorDetails` instances (typically ``yield`` -ed as a generator) describing
 the error.
 
-Validator functions should satisfy the :py:data:`ValidatorFn` type and can be wrapped
-in the ``ValidatorSpec`` spec type.
+Validator functions should satisfy the :py:data:`dataspec.ValidatorFn` type and will be
+wrapped in the ``ValidatorSpec`` spec type.
 
 .. _conformers:
 
@@ -58,8 +62,10 @@ Conformers
 ^^^^^^^^^^
 
 Conformers are functions of one argument, ``x``, that return either a conformed value,
-which may be ``x`` itself, a new value based on ``x``, or the special Spec value
-``INVALID`` if the value cannot be conformed.
+which may be ``x`` itself, a new value based on ``x``, or an object of type
+:py:class:`Invalid <dataspec.Invalid>` if the value cannot be conformed. Builtin specs
+typically return the constant :py:obj:`INVALID <dataspec.INVALID>`, which allows for
+a quick identity check (via the ``is`` operator) in many cases.
 
 All specs may include conformers. Scalar spec types such as ``PredicateSpec`` and
 ``ValidatorSpec`` simply return their argument if it satisfies the spec. Specs for
@@ -72,9 +78,14 @@ elements.
 Tags
 ^^^^
 
-All Specs can be created with optional tags, specified as a string in the first
-positional argument of any spec creation function. Tags are useful for providing
-useful names for specs in debugging and validation messages.
+Tags are simple string names for specs. Tags most often appear in
+:py:class:`ErrorDetails <dataspec.ErrorDetails>` objects when an input value cannot
+be validated indicating the spec or specs which failed. This is useful for both
+debugging and producing useful user-facing validation messages. All Specs can be
+created with custom tags, which are specified as a string in the first positional
+argument of any spec creation function. Callers are not required to provide tags, but
+tags are *required* on Spec instances so ``dataspec`` provides a default value for
+all builtin spec types.
 
 .. _patterns:
 
@@ -92,13 +103,27 @@ recommended to create a factory function for generating specs consistently. ``da
 uses this pattern for many of the common spec types described above. This encourages
 reuse of commonly used specs and should help enforce consistency across your domain.
 
+.. note::
+
+   If nothing changes between definitions, then consider defining your Spec at the
+   module level instead. Spec instances are immutable and stateless, so they only need
+   to be defined once.
+
 .. _reuse:
 
 Reuse
 ^^^^^
 
-Specs are designed to be immutable, so they may be reused in many different contexts.
-Often, the only thing that changes between uses is the tag or conformer. Specs provide
-a convenient API for generating copies of themselves (not modifying the original) which
-update only the relevant attribute. Additionally, Specs can be combined in many useful
-ways to avoid having to redefine common validations repeatedly.
\ No newline at end of file
+Specs are designed to be immutable and stateless, so they may be reused across many
+different contexts. Often, the only thing that changes between uses is the tag or
+conformer. Specs provide a convenient API for generating copies of themselves with new
+tags and conformers. You can even generate new specs with a composition of the existing
+spec's conformer. The API for creating new copies of specs always returns new copies,
+leaving the existing spec unmodified, so you can safely create copies of specs with
+slight tweaks without fear of unexpected modification.
+
+In an application setting, it may make sense to collocate your common specs in a single
+sub-module or sub-package so they can be easily referred to from other parts of the
+application. We typically do not recommend ``CONSTANT_CASE`` for module-level specs,
+since there tend to be quite a few of them and the all-caps names are more challenging
+to skim.
\ No newline at end of file
diff --git a/docs/usage.rst b/docs/usage.rst
index 3859f55..2a6ba7f 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -11,26 +11,26 @@ Usage
 Constructing Specs
 ------------------
 
-To begin using the ``dataspec`` library, you can simply import the :py:func:`dataspec.s`
+To begin using the ``dataspec`` library, you can simply import the :py:obj:`dataspec.s`
 object:
 
 .. code-block:: python
 
    from dataspec import s
 
-: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:func:`s() <dataspec.s>` is a generic Spec constructor, which can be called to
+construct 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:`s() <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:`s() <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
-:py:meth:`dataspec.Spec.validate` methods. For :py:func:`dataspec.s`, tags may be
+:py:meth:`dataspec.Spec.validate` methods. For :py:func:`s() <dataspec.s>`, tags may be
 provided as the first positional argument. Specs are required to have tags and all
 builtin Spec factories will supply a default tag if one is not given.
 
@@ -85,13 +85,14 @@ emit a canonical representation of that value. Conformation is the component of
 
 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,
-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 **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 produced an error).
+canonicalized version of the input data. For example,
+:py:meth:`s.date() <dataspec.SpecAPI.date>` conforms a date (possibly from a
+``strptime`` format string) into a ``date`` object. 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 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
@@ -106,7 +107,7 @@ 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
+``conformer`` keyword argument for :py:func:`s() <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
@@ -118,8 +119,8 @@ Predicate and Validators
 ------------------------
 
 You can define a spec using any simple predicate you may have by passing the predicate
-directly to the ``s`` function, since not every valid state of your data can be specified
-using existing specs.
+directly to the :py:func:`s() <dataspec.s>` function, since not every valid state of
+your data can be specified using existing specs.
 
 .. code-block:: python
 
@@ -150,7 +151,7 @@ input data is failing to validate.
    spec.validate_ex(-1)  # ValidationError(errors=[ErrorDetails(message="Number must be greater than 0", ...)])
 
 Simple predicates can be converted into validator functions using the builtin
-:py:func:`pred_to_validator` decorator:
+:py:func:`dataspec.pred_to_validator` decorator:
 
 .. code-block:: python
 
@@ -169,7 +170,7 @@ Type Specs
 ----------
 
 You can define a Spec that validates input values are instances of specific class types
-by simply passing a Python type directly to the ``s`` constructor:
+by simply passing a Python type directly to the :py:func:`s() <dataspec.s>` constructor:
 
 .. code-block:: python
 
@@ -187,32 +188,37 @@ Factories
 ---------
 
 The ``s`` API also includes several Spec factories for common Python types such as
-``bool``, ``bytes``, ``date``, ``datetime`` (via ``s.inst``), ``float`` (via ``s.num``),
-``int`` (via ``s.num``), ``str``, ``time``, and ``uuid``.
-
-``s`` also includes several pre-built Specs for basic types which are useful if you
-only want to verify that a value is of a specific type. All the pre-built Specs
-are supplied as `s.is_{type}` on ``s``.
+:py:meth:`bool <dataspec.SpecAPI.bool>`, :py:meth:`bytes <dataspec.SpecAPI.bytes>`,
+:py:meth:`date <dataspec.SpecAPI.date>`, :py:meth:`datetime <dataspec.SpecAPI.inst>`
+(via ``s.inst()``), :py:meth:`float <dataspec.SpecAPI.num>` (via ``s.num()``),
+:py:meth:`int <dataspec.SpecAPI.num>` (via ``s.num()``),
+:py:meth:`str <dataspec.SpecAPI.str>`, :py:meth:`time <dataspec.SpecAPI.time>`, and
+:py:meth:`uuid <dataspec.SpecAPI.uuid>`.
+
+:py:func:`s <dataspec.s>` also includes several pre-built Specs for basic types which
+are useful if you only want to verify that a value is of a specific type. All the
+pre-built Specs are supplied as `s.is_{type}` on ``s``. You can generate a more generic
+type-checking spec using :ref:`type_specs`.
 
 .. _string_specs:
 
 String Specs
 ^^^^^^^^^^^^
 
-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
-``s.is_str`` (note no function call required).
+You can create a spec which validates strings with
+:py:meth:`s.str() <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 ``s.is_str`` (note no function call required).
 
 .. _numeric_specs:
 
 Numeric Specs
 ^^^^^^^^^^^^^
 
-Likewise, numeric specs can be created using :py:meth:`dataspec.SpecAPI.num` , with
-several builtin validations available as keyword arguments such as min/max value and
-narrowing down the specific numeric types. If you are only interested in validating
+Likewise, numeric specs can be created using :py:meth:`s.num() <dataspec.SpecAPI.num>`,
+with several builtin validations available as keyword arguments such as min/max value
+and narrowing down the specific numeric types. If you are only interested in validating
 that a value is numeric, you can use the builtin ``s.is_num`` or ``s.is_int`` or
 ``s.is_float`` specs.
 
@@ -223,7 +229,8 @@ UUID Specs
 
 In a previous section, we used a simple predicate to check that a UUID was a certain
 version of an RFC 4122 variant UUID. However, ``dataspec`` includes the builtin UUID
-spec factory :py:meth:`dataspec.SpecAPI.uuid` which can simplify the logic here:
+spec factory :py:meth:`s.uuid() <dataspec.SpecAPI.uuid>` which can simplify the logic
+here:
 
 .. code-block:: python
 
@@ -259,7 +266,8 @@ want to validate that a value is an instance of any of those classes.
 .. note::
 
    ``dataspec`` supports specs for arbitrary date strings if you have
-   ``python-dateutil`` installed. See :py:meth:`dataspec.SpecAPI.inst_str` for info.
+   ``python-dateutil`` installed. See
+   :py:meth:`s.inst_str() <dataspec.SpecAPI.inst_str>` for info.
 
 .. _phone_number_specs:
 
@@ -267,7 +275,7 @@ Phone Number Specs
 ^^^^^^^^^^^^^^^^^^
 
 ``dataspec`` supports creating Specs for validating telephone numbers from strings
-using :py:meth:`dataspec.SpecAPI.phone` *if you have the*
+using :py:meth:`s.phone() <dataspec.SpecAPI.phone>` *if you have the*
 `phonenumbers <https://github.com/daviddrysdale/python-phonenumbers>`_ *library
 installed*. Telephone number Specs can validate that a telephone number is merely
 formatted correctly or they can validate that a telephone number is both possible
@@ -286,7 +294,8 @@ 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:`s.email() <dataspec.SpecAPI.email>` and URLs using
+:py:meth:`s.url() <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
@@ -308,7 +317,8 @@ constituent components: ``scheme`` , ``netloc`` , ``path`` , ``params`` , ``frag
 ``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:`s.url() <dataspec.SpecAPI.url>`.
 
 .. _enumeration_specs:
 
@@ -380,9 +390,9 @@ 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`.
-Optional keys will be validated if they are present, but allow the map to exclude those
-keys without being considered invalid.
+specify an *optional* key in the spec, wrap the key in
+:py:meth:`s.opt() <dataspec.SpecAPI.opt>`. Optional keys will be validated if they are
+present, but allow the map to exclude those keys without being considered invalid.
 
 .. code-block:: python
 
@@ -408,8 +418,8 @@ be one of ``"CA"``, ``"GA"``, or ``"NY"``.
 .. note::
 
    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`.
+   :py:meth:`s.dict_tag() <dataspec.SpecAPI.dict_tag>` to construct your mapping Spec.
+   For more precise control over the value Spec tags, prefer :py:func:`s() <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
@@ -429,9 +439,10 @@ Specs. In such cases, you may naturally turn to one of the builtin
 Specs composed of mapping Specs with disjoint or only partially intersecting key sets
 will end up producing unexpected results. Recall mapping Specs have a default conformer
 which drops keys not declared in the input Spec, so the chained conformation of
-:py:meth:`dataspec.SpecAPI.all` will drop keys potentially expected by later Specs.
+:py:meth:`s.all() <dataspec.SpecAPI.all>` will drop keys potentially expected by later
+Specs.
 
-To merge mapping Specs, use :py:meth:`dataspec.SpecAPI.merge` instead.
+To merge mapping Specs, use :py:meth:`s.merge() <dataspec.SpecAPI.merge>` instead.
 
 .. code-block:: python
 
@@ -464,7 +475,7 @@ Key/Value Specs
 Mapping Specs are useful for heterogeneous associative data structures for which the
 keys are known *a priori*. However, you may often wish to validate a homogeneous
 mapping with unknown keys. For such cases, you can turn to
-:py:meth:`dataspec.SpecAPI.kv`.
+:py:meth:`s.kv() <dataspec.SpecAPI.kv>`.
 
 .. code-block:: python
 
@@ -516,7 +527,8 @@ Combination Specs
 In most of the previous examples, we used basic builtin Specs. However, real world data
 often more nuanced specifications for data. Fortunately, Specs were designed to be
 composed. In particular, Specs can be composed using standard boolean logic. To specify
-an ``or`` spec, you can use :py:meth:`dataspec.SpecAPI.any` with any ``n`` specs.
+an ``or`` spec, you can use :py:meth:`s.any() <dataspec.SpecAPI.any>` with any ``n``
+specs.
 
 .. code-block:: python
 
@@ -525,8 +537,8 @@ an ``or`` spec, you can use :py:meth:`dataspec.SpecAPI.any` with any ``n`` specs
    spec.is_valid("")            # True
    spec.is_valid("3837273723")  # False
 
-Similarly, to specify an ``and`` spec, you can use :py:meth:`dataspec.SpecAPI.all` with
-any ``n`` specs:
+Similarly, to specify an ``and`` spec, you can use
+:py:meth:`s.all() <dataspec.SpecAPI.all>` with any ``n`` specs:
 
 .. code-block:: python
 
@@ -547,11 +559,11 @@ any ``n`` specs:
 
 .. warning::
 
-   Using a :py:meth:`dataspec.SpecAPI.all` Spec to combine mapping Specs for maps with
-   disjoint or only partially intersecting keys will result in maps losing keys during
-   conformation and failing validation in later Specs.
-   Use :py:meth:`dataspec.SpecAPI.merge` to combine mapping Specs. Read more in
-   :ref:`merging_mapping_specs`.
+   Using a :py:meth:`s.all() <dataspec.SpecAPI.all>` Spec to combine mapping Specs for
+   maps with disjoint or only partially intersecting keys will result in maps losing
+   keys during conformation and failing validation in later Specs.
+   Use :py:meth:`s.merge() <dataspec.SpecAPI.merge>` to combine mapping Specs. Read
+   more in :ref:`merging_mapping_specs`.
 
 .. _utility_specs:
 
@@ -562,9 +574,9 @@ Often when dealing with real world data, you may wish to allow certain values to
 blank or ``None``. We *could* handle these cases with :ref:`combination_specs`, but
 since they occur so commonly, ``dataspec`` features a couple of utility Specs for
 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:`s.nilable() <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:`s.blankable <dataspec.SpecAPI.blankable>`.
 
 .. code-block:: python
 
@@ -583,8 +595,8 @@ 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
-argument will be provided by the conformer if the inner Spec does not validate.
+:py:meth:`s.default <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
 
@@ -601,7 +613,7 @@ argument will be provided by the conformer if the inner Spec does not validate.
    ``s.default``.
 
 Occasionally, it may be useful to allow any value to pass validation. For these cases
-:py:meth:`dataspec.SpecAPI.every` is perfect.
+:py:meth:`s.every() <dataspec.SpecAPI.every>` is perfect.
 
 .. note::
 
diff --git a/src/dataspec/__init__.py b/src/dataspec/__init__.py
index b59eedb..73c12df 100644
--- a/src/dataspec/__init__.py
+++ b/src/dataspec/__init__.py
@@ -13,7 +13,7 @@ from dataspec.base import (
     pred_to_validator,
     tag_maybe,
 )
-from dataspec.factories import register_str_format, register_str_format_spec
+from dataspec.factories import register_str_format
 
 __all__ = [
     "INVALID",
@@ -29,7 +29,6 @@ __all__ = [
     "ValidationError",
     "pred_to_validator",
     "register_str_format",
-    "register_str_format_spec",
     "s",
     "tag_maybe",
 ]
diff --git a/src/dataspec/api.py b/src/dataspec/api.py
index 6331126..dcdca13 100644
--- a/src/dataspec/api.py
+++ b/src/dataspec/api.py
@@ -98,9 +98,12 @@ class SpecAPI:
     __slots__ = ()
 
     def __call__(
-        self, *args: Union[Tag, SpecPredicate], conformer: Optional[Conformer] = None
+        self,
+        tag_or_pred: Union[Tag, SpecPredicate],
+        *preds: SpecPredicate,
+        conformer: Optional[Conformer] = None,
     ) -> Spec:
-        return make_spec(*args, conformer=conformer)
+        return make_spec(tag_or_pred, *preds, conformer=conformer)
 
     __call__.__doc__ = make_spec.__doc__
 
diff --git a/src/dataspec/base.py b/src/dataspec/base.py
index a9e7fb4..6ff3923 100644
--- a/src/dataspec/base.py
+++ b/src/dataspec/base.py
@@ -382,37 +382,22 @@ class ValidatorSpec(Spec):
 
     @classmethod
     def from_validators(
-        cls,
-        tag: Tag,
-        *preds: Union[ValidatorFn, "ValidatorSpec"],
-        conformer: Optional[Conformer] = None,
+        cls, tag: Tag, *preds: ValidatorFn, conformer: Optional[Conformer] = None,
     ) -> Spec:
         """Return a single Validator spec from the composition of multiple validator
-        functions or ValidatorSpec instances."""
+        functions."""
         assert len(preds) > 0, "At least on predicate must be specified"
 
-        # Avoid wrapping an existing validator spec or singleton spec in an extra layer
-        # of indirection
+        # Avoid wrapping an existing validator function in an extra layer of
+        # indirection
         if len(preds) == 1:
-            if isinstance(preds[0], ValidatorSpec):
-                return preds[0]
-            else:
-                return ValidatorSpec(tag, preds[0], conformer=conformer)
-
-        specs = []
-        for pred in preds:
-            if isinstance(pred, ValidatorSpec):
-                specs.append(pred)
-            else:
-                specs.append(ValidatorSpec(pred.__name__, pred))
+            return ValidatorSpec(tag, preds[0], conformer=conformer)
 
         def do_validate(v) -> Iterator[ErrorDetails]:
-            for spec in specs:
-                yield from spec.validate(v)
+            for pred in preds:
+                yield from pred(v)
 
-        return cls(
-            tag, do_validate, compose_spec_conformers(*specs, conform_final=conformer)
-        )
+        return cls(tag, do_validate, conformer=conformer)
 
 
 @attr.s(auto_attribs=True, frozen=True, slots=True)
@@ -572,7 +557,7 @@ class CollSpec(Spec):
         return cls(
             tag or "coll",
             spec=spec,
-            conformer=compose_conformers(conform_coll, *filter(None, (conformer,))),
+            conformer=compose_conformers(conform_coll, conformer),
             out_type=out_type,
             validate_coll=validate_coll,
         )
@@ -635,7 +620,7 @@ class DictSpec(Spec):
             tag or "map",
             reqkeyspecs=reqkeys,
             optkeyspecs=optkeys,
-            conformer=compose_conformers(conform_mapping, *filter(None, (conformer,))),
+            conformer=compose_conformers(conform_mapping, conformer),
         )
 
     def validate(self, d) -> Iterator[ErrorDetails]:  # pylint: disable=arguments-differ
@@ -760,9 +745,7 @@ def kv_spec(
             return {k: valspec.conform(v) for k, v in d.items()}
 
     return ValidatorSpec(
-        tag,
-        _kv_valid,
-        conformer=compose_conformers(conform_mapping, *filter(None, (conformer,))),
+        tag, _kv_valid, conformer=compose_conformers(conform_mapping, conformer),
     )
 
 
@@ -863,9 +846,7 @@ class SetSpec(Spec):
                     [mem, mem.name, mem.value] for mem in pred  # type: ignore[var-annotated]  # noqa
                 )
             ),
-            conformer=compose_conformers(
-                _enum_conformer(pred), *filter(None, (conformer,)),
-            ),
+            conformer=compose_conformers(_enum_conformer(pred), conformer,),
         )
 
 
@@ -906,7 +887,7 @@ class TupleSpec(Spec):
             tag or "tuple",
             pred=pred,
             specs=specs,
-            conformer=compose_conformers(conform_tuple, *filter(None, (conformer,))),
+            conformer=compose_conformers(conform_tuple, conformer),
             namedtuple=namedtuple_type,  # type: ignore
         )
 
@@ -944,13 +925,18 @@ def _enrich_errors(
         yield error.with_details(tag, loc=loc)
 
 
-def compose_conformers(*conformers: Conformer) -> Conformer:
+def compose_conformers(*conformers: Optional[Conformer]) -> Optional[Conformer]:
     """
     Return a single conformer which is the composition of the input conformers.
 
     If a single conformer is given, return the conformer.
     """
 
+    conformers = tuple(filter(None, conformers))
+
+    if not conformers:
+        return None
+
     if len(conformers) == 1:
         return conformers[0]
 
@@ -965,25 +951,6 @@ def compose_conformers(*conformers: Conformer) -> Conformer:
     return do_conform
 
 
-def compose_spec_conformers(
-    *specs: Spec, conform_final: Optional[Conformer] = None
-) -> Conformer:
-    """
-    Return a single conformer which is the composition of the conformers from each of
-    the child specs.
-
-    Apply the ``conform_final`` conformer on the final return from the composition, if
-    any.
-
-    This function bypasses the :py:meth:`dataspec.Spec.conform` method and accesses
-    :py:attr:`dataspec.Spec.conformer` directly.
-    """
-
-    return compose_conformers(
-        *filter(None, chain((spec.conformer for spec in specs), (conform_final,)))
-    )
-
-
 def all_spec(
     tag_or_pred: Union[Tag, SpecPredicate],
     *preds: SpecPredicate,
@@ -1013,8 +980,9 @@ def all_spec(
     This method is not suitable for producing a union of mapping Specs. To merge
     mapping Specs, use :py:meth:`dataspec.SpecAPI.merge` instead.
 
-    :param tag_or_pred: an optional tag for the resulting spec or the first Spec
-        or value which can be converted into a Spec
+    :param tag_or_pred: an optional tag for the resulting spec or the first Spec or
+        value which can be converted into a Spec; if no tag is provided, the default is
+        ``"all"``
     :param preds: zero or more Specs or values which can be converted into a Spec
     :param conformer: an optional conformer which will be applied to the final
         conformed value produced by the input Specs conformers
@@ -1026,7 +994,11 @@ def all_spec(
         raise ValueError("Must provide at least one Spec for 'all' Specs")
 
     if len(preds) == 1:
-        return make_spec(*filter(None, (tag,)), preds[0], conformer=conformer)
+        return make_spec(
+            *filter(None, (tag,)),  # type: ignore[arg-type]  # noqa: F821
+            preds[0],
+            conformer=conformer,
+        )
 
     specs = [make_spec(pred) for pred in preds]
 
@@ -1045,7 +1017,7 @@ def all_spec(
     return ValidatorSpec(
         tag or "all",
         _all_valid,
-        conformer=compose_spec_conformers(*specs, conform_final=conformer),
+        conformer=compose_conformers(*(spec.conformer for spec in specs), conformer,),
     )
 
 
@@ -1083,8 +1055,9 @@ def any_spec(
     :py:func:`dataspec.s` with the given ``tag`` and ``conformer`` and the value
     returned without merging.
 
-    :param tag_or_pred: an optional tag for the resulting spec or the first Spec
-        or value which can be converted into a Spec
+    :param tag_or_pred: an optional tag for the resulting spec or the first Spec or
+        value which can be converted into a Spec; if no tag is provided, the default is
+        ``"any"``
     :param preds: zero or more Specs or values which can be converted into a Spec
     :param tag_conformed: if :py:obj:`True`, the conformed value will be wrapped in a
         2-tuple where the first element is the successful spec and the second element
@@ -1098,7 +1071,11 @@ def any_spec(
         raise ValueError("Must provide at least one Spec for 'any' Specs")
 
     if len(preds) == 1:
-        return make_spec(*filter(None, (tag,)), preds[0], conformer=conformer)
+        return make_spec(
+            *filter(None, (tag,)),  # type: ignore[arg-type]  # noqa: F821
+            preds[0],
+            conformer=conformer,
+        )
 
     specs = [make_spec(pred) for pred in preds]
 
@@ -1160,8 +1137,9 @@ def merge_spec(
     input Specs in the order they were provided. Values with exactly one Spec will
     use that Spec as given.
 
-    :param tag_or_pred: an optional tag for the resulting spec or the first Spec
-        or value which can be converted into a Spec
+    :param tag_or_pred: an optional tag for the resulting spec or the first Spec or
+        value which can be converted into a Spec; if no tag is provided, the default is
+        computed as ``"merge-of-spec1-and-spec2-..."``
     :param preds: zero or more mapping Specs or values which can be converted into a
         mapping Spec
     :param conformer: an optional conformer for the value
@@ -1173,7 +1151,11 @@ def merge_spec(
         raise ValueError("Must provide at least one mapping Spec to merge")
 
     if len(preds) == 1:
-        return make_spec(*filter(None, (tag,)), preds[0], conformer=conformer)
+        return make_spec(
+            *filter(None, (tag,)),  # type: ignore[arg-type]  # noqa: F821
+            preds[0],
+            conformer=conformer,
+        )
 
     specs = [make_spec(pred) for pred in preds]
 
@@ -1269,7 +1251,9 @@ def type_spec(
 
 
 def make_spec(  # pylint: disable=inconsistent-return-statements  # noqa: MC0001
-    *args: Union[Tag, SpecPredicate], conformer: Optional[Conformer] = None
+    tag_or_pred: Union[Tag, SpecPredicate],
+    *preds: SpecPredicate,
+    conformer: Optional[Conformer] = None,
 ) -> Spec:
     """
     Create a new Spec instance from a :py:obj:`dataspec.base.SpecPredicate`.
@@ -1322,17 +1306,38 @@ def make_spec(  # pylint: disable=inconsistent-return-statements  # noqa: MC0001
     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 tag_or_pred: an optional tag for the resulting spec or a Spec or value which
+        can be converted into a Spec; if no tag is provided, the default depends on the
+        input type:
+
+        * for ``frozenset`` and ``set`` predicates, the default is ``"set"``
+
+        * for ``Enum`` predicates, the default is the name of the enum
+
+        * for ``tuple`` predicates, the default is ``"tuple"``
+
+        * for ``list`` (collection) predicates, the default is ``"coll"``
+
+        * for ``dict`` (mapping) predicates, the default is ``"map"``
+
+        * for ``type`` predicates, the default is the name of the type
+
+        * for callable predicates, the default is the name of the function
+
+    :param preds: if a tag is given, exactly one spec predicate; if no tag is given,
+        this should not be specified
     :param conformer: an optional :py:data:`dataspec.Conformer` for the value
     :return: a :py:class:`dataspec.base.Spec` instance
     """
-    tag = args[0] if isinstance(args[0], str) else None
+    tag, preds = tag_maybe(tag_or_pred, *preds)
+
+    if len(preds) != 1:
+        raise TypeError("Expected some spec predicate")
 
     try:
-        pred = args[0] if tag is None else args[1]
+        pred = preds[0]
     except IndexError:
-        raise TypeError("Expected some spec predicate; received only a Tag")
+        raise TypeError("Expected some spec predicate")
 
     if isinstance(pred, (frozenset, set)):
         return SetSpec(tag or "set", pred, conformer=conformer)
diff --git a/src/dataspec/factories.py b/src/dataspec/factories.py
index 092dbb4..a42822f 100644
--- a/src/dataspec/factories.py
+++ b/src/dataspec/factories.py
@@ -49,35 +49,65 @@ from dataspec.base import (
 
 
 def blankable_spec(
-    *args: Union[Tag, SpecPredicate], conformer: Optional[Conformer] = None
+    tag_or_pred: Union[Tag, SpecPredicate],
+    *preds: SpecPredicate,
+    conformer: Optional[Conformer] = None,
 ):
     """
     Return a Spec which will validate values either by the input Spec or allow the
     empty string.
 
-    The returned Spec is equivalent to ``s.any(spec, {""})``.
+    The returned Spec is roughly equivalent to ``s.any(spec, {""})``.
+
+    If no Specs or Spec predicates is given, a :py:class:`ValueError` will be raised.
 
-    :param tag: an optional tag for the resulting spec
-    :param pred: a Spec or value which can be converted into a Spec
+    :param tag_or_pred: an optional tag for the resulting Spec *or* a Spec or value
+        which can be converted into a Spec; if no tag is provided, the default is
+        ``"blankable"``
+    :param preds: if a tag is provided for ``tag_or_pred``, exactly Spec predicate as
+        described in ``tag_or_pred``; otherwise, nothing
     :param conformer: an optional conformer for the value
     :return: a Spec which validates either according to ``pred`` or the empty string
     """
-    tag, preds = tag_maybe(*args)  # pylint: disable=no-value-for-parameter
-    assert len(preds) == 1, "Only one predicate allowed"
-    return any_spec(
-        tag or "blankable", preds[0], {""}, conformer=conformer  # type: ignore
+    tag, preds = tag_maybe(tag_or_pred, *preds)
+
+    if len(preds) != 1:
+        raise ValueError(
+            "Must provide exactly one Spec predicate for 'blankable' Specs"
+        )
+
+    spec = make_spec(cast("SpecPredicate", preds[0]))
+
+    # Use a custom validator function here so user-provided Spec still includes that
+    # Spec's tag in its ErrorDetails, but we only include the "blankable" tag in the
+    # ErrorDetails if the value is not blank
+    def blank_or_pred(e) -> Iterator[ErrorDetails]:
+        if e == "":
+            return
+
+        spec_errors = list(spec.validate(e))
+        if spec_errors:
+            yield from spec_errors
+            yield ErrorDetails(
+                message=f"Value '{e}' is not blank", pred=blank_or_pred, value=e,
+            )
+
+    return ValidatorSpec(
+        tag or "blankable",
+        blank_or_pred,
+        conformer=compose_conformers(spec.conformer, conformer),
     )
 
 
 def bool_spec(
-    tag: Optional[Tag] = None,
+    tag: Tag = "bool",
     allowed_values: Optional[Set[bool]] = None,
     conformer: Optional[Conformer] = None,
 ) -> Spec:
     """
     Return a Spec which will validate boolean values.
 
-    :param tag: an optional tag for the resulting spec
+    :param tag: an optional tag for the resulting spec; default is ``"bool"``
     :param allowed_values: if specified, a set of allowed boolean values
     :param conformer: an optional conformer for the value
     :return: a Spec which validates boolean values
@@ -101,13 +131,11 @@ def bool_spec(
 
         validators.append(is_allowed_bool_type)
 
-    return ValidatorSpec.from_validators(
-        tag or "bool", *validators, conformer=conformer
-    )
+    return ValidatorSpec.from_validators(tag, *validators, conformer=conformer)
 
 
 def bytes_spec(  # noqa: MC0001  # pylint: disable=too-many-arguments
-    tag: Optional[Tag] = None,
+    tag: Tag = "bytes",
     type_: Tuple[Union[Type[bytes], Type[bytearray]], ...] = (bytes, bytearray),
     length: Optional[int] = None,
     minlength: Optional[int] = None,
@@ -138,7 +166,7 @@ def bytes_spec(  # noqa: MC0001  # pylint: disable=too-many-arguments
     :py:obj:`typing.Pattern`, the supplied pattern will be used. In both cases, the
     :py:func:`re.fullmatch` will be used to validate input strings.
 
-    :param tag: an optional tag for the resulting spec
+    :param tag: an optional tag for the resulting spec; default is ``"bytes"``
     :param type_:  a single :py:class:`type` or tuple of :py:class:`type` s which
         will be used to type check input values by the resulting Spec
     :param length: if specified, the resulting Spec will validate that bytes are
@@ -157,7 +185,7 @@ def bytes_spec(  # noqa: MC0001  # pylint: disable=too-many-arguments
     def is_bytes(s: Any) -> bool:
         return isinstance(s, type_)
 
-    validators: List[Union[ValidatorFn, ValidatorSpec]] = [is_bytes]
+    validators: List[ValidatorFn] = [is_bytes]
 
     if length is not None:
 
@@ -226,13 +254,12 @@ def bytes_spec(  # noqa: MC0001  # pylint: disable=too-many-arguments
 
         validators.append(bytes_matches_regex)
 
-    return ValidatorSpec.from_validators(
-        tag or "bytes", *validators, conformer=conformer
-    )
+    return ValidatorSpec.from_validators(tag, *validators, conformer=conformer)
 
 
 def default_spec(
-    *args: Union[Tag, SpecPredicate],
+    tag_or_pred: Union[Tag, SpecPredicate],
+    *preds: SpecPredicate,
     default: Any = None,
     conformer: Optional[Conformer] = None,
 ) -> Spec:
@@ -247,29 +274,35 @@ def default_spec(
     This Spec **will allow any value to pass**, but will conform to the given default
     if the data does not satisfy the input Spec.
 
-    :param tag: an optional tag for the resulting spec
-    :param pred: a Spec or value which can be converted into a Spec
+    If no Specs or Spec predicates is given, a :py:class:`ValueError` will be raised.
+
+    :param tag_or_pred: an optional tag for the resulting Spec *or* a Spec or value
+        which can be converted into a Spec; if no tag is provided, the default is
+        ``"default"``
+    :param preds: if a tag is provided for ``tag_or_pred``, exactly Spec predicate as
+        described in ``tag_or_pred``; otherwise, nothing
     :param default: the default value to apply if the Spec does not validate a value
     :param conformer: an optional conformer for the value
     :return: a Spec which validates every value, but which conforms values to a default
     """
-    tag, preds = tag_maybe(*args)  # pylint: disable=no-value-for-parameter
-    assert len(preds) == 1, "Only one predicate allowed"
+    tag, preds = tag_maybe(tag_or_pred, *preds)
+
+    if len(preds) != 1:
+        raise ValueError("Must provide exactly one Spec predicate for 'default' Specs")
+
     return any_spec(
         tag or "default",
-        preds[0],  # type: ignore[arg-type]  # noqa: F821
+        preds[0],
         every_spec(conformer=lambda _: default),
         conformer=conformer,
     )
 
 
-def every_spec(
-    tag: Optional[Tag] = None, conformer: Optional[Conformer] = None
-) -> Spec:
+def every_spec(tag: Tag = "every", conformer: Optional[Conformer] = None) -> Spec:
     """
     Return a Spec which validates every possible value.
 
-    :param tag: an optional tag for the resulting spec
+    :param tag: an optional tag for the resulting spec; default is ``"every"``
     :param conformer: an optional conformer for the value
     :return: a Spec which validates any value
     """
@@ -277,7 +310,7 @@ def every_spec(
     def always_true(_) -> bool:
         return True
 
-    return PredicateSpec(tag or "every", always_true, conformer=conformer)
+    return PredicateSpec(tag, always_true, conformer=conformer)
 
 
 _DEFAULT_STRPTIME_DATE = date(1900, 1, 1)
@@ -328,7 +361,7 @@ def _make_datetime_spec_factory(  # noqa: MC0001
 
     {aware_docstring_blurb}
 
-    :param tag: an optional tag for the resulting spec
+    :param tag: an optional tag for the resulting spec; default is ``"{type_.__name__}"``
     :param format: if specified, a time format string which will be fed to
         :py:meth:`datetime.{type_.__name__}.strptime` to convert the input string
         to a :py:class:`datetime.{type_.__name__}` before applying the other
@@ -366,7 +399,7 @@ def _make_datetime_spec_factory(  # noqa: MC0001
         strptime = datetime.strptime  # type: ignore
 
     def _datetime_spec_factory(  # pylint: disable=too-many-arguments
-        tag: Optional[Tag] = None,
+        tag: Tag = type_.__name__,
         format_: Optional[str] = None,
         before: Optional[type_] = None,  # type: ignore
         after: Optional[type_] = None,  # type: ignore
@@ -455,16 +488,12 @@ def _make_datetime_spec_factory(  # noqa: MC0001
                         yield from validate(dt)
 
             return ValidatorSpec(
-                tag or type.__name__,
+                tag,
                 validate_datetime_str,
-                conformer=compose_conformers(
-                    conform_datetime_str, *filter(None, (conformer,))
-                ),
+                conformer=compose_conformers(conform_datetime_str, conformer),
             )
         else:
-            return ValidatorSpec.from_validators(
-                tag or type_.__name__, *validators, conformer=conformer
-            )
+            return ValidatorSpec.from_validators(tag, *validators, conformer=conformer)
 
     _datetime_spec_factory.__doc__ = docstring
     return _datetime_spec_factory
@@ -481,7 +510,7 @@ except ImportError:
 else:
 
     def datetime_str_spec(  # pylint: disable=too-many-arguments
-        tag: Optional[Tag] = None,
+        tag: Tag = "datetime_str",
         iso_only: bool = False,
         before: Optional[datetime] = None,
         after: Optional[datetime] = None,
@@ -510,7 +539,8 @@ else:
         range of valid ISO 8601 strings than Python 3.7+'s
         :py:func:`datetime.datetime.fromisoformat` function.
 
-        :param tag: an optional tag for the resulting spec
+        :param tag: an optional tag for the resulting spec; default is
+            ``"datetime_str"``
         :param iso_only: if True, restrict the set of allowed date strings to those
             formatted as ISO 8601 datetime strings; default is False
         :param before: if specified, a datetime that specifies the latest instant
@@ -524,8 +554,6 @@ else:
         :return: a Spec which validates strings containing date/time strings
         """
 
-        tag = tag or "datetime_str"
-
         @pred_to_validator("Value '{value}' is not type 'str'", complement=True)
         def is_str(x: Any) -> bool:
             return isinstance(x, str)
@@ -549,14 +577,14 @@ else:
             tag,
             is_str,
             str_contains_datetime,
-            conformer=compose_conformers(
-                cast(Conformer, parse_date_str), *filter(None, (conformer,))
-            ),
+            conformer=compose_conformers(cast(Conformer, parse_date_str), conformer),
         )
 
 
 def dict_tag_spec(
-    *args: Union[Tag, SpecPredicate], conformer: Optional[Conformer] = None
+    tag_or_pred: Union[Tag, SpecPredicate],
+    *preds: SpecPredicate,
+    conformer: Optional[Conformer] = None,
 ) -> Spec:
     """
     Return a mapping Spec for which the Tags for each of the ``dict`` values is set
@@ -571,15 +599,19 @@ def dict_tag_spec(
     For more precise tagging of mapping Spec values, use the default ``s`` constructor
     with a ``dict`` value.
 
-    :param tag: an optional tag for the resulting spec
-    :param pred: a mapping spec predicate
+    :param tag_or_pred: an optional tag for the resulting spec or the first Spec or
+        value which can be converted into a Spec; if no tag is provided, default is
+        ``"map"``
+    :param preds: if a tag is given, exactly one mapping spec predicate; if no tag is
+        given, this should not be specified
     :param conformer: an optional conformer for the value
     :return: a mapping Spec
     """
-    tag, preds = tag_maybe(*args)  # pylint: disable=no-value-for-parameter
-    if len(preds) > 1:
+    tag, preds = tag_maybe(tag_or_pred, *preds)
+
+    if len(preds) != 1:
         raise ValueError(
-            f"Dict specs may only specify one spec predicate, not {len(preds)}"
+            f"Dict specs must specify exactly one spec predicate, not {len(preds)}"
         )
 
     pred = preds[0]
@@ -592,7 +624,7 @@ def dict_tag_spec(
         return k
 
     return make_spec(
-        *((tag,) if tag is not None else ()),
+        *filter(None, (tag,)),  # type: ignore[arg-type]  # noqa: F821
         {k: make_spec(_unwrap_opt_key(k), v) for k, v in pred.items()},
         conformer=conformer,
     )
@@ -702,7 +734,7 @@ def _obj_attr_validator(  # pylint: disable=too-many-arguments
 
 
 def email_spec(
-    tag: Optional[Tag] = None, conformer: Optional[Conformer] = None, **kwargs
+    tag: Tag = "email", conformer: Optional[Conformer] = None, **kwargs
 ) -> Spec:
     """
     Return a spec that can validate strings containing email addresses.
@@ -735,7 +767,7 @@ def email_spec(
     Providing a keyword argument for a non-existent field of
     :py:class:`email.headerregistry.Address` will produce a :py:exc:`ValueError`.
 
-    :param tag: an optional tag for the resulting spec
+    :param tag: an optional tag for the resulting spec; default is ``"email"``
     :param username: if specified, require an exact match for ``username``
     :param username_in: if specified, require ``username`` to match at least one value in the set
     :param username_regex: if specified, require ``username`` to match the regex pattern
@@ -745,7 +777,6 @@ def email_spec(
     :param conformer: an optional conformer for the value
     :return: a Spec which can validate that a string contains an email address
     """
-    tag = tag or "email"
 
     @pred_to_validator(f"Value '{{value}}' is not type 'str'", complement=True)
     def is_str(x: Any) -> bool:
@@ -800,33 +831,57 @@ def email_spec(
 
 
 def nilable_spec(
-    *args: Union[Tag, SpecPredicate], conformer: Optional[Conformer] = None
+    tag_or_pred: Union[Tag, SpecPredicate],
+    *preds: SpecPredicate,
+    conformer: Optional[Conformer] = None,
 ) -> Spec:
     """
     Return a Spec which will validate values either by the input Spec or allow
     the value :py:obj:`None`.
 
-    :param tag: an optional tag for the resulting spec
-    :param pred: a Spec or value which can be converted into a Spec
+    The returned Spec is roughly equivalent to ``s.any(spec, {None})``.
+
+    If no Specs or Spec predicates is given, a :py:class:`ValueError` will be raised.
+
+    :param tag_or_pred: an optional tag for the resulting Spec *or* a Spec or value
+        which can be converted into a Spec; if no tag is provided, the default is
+        ``"nilable"``
+    :param preds: if a tag is provided for ``tag_or_pred``, exactly one Spec predicate
+        as described in ``tag_or_pred``; otherwise, nothing
     :param conformer: an optional conformer for the value
     :return: a Spec which validates either according to ``pred`` or the value
         :py:obj:`None`
     """
-    tag, preds = tag_maybe(*args)  # pylint: disable=no-value-for-parameter
-    assert len(preds) == 1, "Only one predicate allowed"
+    tag, preds = tag_maybe(tag_or_pred, *preds)
+
+    if len(preds) != 1:
+        raise ValueError("Must provide exactly one Spec predicate for 'nilable' Specs")
+
     spec = make_spec(cast("SpecPredicate", preds[0]))
 
-    def nil_or_pred(e) -> bool:
+    # Use a custom validator function here so user-provided Spec still includes that
+    # Spec's tag in its ErrorDetails, but we only include the "nilable" tag in the
+    # ErrorDetails if the value is not None
+    def nil_or_pred(e) -> Iterator[ErrorDetails]:
         if e is None:
-            return True
+            return
 
-        return spec.is_valid(e)
+        spec_errors = list(spec.validate(e))
+        if spec_errors:
+            yield from spec_errors
+            yield ErrorDetails(
+                message=f"Value '{e}' is not None", pred=nil_or_pred, value=e,
+            )
 
-    return PredicateSpec(tag or "nilable", pred=nil_or_pred, conformer=conformer)
+    return ValidatorSpec(
+        tag or "nilable",
+        nil_or_pred,
+        conformer=compose_conformers(spec.conformer, conformer),
+    )
 
 
 def num_spec(
-    tag: Optional[Tag] = None,
+    tag: Tag = "num",
     type_: Union[Type, Tuple[Type, ...]] = (float, int),
     min_: Union[complex, float, int, None] = None,
     max_: Union[complex, float, int, None] = None,
@@ -845,7 +900,7 @@ def num_spec(
     Python's ``<`` operator. If ``min_`` and ``max_`` are specified and ``max_`` is
     less than ``min_``, a :py:exc:`ValueError` will be raised.
 
-    :param tag: an optional tag for the resulting spec
+    :param tag: an optional tag for the resulting spec; default is ``"num"``
     :param type_: a single :py:class:`type` or tuple of :py:class:`type` s which
         will be used to type check input values by the resulting Spec
     :param min_: if specified, the resulting Spec will validate that numeric values
@@ -882,15 +937,43 @@ def num_spec(
         if min_ > max_:  # type: ignore
             raise ValueError("Cannot define a spec with min greater than max")
 
-    return ValidatorSpec.from_validators(tag or "num", *validators, conformer=conformer)
+    return ValidatorSpec.from_validators(tag, *validators, conformer=conformer)
 
 
 def obj_spec(
-    *args: Union[Tag, SpecPredicate], conformer: Optional[Conformer] = None
+    tag_or_pred: Union[Tag, SpecPredicate],
+    *preds: SpecPredicate,
+    conformer: Optional[Conformer] = None,
 ) -> Spec:
-    """Return a Spec for an Object."""
-    tag, preds = tag_maybe(*args)  # pylint: disable=no-value-for-parameter
-    assert len(preds) == 1, "Only one predicate allowed"
+    """
+    Return a Spec for an arbitrary object.
+
+    Object Specs are defined as a mapping Spec with only string keys. The resulting
+    Spec will validate arbitrary objects by calling :py:func:`getattr` on the input
+    value with the mapping key names to validate the value contained on that attribute.
+
+    Object Specs support optional keys via :py:meth:`dataspec.SpecAPI.opt`. The value
+    must be a string.
+
+    Object Specs do not perform any type checks. Type checks can be defined separately
+    by calling :py:func:`dataspec.s` with a type.
+
+    If no Specs or Spec predicates is given, a :py:class:`ValueError` will be raised.
+
+    :param tag_or_pred: an optional tag for the resulting Spec *or* a mapping Spec
+        predicate with string keys (potentially wrapped by
+        :py:meth:`dataspec.SpecAPI.opt`) and Spec predicates for values; if no tag is
+        provided, the default is ``"object"``
+    :param preds: if a tag is provided for ``tag_or_pred``, exactly one mapping Spec
+        predicate as described in ``tag_or_pred``; otherwise, nothing
+    :param conformer: an optional conformer for the value
+    :return: a Spec which validates generic objects by their attributes
+    """
+    tag, preds = tag_maybe(tag_or_pred, *preds)
+
+    if len(preds) != 1:
+        raise ValueError("Must provide exactly one Spec predicate for 'obj' Specs")
+
     return ObjectSpec.from_val(
         tag or "object",
         cast(Mapping[ObjectSpecKey, SpecPredicate], preds[0]),
@@ -901,7 +984,7 @@ def obj_spec(
 T = TypeVar("T")
 
 
-def opt_key(k: T) -> OptionalKey:
+def opt_key(k: T) -> OptionalKey[T]:
     """Return ``k`` wrapped in a marker object indicating that the key is optional in
     associative specs."""
     return OptionalKey(k)
@@ -927,7 +1010,7 @@ else:
             return phonenumbers.format_number(p, phonenumbers.PhoneNumberFormat.E164)
 
     def phonenumber_spec(
-        tag: Optional[Tag] = None,
+        tag: Tag = "phonenumber_str",
         region: Optional[str] = None,
         is_possible: bool = True,
         is_valid: bool = True,
@@ -960,7 +1043,8 @@ else:
         By default, the Spec supplies a conformer which conforms telephone numbers to
         the international E.164 format, which is globally unique.
 
-        :param tag: an optional tag for the resulting spec
+        :param tag: an optional tag for the resulting spec; default is
+            ``"phonenumber_str"``
         :param region: an optional two-letter country code which, if provided, will
             be checked against the parsed telephone number's region
         :param is_possible: if True and the input number can be successfully parsed,
@@ -973,7 +1057,6 @@ else:
         :return: a Spec which validates strings containing telephone numbers
         """
 
-        tag = tag or "phonenumber_str"
         default_conformer = conform_phonenumber
 
         @pred_to_validator("Value '{value}' is not type 'str'", complement=True)
@@ -1045,46 +1128,32 @@ else:
             tag,
             is_str,
             str_contains_phonenumber,
-            conformer=compose_conformers(
-                default_conformer, *filter(None, (conformer,))
-            ),
+            conformer=compose_conformers(default_conformer, conformer),
         )
 
 
 @attr.s(auto_attribs=True, frozen=True, slots=True)
 class StrFormat:
-    validator: ValidatorSpec
+    validate: ValidatorFn
     conformer: Optional[Conformer] = None
 
-    @property
-    def conforming_validator(self) -> ValidatorSpec:
-        if self.conformer is not None:
-            return cast(ValidatorSpec, self.validator.with_conformer(self.conformer))
-        else:
-            return self.validator
-
 
 _STR_FORMATS: MutableMapping[str, StrFormat] = {}
 _STR_FORMAT_LOCK = threading.Lock()
 
 
-def register_str_format_spec(
-    name: str, validate: ValidatorSpec, conformer: Optional[Conformer] = None
-) -> None:  # pragma: no cover
-    """Register a new String format, which will be checked by the ValidatorSpec
-    ``validate``. A conformer can be supplied for the string format which will
-    be applied if desired, but may otherwise be ignored."""
-    with _STR_FORMAT_LOCK:
-        _STR_FORMATS[name] = StrFormat(validate, conformer=conformer)
-
-
 def register_str_format(
     tag: Tag, conformer: Optional[Conformer] = None
-) -> Callable[[Callable], ValidatorFn]:
-    """Decorator to register a Validator function as a string format."""
+) -> Callable[[ValidatorFn], ValidatorFn]:
+    """
+    Register a new String format, which will be checked by the validator function
+    ``validate``. A conformer can be supplied for the string format which will be
+    applied if desired, but may otherwise be ignored.
+    """
 
-    def create_str_format(f) -> ValidatorFn:
-        register_str_format_spec(tag, ValidatorSpec(tag, f), conformer=conformer)
+    def create_str_format(f: ValidatorFn) -> ValidatorFn:
+        with _STR_FORMAT_LOCK:
+            _STR_FORMATS[tag] = StrFormat(f, conformer=conformer)
         return f
 
     return create_str_format
@@ -1160,7 +1229,7 @@ else:
 
 
 def str_spec(  # noqa: MC0001  # pylint: disable=too-many-arguments
-    tag: Optional[Tag] = None,
+    tag: Tag = "str",
     length: Optional[int] = None,
     minlength: Optional[int] = None,
     maxlength: Optional[int] = None,
@@ -1214,7 +1283,7 @@ def str_spec(  # noqa: MC0001  # pylint: disable=too-many-arguments
        time string
      * ``uuid`` validates that a string contains a valid UUID
 
-    :param tag: an optional tag for the resulting spec
+    :param tag: an optional tag for the resulting spec; default is ``"str"``
     :param length: if specified, the resulting Spec will validate that strings are
         exactly ``length`` characters long by :py:func:`len`
     :param minlength: if specified, the resulting Spec will validate that strings are
@@ -1236,7 +1305,7 @@ def str_spec(  # noqa: MC0001  # pylint: disable=too-many-arguments
     def is_str(s: Any) -> bool:
         return isinstance(s, str)
 
-    validators: List[Union[ValidatorFn, ValidatorSpec]] = [is_str]
+    validators: List[ValidatorFn] = [is_str]
 
     if length is not None:
 
@@ -1308,16 +1377,19 @@ def str_spec(  # noqa: MC0001  # pylint: disable=too-many-arguments
         validators.append(str_matches_regex)
     elif regex is None and format_ is not None and conform_format is None:
         with _STR_FORMAT_LOCK:
-            validators.append(_STR_FORMATS[format_].validator)
+            validators.append(_STR_FORMATS[format_].validate)
     elif regex is None and format_ is None and conform_format is not None:
         with _STR_FORMAT_LOCK:
-            validators.append(_STR_FORMATS[conform_format].conforming_validator)
+            fmt = _STR_FORMATS[conform_format]
+
+        conformer = compose_conformers(fmt.conformer, conformer)
+        validators.append(fmt.validate)
     elif sum(int(v is not None) for v in [regex, format_, conform_format]) > 1:
         raise ValueError(
             "Cannot define a spec with more than one of: regex, format, conforming format"
         )
 
-    return ValidatorSpec.from_validators(tag or "str", *validators, conformer=conformer)
+    return ValidatorSpec.from_validators(tag, *validators, conformer=conformer)
 
 
 _URL_RESULT_FIELDS = frozenset(
@@ -1337,7 +1409,7 @@ _URL_DISALLOWED_REGEX_FIELDS = frozenset({"port"})
 
 
 def url_str_spec(
-    tag: Optional[Tag] = None,
+    tag: Tag = "url_str",
     query: Optional[SpecPredicate] = None,
     conformer: Optional[Conformer] = None,
     **kwargs,
@@ -1381,7 +1453,7 @@ def url_str_spec(
     Providing a keyword argument for a non-existent field of
     :py:class:`urllib.parse.ParseResult` will produce a :py:exc:`ValueError`.
 
-    :param tag: an optional tag for the resulting spec
+    :param tag: an optional tag for the resulting spec; default is ``"url_str"``
     :param query: an optional spec for the :py:class:`dict` created by calling
         :py:func:`urllib.parse.parse_qs` on the :py:attr:`urllib.parse.ParseResult.query`
         attribute of the parsed URL
@@ -1419,7 +1491,7 @@ def url_str_spec(
     def is_str(s: Any) -> bool:
         return isinstance(s, str)
 
-    validators: List[Union[ValidatorFn, ValidatorSpec]] = [is_str]
+    validators: List[ValidatorFn] = [is_str]
 
     child_validators = []
     for url_attr in _URL_RESULT_FIELDS:
@@ -1483,13 +1555,11 @@ def url_str_spec(
 
     validators.append(validate_url)
 
-    return ValidatorSpec.from_validators(
-        tag or "url_str", *validators, conformer=conformer
-    )
+    return ValidatorSpec.from_validators(tag, *validators, conformer=conformer)
 
 
 def uuid_spec(
-    tag: Optional[Tag] = None,
+    tag: Tag = "uuid",
     versions: Optional[Set[int]] = None,
     conformer: Optional[Conformer] = None,
 ) -> Spec:
@@ -1503,7 +1573,7 @@ def uuid_spec(
     of RFC 4122 variant UUIDs. If ``versions`` specifies an invalid RFC 4122 variant
     UUID version, a :py:exc:`ValueError` will be raised.
 
-    :param tag: an optional tag for the resulting spec
+    :param tag: an optional tag for the resulting spec; default is ``"uuid"``
     :param versions: an optional set of integers of 1, 3, 4, and 5 which the input
         :py:class:`uuid.UUID` must match; otherwise, any version will pass the Spec
     :param conformer: an optional conformer for the value
@@ -1514,7 +1584,7 @@ def uuid_spec(
     def is_uuid(v: Any) -> bool:
         return isinstance(v, uuid.UUID)
 
-    validators: List[Union[ValidatorFn, ValidatorSpec]] = [is_uuid]
+    validators: List[ValidatorFn] = [is_uuid]
 
     if versions is not None:
 
@@ -1535,6 +1605,4 @@ def uuid_spec(
 
         validators.append(uuid_is_version)
 
-    return ValidatorSpec.from_validators(
-        tag or "uuid", *validators, conformer=conformer
-    )
+    return ValidatorSpec.from_validators(tag, *validators, conformer=conformer)
diff --git a/tox.ini b/tox.ini
index b7dec87..35ab750 100644
--- a/tox.ini
+++ b/tox.ini
@@ -3,12 +3,12 @@ envlist = {py36,py37,py38}{-dateutil,-phonenumbers,},coverage,format,mypy,lint,s
 
 [testenv]
 deps =
-    coverage==4.5.4
+    coverage
     dateutil: python-dateutil
     phonenumbers: phonenumbers
     ;; PyTest 5.2.3 created a situation where coverage.py collected no results
     ;; from any test runs. PyTest 5.2.2 does not exhibit this behavior.
-    pytest==5.2.2
+    pytest
 parallel_show_output = true
 commands =
     coverage run \
@@ -22,7 +22,7 @@ commands =
 depends = py36,py37,py38
 deps =
     coveralls
-    coverage==4.5.4
+    coverage
 passenv = COVERALLS_REPO_TOKEN
 setenv =
     ; Disable PEP 517 behavior since we're using usedevelop = true


Test patch:
diff --git a/tests/test_base.py b/tests/test_base.py
index 4b6510f..a8185c2 100644
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -534,10 +534,20 @@ class TestObjectSpecValidation:
     @pytest.mark.parametrize(
         "pred", [{"id": str, s.opt("id"): int}, {s.opt("id"): int, "id": str},]
     )
-    def test_obj_spec_definition(self, pred):
+    def test_obj_spec_definition_with_duplicate_keys(self, pred):
         with pytest.raises(KeyError):
             s.obj(pred)
 
+    def test_object_spec_construction(self):
+        with pytest.raises(ValueError):
+            s.obj("tag_only")
+
+        with pytest.raises(ValueError):
+            s.obj({"id": str}, {"name": str})
+
+        with pytest.raises(ValueError):
+            s.blankable("tag_and_two", {"id": str}, {"name": str})
+
     @attr.s(auto_attribs=True, frozen=True, slots=True)
     class Person:
         id: str
diff --git a/tests/test_factories.py b/tests/test_factories.py
index 6bad5b9..b532cfe 100644
--- a/tests/test_factories.py
+++ b/tests/test_factories.py
@@ -27,6 +27,16 @@ def test_is_any(v):
 
 
 class TestBlankableSpecValidation:
+    def test_blankable_spec_construction(self):
+        with pytest.raises(ValueError):
+            s.blankable("tag_only")
+
+        with pytest.raises(ValueError):
+            s.blankable(s.is_str, s.is_num)
+
+        with pytest.raises(ValueError):
+            s.blankable("tag_and_two", s.is_str, s.is_num)
+
     @pytest.fixture
     def blankable_spec(self) -> Spec:
         return s.blankable(s.str(regex=r"\d{5}"))
@@ -40,6 +50,7 @@ class TestBlankableSpecValidation:
     )
     def test_blankable_validation_failure(self, blankable_spec: Spec, v):
         assert not blankable_spec.is_valid(v)
+        assert blankable_spec.validate_all(v)
 
 
 class TestBoolValidation:
@@ -208,6 +219,16 @@ class TestBytesSpecValidation:
 
 
 class TestDefaultSpecValidation:
+    def test_default_spec_construction(self):
+        with pytest.raises(ValueError):
+            s.default("tag_only")
+
+        with pytest.raises(ValueError):
+            s.default(s.is_str, s.is_num)
+
+        with pytest.raises(ValueError):
+            s.default("tag_and_two", s.is_str, s.is_num)
+
     @pytest.fixture
     def default_spec(self) -> Spec:
         return s.default(s.str(regex=r"\d{5}"))
@@ -290,9 +311,9 @@ class TestDictTagSpecValidation:
             ),
             (
                 {"name": "Darryl Smith", "license_states": ["NY", "California"]},
-                ["licensures", "license_states", "state", "str_is_exactly_len"],
+                ["licensures", "license_states", "state"],
             ),
-            ({"name": "GaryBusey"}, ["licensures", "name", "str_matches_regex"]),
+            ({"name": "GaryBusey"}, ["licensures", "name"]),
         ],
     )
     def test_error_details(self, v, via):
@@ -960,9 +981,31 @@ class TestInstStringSpecValidation:
         assert INVALID is iso_inst_str_spec.conform(v)
 
 
-@pytest.mark.parametrize("v", [None, "", "a string"])
-def test_nilable(v):
-    assert s.nilable(s.is_str).is_valid(v)
+class TestNilableSpecValidation:
+    def test_nilable_spec_construction(self):
+        with pytest.raises(ValueError):
+            s.nilable("tag_only")
+
+        with pytest.raises(ValueError):
+            s.nilable(s.is_str, s.is_num)
+
+        with pytest.raises(ValueError):
+            s.nilable("tag_and_two", s.is_str, s.is_num)
+
+    @pytest.fixture
+    def nilable_spec(self) -> Spec:
+        return s.nilable(s.str(regex=r"\d{5}"))
+
+    @pytest.mark.parametrize("v", [None, "11111", "12345"])
+    def test_nilable_validation(self, nilable_spec: Spec, v):
+        assert nilable_spec.is_valid(v)
+
+    @pytest.mark.parametrize(
+        "v", ["", "    ", "1234", "1234D", "   12345", {}, set(), []]
+    )
+    def test_nilable_validation_failure(self, nilable_spec: Spec, v):
+        assert not nilable_spec.is_valid(v)
+        assert nilable_spec.validate_all(v)
 
 
 class TestNumSpecValidation: