Skip to content

Commit

Permalink
Merge pull request #7 from pepkit/dev
Browse files Browse the repository at this point in the history
v0.0.4
  • Loading branch information
stolarczyk authored Jan 31, 2020
2 parents bf586a8 + bc1e0f7 commit 35e7753
Show file tree
Hide file tree
Showing 8 changed files with 173 additions and 17 deletions.
7 changes: 7 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

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.0.4] - 2020-01-31
### Added
- `validate_sample` function for sample level validation
- sample validation CLI support (via `-n`/`--sample-name` argument)
- `validate_config` to facilitate samples exclusion in validation
- config validation CLI support (via `-c`/`--just-config` argument)

## [0.0.3] - 2020-01-30
### Added
- Option to exclude the validation case from error messages in both Python API and CLI app with `exclude_case` and `-e`/`--exclude-case`, respectively.
Expand Down
2 changes: 1 addition & 1 deletion eido/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
from .const import *
from .eido import *

__all__ = ["validate_project"]
__all__ = ["validate_project", "validate_sample", "validate_config"]

logmuse.init_logger(PKG_NAME)
2 changes: 1 addition & 1 deletion eido/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.0.3"
__version__ = "0.0.4"
118 changes: 105 additions & 13 deletions eido/eido.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os
import jsonschema
import oyaml as yaml
from copy import deepcopy as dpcpy

import logmuse
from ubiquerg import VersionInHelpParser
Expand Down Expand Up @@ -40,6 +41,17 @@ def build_argparser():
help="Whether to exclude the validation case from an error. "
"Only the human readable message explaining the error will be raised. "
"Useful when validating large PEPs.")

group = parser.add_mutually_exclusive_group()

group.add_argument(
"-n", "--sample-name", required=False,
help="Name or index of the sample to validate. Only this sample will be validated.")

group.add_argument(
"-c", "--just-config", required=False, action="store_true", default=False,
help="Whether samples should be excluded from the validation.")

return parser


Expand Down Expand Up @@ -73,30 +85,97 @@ def _load_yaml(filepath):
return data


def validate_project(project, schema, exclude_case=False):
def _read_schema(schema):
"""
Validate a project object against a schema
Safely read schema from YAML-formatted file.
:param peppy.Project project: a project object to validate
:param str | Mapping schema: path to the schema file
or schema in a dict form
:return dict: read schema
:raise TypeError: if the schema arg is neither a Mapping nor a file path
"""
if isinstance(schema, str) and os.path.isfile(schema):
return _load_yaml(schema)
elif isinstance(schema, dict):
return schema
raise TypeError("schema has to be either a dict or a path to an existing file")


def _validate_object(object, schema, exclude_case=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
"""
if isinstance(schema, str) and os.path.isfile(schema):
schema_dict = _load_yaml(schema)
elif isinstance(schema, dict):
schema_dict = schema
else:
raise TypeError("schema has to be either a dict or a path to an existing file")
project_dict = project.to_dict()
try:
jsonschema.validate(project_dict, _preprocess_schema(schema_dict))
jsonschema.validate(object, schema)
except jsonschema.exceptions.ValidationError as e:
if not exclude_case:
raise e
raise jsonschema.exceptions.ValidationError(e.message)


def validate_project(project, schema, exclude_case=False):
"""
Validate a project object against a schema
:param peppy.Sample 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_dict = _read_schema(schema=schema)
project_dict = project.to_dict()
_validate_object(project_dict, _preprocess_schema(schema_dict), exclude_case)
_LOGGER.debug("Project validation successful")


def validate_sample(project, sample_name, schema, exclude_case=False):
"""
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
"""
schema_dict = _read_schema(schema=schema)
sample_dict = project.samples[sample_name] if isinstance(sample_name, int) \
else project.get_sample(sample_name)
sample_schema_dict = schema_dict["properties"]["samples"]["items"]
_validate_object(sample_dict, sample_schema_dict, exclude_case)
_LOGGER.debug("'{}' sample validation successful".format(sample_name))


def validate_config(project, schema, exclude_case=False):
"""
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_dict = _read_schema(schema=schema)
schema_cpy = dpcpy(schema_dict)
try:
del schema_cpy["properties"]["samples"]
except KeyError:
pass
if "required" in schema_cpy:
try:
schema_cpy["required"].remove("samples")
except ValueError:
pass
project_dict = project.to_dict()
_validate_object(project_dict, schema_cpy, exclude_case)
_LOGGER.debug("Config validation successful")


def main():
""" Primary workflow """
parser = logmuse.add_logging_options(build_argparser())
Expand All @@ -108,5 +187,18 @@ def main():
_LOGGER = logmuse.logger_via_cli(args)
_LOGGER.debug("Creating a Project object from: {}".format(args.pep))
p = Project(args.pep)
_LOGGER.debug("Comparing the Project ('{}') against a schema: {}.".format(args.pep, args.schema))
validate_project(p, args.schema, args.exclude_case)
if args.sample_name:
try:
args.sample_name = int(args.sample_name)
except ValueError:
pass
_LOGGER.debug("Comparing Sample ('{}') in the Project "
"('{}') against a schema: {}.".format(args.sample_name, args.pep, args.schema))
validate_sample(p, args.sample_name, args.schema, args.exclude_case)
elif args.just_config:
_LOGGER.debug("Comparing config ('{}') against a schema: {}.".format(args.pep, args.schema))
validate_config(p, args.schema, args.exclude_case)
else:
_LOGGER.debug("Comparing Project ('{}') against a schema: {}.".format(args.pep, args.schema))
validate_project(p, args.schema, args.exclude_case)
_LOGGER.info("Validation successful")
6 changes: 5 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,8 @@ def schema_samples_file_path(schemas_path):

@pytest.fixture
def schema_invalid_file_path(schemas_path):
return os.path.join(schemas_path, "test_schema_invalid.yaml")
return os.path.join(schemas_path, "test_schema_invalid.yaml")

@pytest.fixture
def schema_sample_invalid_file_path(schemas_path):
return os.path.join(schemas_path, "test_schema_sample_invalid.yaml")
27 changes: 27 additions & 0 deletions tests/data/schemas/test_schema_sample_invalid.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
description: test PEP schema

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
newattr:
type: string
required:
- newattr

required:
- dcc
- samples
7 changes: 6 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import subprocess
import pytest

class TestCLI:
def test_cli_help(self):
Expand All @@ -7,4 +8,8 @@ def test_cli_help(self):

def test_cli_works(self, project_file_path, schema_file_path):
out = subprocess.check_call(['eido', '-p', project_file_path, '-s', schema_file_path])
assert out == 0
assert out == 0

def test_cli_exclusiveness(self, project_file_path, schema_file_path):
with pytest.raises(subprocess.CalledProcessError):
subprocess.check_call(['eido', '-p', project_file_path, '-s', schema_file_path, '-s', 'name', '-c'])
21 changes: 21 additions & 0 deletions tests/test_project_validation.py → tests/test_validations.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,24 @@ def test_validate_works_with_dict_schema(self, project_object, schema_file_path)
def test_validate_raises_error_for_incorrect_schema_type(self, project_object, schema_arg):
with pytest.raises(TypeError):
validate_project(project=project_object, schema=schema_arg)


class TestSampleValidation:
@pytest.mark.parametrize("sample_name", [0, 1, "GSM1558746"])
def test_validate_works(self, project_object, sample_name, schema_samples_file_path):
validate_sample(project=project_object, sample_name=sample_name, schema=schema_samples_file_path)

@pytest.mark.parametrize("sample_name", [22, "bogus_sample_name"])
def test_validate_raises_error_for_incorrect_sample_name(self, project_object, sample_name, schema_samples_file_path):
with pytest.raises((ValueError, IndexError)):
validate_sample(project=project_object, sample_name=sample_name, schema=schema_samples_file_path)

@pytest.mark.parametrize("sample_name", [0, 1, "GSM1558746"])
def test_validate_detects_invalid(self, project_object, sample_name, schema_sample_invalid_file_path):
with pytest.raises(ValidationError):
validate_sample(project=project_object, sample_name=sample_name, schema=schema_sample_invalid_file_path)


class TestConfigValidation:
def test_validate_succeeds_on_invalid_sample(self, project_object, schema_sample_invalid_file_path):
validate_config(project=project_object, schema=schema_sample_invalid_file_path)

0 comments on commit 35e7753

Please sign in to comment.