Skip to content

Commit

Permalink
Merge pull request #16 from datosgobar/0.1.1
Browse files Browse the repository at this point in the history
0.1.1
  • Loading branch information
abenassi committed Dec 14, 2016
2 parents 2a5a4b2 + e2259b7 commit e632cd6
Show file tree
Hide file tree
Showing 74 changed files with 4,480 additions and 518 deletions.
6 changes: 6 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
History
===

0.1.1 (2016-12-01)
------------------

* Se incorpora validación de tipo y formato de campo
* Los métodos `DataJson.is_valid_catalog()` y `DataJson.validate_catalog()` ahora aceptan un `dict` además de un `path/to/data.json` o una url a un data.json.

0.1.0 (2016-12-01)
------------------

Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ Ambos métodos mencionados de `DataJson()` son capaces de validar archivos `data
- Para validar un **archivo local**, `datajson_path_or_url` deberá ser el **path absoluto** a él.
- Para validar un **archivo remoto**, `datajson_path_or_url` deberá ser una **URL que comience con 'http' o 'https'**.

Alternativamente, también se pueden validar **diccionarios**, es decir, el resultado de deserializar un archivo `data.json` en una variable.

Por conveniencia, la carpeta [`tests/samples/`](tests/samples/) contiene varios ejemplos de `data.json`s bien y mal formados con distintos tipos de errores.

### Ejemplos
Expand Down Expand Up @@ -136,6 +138,21 @@ print validation_report
}
```

### Diccionario (data.json deserializado)

El siguiente fragmento de código tendrá resultados idénticos al primero:
```python
import json
datajson_path = "tests/samples/full_data.json"

datajson = json.load(datajson_path)

validation_result = dj.is_valid_catalog(datajson)
validation_report = dj.validate_catalog(datajson)
(...)

```

## Tests

Los tests se corren con `nose`. Desde la raíz del repositorio:
Expand Down
2 changes: 1 addition & 1 deletion pydatajson/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@

__author__ = """Datos Argentina"""
__email__ = 'datos@modernizacion.gob.ar'
__version__ = '0.1.0'
__version__ = '0.1.1'
122 changes: 81 additions & 41 deletions pydatajson/pydatajson.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
from __future__ import print_function
from __future__ import with_statement

import sys
import os.path
from urlparse import urljoin, urlparse
import warnings
import json
from pprint import pprint
import jsonschema
import requests

Expand Down Expand Up @@ -65,52 +67,60 @@ def _create_validator(cls, schema_filename, schema_dir):
`schema_filename` dentro de `schema_dir`.
"""
schema_path = os.path.join(schema_dir, schema_filename)
schema = cls._deserialize_json(schema_path)
schema = cls._json_to_dict(schema_path)

# Según https://github.com/Julian/jsonschema/issues/98
# Permite resolver referencias locales a otros esquemas.
resolver = jsonschema.RefResolver(
base_uri=urljoin('file:', schema_path), referrer=schema)

format_checker = jsonschema.FormatChecker()

validator = jsonschema.Draft4Validator(
schema=schema, resolver=resolver)
schema=schema, resolver=resolver, format_checker=format_checker)

return validator

@staticmethod
def _deserialize_json(json_path_or_url):
def _json_to_dict(dict_or_json_path):
"""Toma el path a un JSON y devuelve el diccionario que representa.
Asume que el parámetro es una URL si comienza con 'http' o 'https', o
un path local de lo contrario.
Si el argumento es un dict, lo deja pasar. Si es un string asume que el
parámetro es una URL si comienza con 'http' o 'https', o un path local
de lo contrario.
Args:
json_path_or_url (str): Path local o URL remota a un archivo de
texto plano en formato JSON.
dict_or_json_path (dict or str): Si es un str, path local o URL
remota a un archivo de texto plano en formato JSON.
Returns:
dict: El diccionario que resulta de deserializar
json_path_or_url.
dict_or_json_path.
"""
parsed_url = urlparse(json_path_or_url)
assert isinstance(dict_or_json_path, (dict, str, unicode))

if isinstance(dict_or_json_path, dict):
return dict_or_json_path

parsed_url = urlparse(dict_or_json_path)

if parsed_url.scheme in ["http", "https"]:
req = requests.get(json_path_or_url)
req = requests.get(dict_or_json_path)
json_string = req.content

else:
# En caso de que json_path_or_url parezca ser una URL remota,
# En caso de que dict_or_json_path parezca ser una URL remota,
# advertirlo
path_start = parsed_url.path.split(".")[0]
if path_start == "www" or path_start.isdigit():
warnings.warn("""
La dirección del archivo JSON ingresada parece una URL, pero no comienza
con 'http' o 'https' así que será tratada como una dirección local. ¿Tal vez
quiso decir 'http://{}'?
""".format(json_path_or_url).encode("utf8"))
""".format(dict_or_json_path).encode("utf8"))

with open(json_path_or_url) as json_file:
with open(dict_or_json_path) as json_file:
json_string = json_file.read()

json_dict = json.loads(json_string, encoding="utf8")
Expand All @@ -129,7 +139,7 @@ def is_valid_catalog(self, datajson_path):
Returns:
bool: True si el data.json cumple con el schema, sino False.
"""
datajson = self._deserialize_json(datajson_path)
datajson = self._json_to_dict(datajson_path)
res = self.validator.is_valid(datajson)
return res

Expand All @@ -156,44 +166,74 @@ def validate_catalog(self, datajson_path):
}
}
"""
datajson = self._deserialize_json(datajson_path)

# Genero árbol de errores para explorarlo
error_tree = jsonschema.ErrorTree(self.validator.iter_errors(datajson))

def _dataset_result(index, dataset):
"""Dado un dataset y su índice en el data.json, devuelve una
diccionario con el resultado de su validación. """
dataset_total_errors = error_tree["dataset"][index].total_errors

result = {
"status": "OK" if dataset_total_errors == 0 else "ERROR",
"title": dataset.get("title")
}

return result

datasets_results = [
_dataset_result(i, ds) for i, ds in enumerate(datajson["dataset"])
]
datajson = self._json_to_dict(datajson_path)

res = {
"status": "OK" if error_tree.total_errors == 0 else "ERROR",
# La respuesta por default se devuelve si no hay errores
default_response = {
"status": "OK",
"error": {
"catalog": {
"status": "OK" if error_tree.errors == {} else "ERROR",
"status": "OK",
"title": datajson.get("title")
},
"dataset": datasets_results
# "dataset" contiene lista de rtas default si el catálogo
# contiene la clave "dataset" y además su valor es una lista.
# En caso contrario "dataset" es None.
"dataset": [
{
"status": "OK",
"title": dataset.get("title")
} for dataset in datajson["dataset"]
] if ("dataset" in datajson and
isinstance(datajson["dataset"], list)) else None
}
}

return res
def _update_response(validation_error, response):
"""Actualiza la respuesta por default acorde a un error de
validación."""
new_response = response.copy()

# El status del catálogo entero será ERROR
new_response["status"] = "ERROR"

path = validation_error.path

if len(path) >= 2 and path[0] == "dataset":
# El error está a nivel de un dataset particular o inferior
new_response["error"]["dataset"][path[1]]["status"] = "ERROR"
else:
# El error está a nivel de catálogo
new_response["error"]["catalog"]["status"] = "ERROR"

return new_response

# Genero la lista de errores en la instancia a validar
errors_iterator = self.validator.iter_errors(datajson)

final_response = default_response.copy()
for error in errors_iterator:
final_response = _update_response(error, final_response)

return final_response


def main():
"""En caso de ejecutar el módulo como script, se corre esta función."""
pass
"""Permite ejecutar el módulo por línea de comandos.
Valida un path o url a un archivo data.json devolviendo True/False si es
válido y luego el resultado completo.
Example:
python pydatajson.py http://181.209.63.71/data.json
python pydatajson.py ~/github/pydatajson/tests/samples/full_data.json
"""
datajson_file = sys.argv[1]
dj = DataJson()
bool_res = dj.is_valid_catalog(datajson_file)
full_res = dj.validate_catalog(datajson_file)
pprint(bool_res)
pprint(full_res)


if __name__ == '__main__':
Expand Down
42 changes: 41 additions & 1 deletion pydatajson/schemas/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,51 @@
"properties": {
"publisher": {
"type": "object",
"required": ["name", "mbox"]
"required": ["name", "mbox"],
"properties": {
"name": { "$ref": "mixed-types.json#nonEmptyString" },
"mbox": {
"type": "string",
"format": "email"
}
}
},
"dataset": {
"type": "array",
"items": { "$ref": "dataset.json" }
},
"title": { "$ref": "mixed-types.json#nonEmptyString" },
"description": { "$ref": "mixed-types.json#nonEmptyString" },
"superThemeTaxonomy": { "type": "string", "format": "uri" },
"issued": { "$ref": "mixed-types.json#dateOrDatetimeStringOrNull" },
"modified": { "$ref": "mixed-types.json#dateOrDatetimeStringOrNull" },
"language": { "$ref": "mixed-types.json#arrayOrNull" },
"themeTaxonomy": {
"anyOf": [
{
"type": "array",
"items": { "$ref": "theme.json" }
},
{ "type": "null" }
]
},
"license": { "$ref": "mixed-types.json#nonEmptyStringOrNull" },
"homepage": {
"anyOf": [
{
"type": "string",
"format": "uri"
},
{ "type": "null" }
]
},
"rights": { "$ref": "mixed-types.json#nonEmptyStringOrNull" },
"spatial": {
"anyOf": [
{ "type": "string" },
{ "type": "array" },
{ "type": "null" }
]
}
}
}
65 changes: 63 additions & 2 deletions pydatajson/schemas/dataset.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,72 @@
"properties": {
"publisher": {
"type": "object",
"required": ["name"]
"required": ["name"],
"properties": {
"name": { "$ref": "mixed-types.json#nonEmptyString" },
"mbox": {
"anyOf": [
{
"type": "string",
"format": "email"
},
{ "type": "null" }
]
}
}
},
"distribution": {
"type": "array",
"items": { "$ref": "distribution.json" }
}
},
"title": { "$ref": "mixed-types.json#nonEmptyString" },
"description": { "$ref": "mixed-types.json#nonEmptyString" },
"issued": { "$ref": "mixed-types.json#dateOrDatetimeString" },
"superTheme": { "type": "array" },
"accrualPeriodicity": {
"anyOf" : [
{"type": "string", "pattern": "^R/P\\d+(\\.\\d+)?[Y|M|W|D]$"},
{"type": "string", "pattern": "^R/PT\\d+(\\.\\d+)?[H|M|S]$"},
{"type": "string", "pattern": "^eventual$"}
]
},
"contactPoint": {
"type": "object",
"properties": {
"fn": { "$ref": "mixed-types.json#nonEmptyStringOrNull" },
"hasEmail": {
"anyOf": [
{
"type": "string",
"format": "email"
},
{ "type": "null" }
]
}
}
},
"theme": { "$ref": "mixed-types.json#arrayOrNull" },
"keyword": { "$ref": "mixed-types.json#arrayOrNull" },
"modified": { "$ref": "mixed-types.json#dateOrDatetimeStringOrNull" },
"identifier": { "$ref": "mixed-types.json#nonEmptyStringOrNull" },
"language": { "$ref": "mixed-types.json#arrayOrNull" },
"spatial": {
"anyOf": [
{ "type": "string" },
{ "type": "array" },
{ "type": "null" }
]
},
"temporal": { "$ref": "mixed-types.json#nonEmptyStringOrNull" },
"landingPage": {
"anyOf": [
{
"type": "string",
"format": "uri"
},
{ "type": "null" }
]
},
"license": { "$ref": "mixed-types.json#nonEmptyStringOrNull" }
}
}

0 comments on commit e632cd6

Please sign in to comment.