Repository: pepkit/eido

Issue:
Unused parameter in validate_* functions
It looks like the `use_case` parameter in `_validate_object` is unused, and that some other functions have that parameter simply for passage to `_validate_object`. @nsheff is this a known issue and something that's been retained for backward- or cross-project-compatibility, or can we try removing it?

Discussion:

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 7d32d22..b1fde78 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,18 +1,39 @@
 # Changelog
 
 This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format.
+
+
+## [0.2.0] - 2023-06-21
+
+### Added
+- CLI options for modulating peppy project (#50)
+
+### Fixed
+- Rewrote history to remove large files committed by mistake
+- You can now check for existing of files in subsample table (#26)
+
+### Changed
+- All validation functions now use similar error-raising behavior
+- Validation functions now return all individual error objects grouped by type
+
+
 ## [0.1.9] - 2022-09-12
+
 ### Fixed
 - CSV filter bug
+
 ### Added
 - New test cases
+
 ## [0.1.8] - 2022-08-29
 ### Changed
 - the way of merging tables for multiline output format from eido convert
+
 ### Added
 - better architecture for output formatters that goes well with **open-closed principle**
 - using mock in some testcases
 - test data in the format that was causing the errors previously
+
 ### Fixed
 - passing plugin keyword arguments to `run_filter` function
 - saving output file will now work for path like `file.txt`, no need to pass full path
diff --git a/docs/example-schemas.md b/docs/example-schemas.md
index 1de0986..cfea4b4 100644
--- a/docs/example-schemas.md
+++ b/docs/example-schemas.md
@@ -2,7 +2,7 @@
 
 With `eido` you can create your own schema to describe the kind of projects your tool can accept. We are currently hosting schemas at [schema.databio.org](https://github.com/databio/schema.databio.org) in a GitHub repository. You can browse that repository for examples, or look at few examples here:
 
-- [Generic PEP2.0.0 schema](https://schema.databio.org/pep/2.0.0.yaml) -- all PEPs should validate against this schema
-- [PEPPRO pipeline schema](https://schema.databio.org/pipelines/ProseqPEP.yaml) -- describes PEPs compatible with the [PEPPRO](https://peppro.databio.org) pipeline
-- [PEPATAC pipeline schema](https://schema.databio.org/pipelines/pepatac.yaml) -- describes PEPs compatible with the [PEPATAC](https://pepatac.databio.org) pipeline
-- [refgenie databio build schema](https://schema.databio.org/refgenie/refgenie_build.yaml) -- describes PEPs compatible with building [refgenie](https://refgenie.databio.org) assets
+- [Generic PEP2.0.0 schema](http://schema.databio.org/pep/2.0.0.yaml) -- all PEPs should validate against this schema
+- [PEPPRO pipeline schema](http://schema.databio.org/pipelines/ProseqPEP.yaml) -- describes PEPs compatible with the [PEPPRO](http://peppro.databio.org) pipeline
+- [PEPATAC pipeline schema](http://schema.databio.org/pipelines/pepatac.yaml) -- describes PEPs compatible with the [PEPATAC](http://pepatac.databio.org) pipeline
+- [refgenie databio build schema](https://schema.databio.org/refgenie/refgenie_build.yaml) -- describes PEPs compatible with building [refgenie](http://refgenie.databio.org) assets
diff --git a/eido/__init__.py b/eido/__init__.py
index 86d2699..f1c7e6d 100644
--- a/eido/__init__.py
+++ b/eido/__init__.py
@@ -23,4 +23,7 @@ __all__ = [
     "yaml_pep_filter",
     "csv_pep_filter",
     "yaml_samples_pep_filter",
+    "EidoValidationError",
+    "validate_input_files",
+    "get_input_files_size",
 ]
diff --git a/eido/_version.py b/eido/_version.py
index c11f861..d3ec452 100644
--- a/eido/_version.py
+++ b/eido/_version.py
@@ -1,1 +1,1 @@
-__version__ = "0.1.9"
+__version__ = "0.2.0"
diff --git a/eido/argparser.py b/eido/argparser.py
index 0197f83..f5ae763 100644
--- a/eido/argparser.py
+++ b/eido/argparser.py
@@ -40,22 +40,37 @@ def build_argparser():
     )
     sps = {}
     for cmd, desc in SUBPARSER_MSGS.items():
-        sps[cmd] = subparsers.add_parser(cmd, description=desc, help=desc)
-        sps[cmd].add_argument(
+        subparser = subparsers.add_parser(cmd, description=desc, help=desc)
+        subparser.add_argument(
             "--st-index",
             required=False,
             type=str,
+            default=SAMPLE_NAME_ATTR,
             help=f"Sample table index to use, samples are identified by '{SAMPLE_NAME_ATTR}' by default.",
         )
+        subparser.add_argument(
+            "--sst-index",
+            required=False,
+            type=str,
+            default=SAMPLE_NAME_ATTR,
+            help=f"Subsample table index to use, samples are identified by '{SAMPLE_NAME_ATTR}' by default.",
+        )
+        subparser.add_argument(
+            "--amendments",
+            required=False,
+            type=str,
+            nargs="+",
+            help=f"Names of the amendments to activate.",
+        )
         if cmd != CONVERT_CMD:
-            sps[cmd].add_argument(
+            subparser.add_argument(
                 "pep",
                 metavar="PEP",
                 help="Path to a PEP configuration file in yaml format.",
                 default=None,
             )
         else:
-            sps[cmd].add_argument(
+            subparser.add_argument(
                 "pep",
                 metavar="PEP",
                 nargs="?",
@@ -63,6 +78,8 @@ def build_argparser():
                 default=None,
             )
 
+        sps[cmd] = subparser
+
     sps[VALIDATE_CMD].add_argument(
         "-s",
         "--schema",
diff --git a/eido/cli.py b/eido/cli.py
index 5c17a44..a41ebff 100644
--- a/eido/cli.py
+++ b/eido/cli.py
@@ -6,8 +6,12 @@ from peppy import Project
 
 from .argparser import LEVEL_BY_VERBOSITY, build_argparser
 from .const import *
-from .conversion import convert_project, get_available_pep_filters, pep_conversion_plugins
-from .exceptions import EidoFilterError
+from .conversion import (
+    convert_project,
+    get_available_pep_filters,
+    pep_conversion_plugins,
+)
+from .exceptions import EidoFilterError, EidoValidationError
 from .inspection import inspect_project
 from .validation import validate_config, validate_project, validate_sample
 
@@ -30,6 +34,26 @@ def _parse_filter_args_str(input):
     )
 
 
+def print_error_summary(errors_by_type):
+    """Print a summary of errors, organized by error type"""
+    n_error_types = len(errors_by_type)
+    print(f"Found {n_error_types} types of error:")
+    for type in errors_by_type:
+        n = len(errors_by_type[type])
+        msg = f"  - {type}: ({n} samples) "
+        if n < 50:
+            msg += ", ".join([x["sample_name"] for x in errors_by_type[type]])
+        print(msg)
+
+    if len(errors_by_type) > 1:
+        final_msg = f"Validation unsuccessful. {len(errors_by_type)} error types found."
+    else:
+        final_msg = f"Validation unsuccessful. {len(errors_by_type)} error type found."
+
+    print(final_msg)
+    return final_msg
+
+
 def main():
     """Primary workflow"""
     parser, sps = build_argparser()
@@ -85,7 +109,12 @@ def main():
         else:
             paths = None
 
-        p = Project(args.pep, sample_table_index=args.st_index)
+        p = Project(
+            args.pep,
+            sample_table_index=args.st_index,
+            subsample_table_index=args.sst_index,
+            amendments=args.amendments,
+        )
         plugin_kwargs = _parse_filter_args_str(args.args)
 
         # append paths
@@ -97,7 +126,12 @@ def main():
 
     _LOGGER.debug(f"Creating a Project object from: {args.pep}")
     if args.command == VALIDATE_CMD:
-        p = Project(cfg=args.pep, sample_table_index=args.st_index)
+        p = Project(
+            args.pep,
+            sample_table_index=args.st_index,
+            subsample_table_index=args.sst_index,
+            amendments=args.amendments,
+        )
         if args.sample_name:
             try:
                 args.sample_name = int(args.sample_name)
@@ -107,20 +141,34 @@ def main():
                 f"Comparing Sample ('{args.pep}') in Project ('{args.pep}') "
                 f"against a schema: {args.schema}"
             )
-            validate_sample(p, args.sample_name, args.schema, args.exclude_case)
+            validator = validate_sample
+            arguments = [p, args.sample_name, args.schema, args.exclude_case]
         elif args.just_config:
             _LOGGER.debug(
                 f"Comparing Project ('{args.pep}') against a schema: {args.schema}"
             )
-            validate_config(p, args.schema, args.exclude_case)
+            validator = validate_config
+            arguments = [p, args.schema, args.exclude_case]
         else:
             _LOGGER.debug(
                 f"Comparing Project ('{args.pep}') against a schema: {args.schema}"
             )
-            validate_project(p, args.schema, args.exclude_case)
+            validator = validate_project
+            arguments = [p, args.schema, args.exclude_case]
+        try:
+            validator(*arguments)
+        except EidoValidationError as e:
+            print_error_summary(e.errors_by_type)
+            return False
         _LOGGER.info("Validation successful")
+        sys.exit(0)
 
     if args.command == INSPECT_CMD:
-        p = Project(cfg=args.pep, sample_table_index=args.st_index)
+        p = Project(
+            args.pep,
+            sample_table_index=args.st_index,
+            subsample_table_index=args.sst_index,
+            amendments=args.amendments,
+        )
         inspect_project(p, args.sample_name, args.attr_limit)
         sys.exit(0)
diff --git a/eido/conversion_plugins.py b/eido/conversion_plugins.py
index 287e70a..309db5b 100644
--- a/eido/conversion_plugins.py
+++ b/eido/conversion_plugins.py
@@ -73,7 +73,9 @@ def processed_pep_filter(p, **kwargs) -> Dict[str, str]:
 
     return {
         "project": str(prj_repr),
-        "samples": str(p.samples) if samples_as_objects else str(p.sample_table.to_csv()),
+        "samples": str(p.samples)
+        if samples_as_objects
+        else str(p.sample_table.to_csv()),
         "subsamples": str(p.subsamples)
         if subsamples_as_objects
         else str(p.subsample_table.to_csv()),
diff --git a/eido/exceptions.py b/eido/exceptions.py
index f800f49..6067063 100644
--- a/eido/exceptions.py
+++ b/eido/exceptions.py
@@ -2,7 +2,12 @@
 
 from abc import ABCMeta
 
-_all__ = ["PathAttrNotFoundError", "EidoSchemaInvalidError", "EidoFilterError"]
+_all__ = [
+    "EidoFilterError",
+    "EidoSchemaInvalidError",
+    "EidoValidationError",
+    "PathAttrNotFoundError",
+]
 
 
 class EidoException(Exception):
@@ -35,6 +40,6 @@ class EidoFilterError(EidoException):
 class EidoValidationError(EidoException):
     """Object was not validated successfully according to schema."""
 
-    def __init__(self, message, errors):
+    def __init__(self, message, errors_by_type):
         super().__init__(message)
-        self.errors = errors
+        self.errors_by_type = errors_by_type
diff --git a/eido/inspection.py b/eido/inspection.py
index 8af201f..f76726c 100644
--- a/eido/inspection.py
+++ b/eido/inspection.py
@@ -1,3 +1,25 @@
+import os
+from logging import getLogger
+
+from warnings import catch_warnings
+from ubiquerg import size
+
+
+from .const import (
+    ALL_INPUTS_KEY,
+    FILES_KEY,
+    INPUT_FILE_SIZE_KEY,
+    MISSING_KEY,
+    PROP_KEY,
+    REQUIRED_FILES_KEY,
+    REQUIRED_INPUTS_KEY,
+)
+from .schema import read_schema
+from .validation import _validate_sample_object, _get_attr_values
+
+_LOGGER = getLogger(__name__)
+
+
 def inspect_project(p, sample_names=None, max_attr=10):
     """
     Print inspection info: Project or,
@@ -18,3 +40,54 @@ def inspect_project(p, sample_names=None, max_attr=10):
         return
     print(p)
     return
+
+
+def get_input_files_size(sample, schema):
+    """
+    Determine which of this Sample's required attributes/files are missing
+    and calculate sizes of the files (inputs).
+
+    The names of the attributes that are required and/or deemed as inputs
+    are sourced from the schema, more specifically from required_input_attrs
+    and input_attrs sections in samples section. Note, this function does
+    perform actual Sample object validation with jsonschema.
+
+    :param peppy.Sample sample: sample to investigate
+    :param list[dict] | str schema: schema dict to validate against or a path to one
+    :return dict: dictionary with validation data, i.e missing,
+        required_inputs, all_inputs, input_file_size
+    :raise ValidationError: if any required sample attribute is missing
+    """
+    if isinstance(schema, str):
+        schema = read_schema(schema)
+
+    # first, validate attrs existence using jsonschema
+    _validate_sample_object(schemas=schema, sample=sample)
+
+    all_inputs = set()
+    required_inputs = set()
+    schema = schema[-1]  # use only first schema, in case there are imports
+    sample_schema_dict = schema["properties"]["_samples"]["items"]
+    if FILES_KEY in sample_schema_dict:
+        all_inputs.update(_get_attr_values(sample, sample_schema_dict[FILES_KEY]))
+    if REQUIRED_FILES_KEY in sample_schema_dict:
+        required_inputs = set(
+            _get_attr_values(sample, sample_schema_dict[REQUIRED_FILES_KEY])
+        )
+        all_inputs.update(required_inputs)
+    with catch_warnings(record=True) as w:
+        input_file_size = sum(
+            [size(f, size_str=False) or 0.0 for f in all_inputs if f != ""]
+        ) / (1024**3)
+        if w:
+            _LOGGER.warning(
+                f"{len(w)} input files missing, job input size was "
+                f"not calculated accurately"
+            )
+
+    return {
+        MISSING_KEY: [i for i in required_inputs if not os.path.exists(i)],
+        REQUIRED_INPUTS_KEY: required_inputs,
+        ALL_INPUTS_KEY: all_inputs,
+        INPUT_FILE_SIZE_KEY: input_file_size,
+    }
diff --git a/eido/output_formatters.py b/eido/output_formatters.py
index e063727..8d20a12 100644
--- a/eido/output_formatters.py
+++ b/eido/output_formatters.py
@@ -26,7 +26,6 @@ class MultilineOutputFormatter(BaseOutputFormatter):
         header = MultilineOutputFormatter._get_header(sample_attributes)
 
         for sample in samples:
-
             attribute_with_multiple_properties = MultilineOutputFormatter._get_the_name_of_the_first_attribute_with_multiple_properties(
                 sample, sample_attributes
             )
@@ -103,14 +102,15 @@ class MultilineOutputFormatter(BaseOutputFormatter):
             Representation of sample as a CSV row.
         """
         sample_row = []
-        for attribute in sample_attributes:
 
-            if MultilineOutputFormatter._sample_attribute_is_list(
-                sample, attribute
-            ) and getattr(sample, attribute):
-                value = getattr(sample, attribute)[sample_index]
+        for attribute in sample_attributes:
+            if (
+                MultilineOutputFormatter._sample_attribute_is_list(sample, attribute)
+                and sample[attribute]
+            ):
+                value = sample[attribute][sample_index]
             else:
-                value = getattr(sample, attribute, "")
+                value = sample[attribute]
 
             sample_row.append(value or "")
 
diff --git a/eido/schema.py b/eido/schema.py
index a65eda3..a8a6b33 100644
--- a/eido/schema.py
+++ b/eido/schema.py
@@ -30,7 +30,9 @@ def preprocess_schema(schema_dict):
         schema_dict[PROP_KEY]["_samples"] = schema_dict[PROP_KEY]["samples"]
         del schema_dict[PROP_KEY]["samples"]
         if "required" in schema_dict:
-            schema_dict["required"][schema_dict["required"].index("samples")] = "_samples"
+            schema_dict["required"][
+                schema_dict["required"].index("samples")
+            ] = "_samples"
         if (
             "items" in schema_dict[PROP_KEY]["_samples"]
             and PROP_KEY in schema_dict[PROP_KEY]["_samples"]["items"]
@@ -71,10 +73,11 @@ def read_schema(schema):
 
     schema_list = []
     if isinstance(schema, str):
-        return _recursively_read_schemas(load_yaml(schema), schema_list)
-    elif isinstance(schema, dict):
-        return _recursively_read_schemas(schema, schema_list)
-    raise TypeError(
-        f"schema has to be a dict, path to an existing file or URL to a remote one. "
-        f"Got: {type(schema)}"
-    )
+        _LOGGER.debug(f"Reading schema: {schema}")
+        schema = load_yaml(schema)
+    if not isinstance(schema, dict):
+        raise TypeError(
+            f"schema has to be a dict, path to an existing file or URL to a remote one. "
+            f"Got: {type(schema)}"
+        )
+    return _recursively_read_schemas(schema, schema_list)
diff --git a/eido/validation.py b/eido/validation.py
index 9ee24e4..a47e3dd 100644
--- a/eido/validation.py
+++ b/eido/validation.py
@@ -1,95 +1,105 @@
 import os
 from copy import deepcopy as dpcpy
 from logging import getLogger
-from warnings import catch_warnings as cw
+
+from warnings import warn
+
 from .exceptions import EidoValidationError
 
-from pandas.core.common import flatten
-from ubiquerg import size
 
+from pandas.core.common import flatten
 from jsonschema import Draft7Validator
 
 from .const import (
-    ALL_INPUTS_KEY,
     FILES_KEY,
-    INPUT_FILE_SIZE_KEY,
-    MISSING_KEY,
     PROP_KEY,
     REQUIRED_FILES_KEY,
-    REQUIRED_INPUTS_KEY,
 )
+from .exceptions import PathAttrNotFoundError
 from .schema import preprocess_schema, read_schema
 
 _LOGGER = getLogger(__name__)
 
 
-def _validate_object(object, schema, exclude_case=False):
+def _validate_object(object, schema, sample_name_colname=False):
     """
     Generic function to validate object against a schema
 
     :param Mapping object: an object to validate
     :param str | dict schema: schema dict to validate against or a path to one
-    :param bool exclude_case: whether to exclude validated objects
         from the error. Useful when used ith large projects
+    :raises EidoValidationError: if validation is unsuccessful
     """
-
     validator = Draft7Validator(schema)
+    print(object, schema)
     if not validator.is_valid(object):
         errors = sorted(validator.iter_errors(object), key=lambda e: e.path)
+        errors_by_type = {}
+
+        # Accumulate and restructure error objects by error type
         for error in errors:
-            print(
-                error.message,
-                f'''in the following location "{".".join(error.absolute_schema_path)}"''',
+            if not error.message in errors_by_type:
+                errors_by_type[error.message] = []
+
+            try:
+                instance_name = error.instance[sample_name_colname]
+            except KeyError:
+                instance_name = "project"
+            errors_by_type[error.message].append(
+                {
+                    "type": error.message,
+                    "message": f"{error.message} on instance {instance_name}",
+                    "sample_name": instance_name,
+                }
             )
-        raise EidoValidationError(
-            f"Validation unsuccessful. {len(errors)} error(s) found.", errors
-        )
 
+        raise EidoValidationError("Validation failed", errors_by_type)
+    else:
+        _LOGGER.debug("Validation was successful...")
 
-def validate_project(project, schema, exclude_case=False):
+
+def validate_project(project, schema):
     """
     Validate a project object against a schema
 
-    :param peppy.Sample project: a project object to validate
+    :param peppy.Project project: a project object to validate
     :param str | dict schema: schema dict to validate against or a path to one
-    :param bool exclude_case: whether to exclude validated objects
     from the error. Useful when used ith large projects
     """
+    sample_name_colname = project.sample_name_colname
     schema_dicts = read_schema(schema=schema)
     for schema_dict in schema_dicts:
         project_dict = project.to_dict()
-        _validate_object(project_dict, preprocess_schema(schema_dict), exclude_case)
+        _validate_object(
+            project_dict, preprocess_schema(schema_dict), sample_name_colname
+        )
         _LOGGER.debug("Project validation successful")
 
 
-def _validate_sample_object(sample, schemas, exclude_case=False):
+def _validate_sample_object(sample, schemas):
     """
     Internal function that allows to validate a peppy.Sample object without
     requiring a reference to peppy.Project.
 
     :param peppy.Sample sample: a sample object to validate
     :param list[dict] schemas: list of schemas to validate against or a path to one
-    :param bool exclude_case: whether to exclude validated objects
-        from the error. Useful when used ith large projects
     """
     for schema_dict in schemas:
         schema_dict = preprocess_schema(schema_dict)
         sample_schema_dict = schema_dict[PROP_KEY]["_samples"]["items"]
-        _validate_object(sample, sample_schema_dict, exclude_case)
+        _validate_object(sample.to_dict(), sample_schema_dict)
         _LOGGER.debug(
             f"{getattr(sample, 'sample_name', '')} sample validation successful"
         )
 
 
-def validate_sample(project, sample_name, schema, exclude_case=False):
+def validate_sample(project, sample_name, schema):
     """
     Validate the selected sample object against a schema
 
     :param peppy.Project project: a project object to validate
     :param str | int sample_name: name or index of the sample to validate
     :param str | dict schema: schema dict to validate against or a path to one
-    :param bool exclude_case: whether to exclude validated objects
-        from the error. Useful when used ith large projects
     """
     sample = (
         project.samples[sample_name]
@@ -97,18 +107,17 @@ def validate_sample(project, sample_name, schema, exclude_case=False):
         else project.get_sample(sample_name)
     )
     _validate_sample_object(
-        sample=sample, schemas=read_schema(schema=schema), exclude_case=exclude_case
+        sample=sample,
+        schemas=read_schema(schema=schema),
     )
 
 
-def validate_config(project, schema, exclude_case=False):
+def validate_config(project, schema):
     """
     Validate the config part of the Project object against a schema
 
     :param peppy.Project project: a project object to validate
     :param str | dict schema: schema dict to validate against or a path to one
-    :param bool exclude_case: whether to exclude validated objects
-        from the error. Useful when used ith large projects
     """
     schema_dicts = read_schema(schema=schema)
     for schema_dict in schema_dicts:
@@ -123,79 +132,89 @@ def validate_config(project, schema, exclude_case=False):
             except ValueError:
                 pass
         project_dict = project.to_dict()
-        _validate_object(project_dict, schema_cpy, exclude_case)
+        _validate_object(project_dict, schema_cpy)
         _LOGGER.debug("Config validation successful")
 
 
-def validate_inputs(sample, schema, exclude_case=False):
+def _get_attr_values(obj, attrlist):
+    """
+    Get value corresponding to each given attribute.
+
+    :param Mapping obj: an object to get the attributes from
+    :param str | Iterable[str] attrlist: names of attributes to
+        retrieve values for
+    :return dict: value corresponding to
+        each named attribute; null if this Sample's value for the
+        attribute given by the argument to the "attrlist" parameter is
+        empty/null, or if this Sample lacks the indicated attribute
     """
-    Determine which of this Sample's required attributes/files are missing
-    and calculate sizes of the inputs.
+    # If attribute is None, then value is also None.
+    if not attrlist:
+        return None
+    if not isinstance(attrlist, list):
+        attrlist = [attrlist]
+    # Strings contained here are appended later so shouldn't be null.
+    return list(flatten([getattr(obj, attr, "") for attr in attrlist]))
+
+
+def validate_input_files(project, schemas, sample_name=None):
+    """
+    Determine which of the required and optional files are missing.
 
     The names of the attributes that are required and/or deemed as inputs
-    are sourced from the schema, more specifically from required_input_attrs
-    and input_attrs sections in samples section. Note, this function does
-    perform actual Sample object validation with jsonschema.
-
-    :param peppy.Sample sample: sample to investigate
-    :param list[dict] | str schema: schema dict to validate against or a path to one
-    :return dict: dictionary with validation data, i.e missing,
-        required_inputs, all_inputs, input_file_size
-    :param bool exclude_case: whether to exclude validated objects
-        from the error. Useful when used ith large projects
-    :raise ValidationError: if any required sample attribute is missing
+    are sourced from the schema, more specifically from `required_files`
+    and `files` sections in samples section:
+
+    - If any of the required files are missing, this function raises an error.
+    - If any of the optional files are missing, the function raises a warning.
+
+    Note, this function also performs Sample object validation with jsonschema.
+
+    :param peppy.Project project: project that defines the samples to validate
+    :param str | dict schema: schema dict to validate against or a path to one
+    :param str | int sample_name: name or index of the sample to validate. If None,
+        validate all samples in the project
+    :raise PathAttrNotFoundError: if any required sample attribute is missing
     """
 
-    def _get_attr_values(obj, attrlist):
-        """
-        Get value corresponding to each given attribute.
-
-        :param Mapping obj: an object to get the attributes from
-        :param str | Iterable[str] attrlist: names of attributes to
-            retrieve values for
-        :return dict: value corresponding to
-            each named attribute; null if this Sample's value for the
-            attribute given by the argument to the "attrlist" parameter is
-            empty/null, or if this Sample lacks the indicated attribute
-        """
-        # If attribute is None, then value is also None.
-        if not attrlist:
-            return None
-        if not isinstance(attrlist, list):
-            attrlist = [attrlist]
-        # Strings contained here are appended later so shouldn't be null.
-        return list(flatten([getattr(obj, attr, "") for attr in attrlist]))
-
-    if isinstance(schema, str):
-        schema = read_schema(schema)
-
-    # validate attrs existence first
-    _validate_sample_object(schemas=schema, sample=sample, exclude_case=exclude_case)
-
-    all_inputs = set()
-    required_inputs = set()
-    schema = schema[-1]  # use only first schema, in case there are imports
-    sample_schema_dict = schema["properties"]["_samples"]["items"]
-    if FILES_KEY in sample_schema_dict:
-        all_inputs.update(_get_attr_values(sample, sample_schema_dict[FILES_KEY]))
-    if REQUIRED_FILES_KEY in sample_schema_dict:
-        required_inputs = set(
-            _get_attr_values(sample, sample_schema_dict[REQUIRED_FILES_KEY])
+    if sample_name is None:
+        samples = project.samples
+    else:
+        samples = (
+            project.samples[sample_name]
+            if isinstance(sample_name, int)
+            else project.get_sample(sample_name)
         )
-        all_inputs.update(required_inputs)
-    with cw(record=True) as w:
-        input_file_size = sum(
-            [size(f, size_str=False) or 0.0 for f in all_inputs if f != ""]
-        ) / (1024 ** 3)
-        if w:
-            _LOGGER.warning(
-                f"{len(w)} input files missing, job input size was "
-                f"not calculated accurately"
+        samples = [samples]
+
+    if isinstance(schemas, str):
+        schemas = read_schema(schemas)
+
+    for sample in samples:
+        # validate attrs existence first
+        _validate_sample_object(schemas=schemas, sample=sample)
+
+        all_inputs = set()
+        required_inputs = set()
+        schema = schemas[-1]  # use only first schema, in case there are imports
+        sample_schema_dict = schema["properties"]["_samples"]["items"]
+        if FILES_KEY in sample_schema_dict:
+            all_inputs.update(_get_attr_values(sample, sample_schema_dict[FILES_KEY]))
+        if REQUIRED_FILES_KEY in sample_schema_dict:
+            required_inputs = set(
+                _get_attr_values(sample, sample_schema_dict[REQUIRED_FILES_KEY])
+            )
+            all_inputs.update(required_inputs)
+
+        missing_required_inputs = [i for i in required_inputs if not os.path.exists(i)]
+        missing_inputs = [i for i in all_inputs if not os.path.exists(i)]
+        if missing_inputs:
+            warn(
+                f"For sample '{getattr(sample, project.sample_table_index)}'. "
+                f"Optional inputs not found: {missing_inputs}"
+            )
+        if missing_required_inputs:
+            raise PathAttrNotFoundError(
+                f"For sample '{getattr(sample, project.sample_table_index)}'. "
+                f"Required inputs not found: {required_inputs}"
             )
-
-    return {
-        MISSING_KEY: [i for i in required_inputs if not os.path.exists(i)],
-        REQUIRED_INPUTS_KEY: required_inputs,
-        ALL_INPUTS_KEY: all_inputs,
-        INPUT_FILE_SIZE_KEY: input_file_size,
-    }
diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt
index 882b8d2..6c9f39c 100644
--- a/requirements/requirements-all.txt
+++ b/requirements/requirements-all.txt
@@ -1,5 +1,5 @@
 jsonschema>=3.0.1
 logmuse>=0.2.5
 pandas
-peppy>=0.32.0
+peppy>=0.35.5
 ubiquerg>=0.5.2
diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt
deleted file mode 100644
index 10fe0d1..0000000
--- a/requirements/requirements-dev.txt
+++ /dev/null
@@ -1,2 +0,0 @@
--e git+https://github.com/pepkit/attmap@dev#egg=attmap
--e git+https://github.com/pepkit/peppy@dev#egg=peppy
diff --git a/setup.py b/setup.py
index 929b295..cf2509c 100644
--- a/setup.py
+++ b/setup.py
@@ -19,6 +19,7 @@ with open("requirements/requirements-all.txt", "r") as reqs_file:
 
 extra["install_requires"] = DEPENDENCIES
 
+
 # Additional files to include with package
 def get_static(name, condition=None):
     static = [
@@ -54,10 +55,10 @@ setup(
     classifiers=[
         "Development Status :: 4 - Beta",
         "License :: OSI Approved :: BSD License",
-        "Programming Language :: Python :: 3.6",
-        "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
+        "Programming Language :: Python :: 3.10",
+        "Programming Language :: Python :: 3.11",
         "Topic :: Scientific/Engineering :: Bio-Informatics",
     ],
     keywords="project, metadata, bioinformatics, sequencing, ngs, workflow",
diff --git a/webeido/webeido.py b/webeido/webeido.py
index 3969ad3..d67a7b3 100644
--- a/webeido/webeido.py
+++ b/webeido/webeido.py
@@ -68,7 +68,9 @@ async def validate_specific_schema(
 # Provide an external schema
 @app.post("/validate_one")
 async def validate_one(
-    request: Request, files: List[UploadFile] = File(...), schemas_to_test=schemas_to_test
+    request: Request,
+    files: List[UploadFile] = File(...),
+    schemas_to_test=schemas_to_test,
 ):
     # TODO implement this
     return False
@@ -104,7 +106,9 @@ async def validate_fromhub(namespace: str, pep_id: str):
 
 @app.post("/validate")
 async def validate_pep(
-    request: Request, files: List[UploadFile] = File(...), schemas_to_test=schemas_to_test
+    request: Request,
+    files: List[UploadFile] = File(...),
+    schemas_to_test=schemas_to_test,
 ):
     ufiles = []
     upload_folder = "uploads"


Test patch:
diff --git a/.github/workflows/run-pytest.yml b/.github/workflows/run-pytest.yml
index dbdf8a4..89ffd78 100644
--- a/.github/workflows/run-pytest.yml
+++ b/.github/workflows/run-pytest.yml
@@ -9,7 +9,7 @@ jobs:
     runs-on: ${{ matrix.os }}
     strategy:
       matrix:
-        python-version: ["3.6", "3.9", "3.10"]
+        python-version: ["3.8", "3.11"]
         os: [ubuntu-latest]
 
     steps:
@@ -20,10 +20,7 @@ jobs:
       with:
         python-version: ${{ matrix.python-version }}
 
-    - name: Install dev dependancies
-      run: if [ -f requirements/requirements-dev.txt ]; then pip install -r requirements/requirements-dev.txt; fi
-
-    - name: Install test dependancies
+    - name: Install test dependencies
       run: if [ -f requirements/requirements-test.txt ]; then pip install -r requirements/requirements-test.txt; fi
 
     - name: Install package
diff --git a/tests/conftest.py b/tests/conftest.py
index f7b16dc..905591e 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -90,9 +90,14 @@ def output_pep_with_fasta_column(path_pep_with_fasta_column):
 
 @pytest.fixture
 def taxprofiler_csv_multiline_output(path_to_taxprofiler_csv_multiline_output):
-    return pd.read_csv(path_to_taxprofiler_csv_multiline_output).to_csv(
-        path_or_buf=None, index=None
-    )
+    with open(path_to_taxprofiler_csv_multiline_output, "r") as file:
+        data = file.read()
+    return data
+    # This is broken unless I add na_filter=False. But it's a bad idea anyway, since
+    # we're just using this for string comparison anyway...
+    return pd.read_csv(
+        path_to_taxprofiler_csv_multiline_output, na_filter=False
+    ).to_csv(path_or_buf=None, index=None)
 
 
 @pytest.fixture
@@ -116,3 +121,13 @@ def output_pep_nextflow_taxprofiler(path_pep_nextflow_taxprofiler):
 @pytest.fixture
 def save_result_mock(mocker):
     return mocker.patch("eido.conversion.save_result")
+
+
+@pytest.fixture
+def test_file_existence_schema(schemas_path):
+    return os.path.join(schemas_path, "schema_test_file_exist.yaml")
+
+
+@pytest.fixture
+def test_file_existance_pep(peps_path):
+    return os.path.join(peps_path, "test_file_existence", "project_config.yaml")
diff --git a/tests/data/peps/test_file_existence/project_config.yaml b/tests/data/peps/test_file_existence/project_config.yaml
new file mode 100644
index 0000000..23ebfee
--- /dev/null
+++ b/tests/data/peps/test_file_existence/project_config.yaml
@@ -0,0 +1,12 @@
+pep_version: "2.1.0"
+sample_table: sample_table.csv
+subsample_table: subsample_table.csv
+
+
+sample_modifiers:
+  append:
+    local_files: LOCAL
+  derive:
+    attributes: [local_files]
+    sources:
+      LOCAL: "../data/{file_path}"
diff --git a/tests/data/peps/test_file_existence/sample_table.csv b/tests/data/peps/test_file_existence/sample_table.csv
new file mode 100644
index 0000000..1137443
--- /dev/null
+++ b/tests/data/peps/test_file_existence/sample_table.csv
@@ -0,0 +1,5 @@
+sample_name,protocol,identifier
+frog_1,anySampleType,frog1
+frog_2,anySampleType,frog2
+frog_3,anySampleType,frog3
+frog_4,anySampleType,frog4
diff --git a/tests/data/peps/test_file_existence/subsample_table.csv b/tests/data/peps/test_file_existence/subsample_table.csv
new file mode 100644
index 0000000..1d4f955
--- /dev/null
+++ b/tests/data/peps/test_file_existence/subsample_table.csv
@@ -0,0 +1,6 @@
+sample_name,file_path,subsample_name
+frog_1,file/a.txt,a
+frog_1,file/b.txt,b
+frog_1,file/c.txt,c
+frog_2,file/a.txt,a
+frog_2,file/b.txt,b
diff --git a/tests/data/schemas/schema_test_file_exist.yaml b/tests/data/schemas/schema_test_file_exist.yaml
new file mode 100644
index 0000000..8653210
--- /dev/null
+++ b/tests/data/schemas/schema_test_file_exist.yaml
@@ -0,0 +1,35 @@
+description: test existing files in subsamples
+
+properties:
+  dcc:
+    type: object
+    properties:
+      compute_packages:
+        type: object
+  samples:
+    type: array
+    items:
+      type: object
+      properties:
+        sample_name:
+          type: string
+        protocol:
+          type: string
+        local_files:
+          anyOf:
+            - type: string
+            - type: array
+              items:
+                type: string
+      files:
+        - local_files
+
+      required_files:
+        - local_files
+
+      required:
+        - sample_name
+        - local_files
+
+required:
+  - samples
diff --git a/tests/data/schemas/test_schema_invalid_with_type.yaml b/tests/data/schemas/test_schema_invalid_with_type.yaml
new file mode 100644
index 0000000..9815bda
--- /dev/null
+++ b/tests/data/schemas/test_schema_invalid_with_type.yaml
@@ -0,0 +1,25 @@
+description: test PEP schema
+type: object
+properties:
+  dcc:
+    type: object
+    properties:
+      compute_packages:
+        type: object
+  samples:
+    type: array
+    items:
+      type: object
+      properties:
+        sample_name:
+          type: string
+        protocol:
+          type: string
+        genome:
+          type: string
+  invalid:
+    type: string
+
+required:
+  - samples
+  - invalid
diff --git a/tests/test_conversions.py b/tests/test_conversions.py
index c12990c..179020e 100644
--- a/tests/test_conversions.py
+++ b/tests/test_conversions.py
@@ -9,7 +9,9 @@ class TestConversionInfrastructure:
     def test_plugins_contents(self):
         avail_plugins = pep_conversion_plugins()
         avail_filters = get_available_pep_filters()
-        assert all([plugin_name in avail_filters for plugin_name in avail_plugins.keys()])
+        assert all(
+            [plugin_name in avail_filters for plugin_name in avail_plugins.keys()]
+        )
 
     def test_plugins_are_callable(self):
         avail_plugins = pep_conversion_plugins()
diff --git a/tests/test_validations.py b/tests/test_validations.py
index fb93d06..a217ca4 100644
--- a/tests/test_validations.py
+++ b/tests/test_validations.py
@@ -6,7 +6,7 @@ from peppy import Project
 from peppy.utils import load_yaml
 
 from eido import *
-from eido.exceptions import EidoValidationError
+from eido.exceptions import EidoValidationError, PathAttrNotFoundError
 
 
 def _check_remote_file_accessible(url):
@@ -57,7 +57,9 @@ class TestProjectValidation:
 
 class TestSampleValidation:
     @pytest.mark.parametrize("sample_name", [0, 1, "GSM1558746"])
-    def test_validate_works(self, project_object, sample_name, schema_samples_file_path):
+    def test_validate_works(
+        self, project_object, sample_name, schema_samples_file_path
+    ):
         validate_sample(
             project=project_object,
             sample_name=sample_name,
@@ -136,3 +138,11 @@ class TestProjectWithoutConfigValidation:
             validate_project(
                 project=Project(remote_pep_cfg), schema=schema_invalid_file_path
             )
+
+    def test_validate_file_existance(
+        self, test_file_existance_pep, test_file_existence_schema
+    ):
+        schema_path = test_file_existence_schema
+        prj = Project(test_file_existance_pep)
+        with pytest.raises(PathAttrNotFoundError):
+            validate_input_files(prj, schema_path)