dataproduct-cli 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dataproduct/__init__.py +5 -0
- dataproduct/cli.py +81 -0
- dataproduct/command_init.py +33 -0
- dataproduct/command_lint.py +58 -0
- dataproduct/command_publish.py +47 -0
- dataproduct/config/__init__.py +67 -0
- dataproduct/data_product.py +93 -0
- dataproduct/init/__init__.py +0 -0
- dataproduct/init/init_template.py +26 -0
- dataproduct/integration/__init__.py +0 -0
- dataproduct/integration/entropy_data.py +87 -0
- dataproduct/lint/__init__.py +0 -0
- dataproduct/lint/files.py +38 -0
- dataproduct/lint/resolve.py +11 -0
- dataproduct/lint/schema.py +44 -0
- dataproduct/lint/validate.py +61 -0
- dataproduct/model/__init__.py +4 -0
- dataproduct/model/exceptions.py +23 -0
- dataproduct/model/run.py +69 -0
- dataproduct/output/__init__.py +0 -0
- dataproduct/output/output_format.py +6 -0
- dataproduct/output/result_writer.py +77 -0
- dataproduct/py.typed +0 -0
- dataproduct/schemas/odps-1.0.0.init.yaml +37 -0
- dataproduct/schemas/odps-1.0.0.schema.json +517 -0
- dataproduct_cli-0.0.1.dist-info/METADATA +126 -0
- dataproduct_cli-0.0.1.dist-info/RECORD +31 -0
- dataproduct_cli-0.0.1.dist-info/WHEEL +5 -0
- dataproduct_cli-0.0.1.dist-info/entry_points.txt +2 -0
- dataproduct_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
- dataproduct_cli-0.0.1.dist-info/top_level.txt +1 -0
dataproduct/__init__.py
ADDED
dataproduct/cli.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
from importlib import metadata
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Iterable, Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from click import Context
|
|
9
|
+
from dotenv import find_dotenv, load_dotenv
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from typer.core import TyperGroup
|
|
12
|
+
from typing_extensions import Annotated
|
|
13
|
+
|
|
14
|
+
from dataproduct.output.output_format import OutputFormat
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
debug_option = Annotated[bool, typer.Option(help="Enable debug logging")]
|
|
19
|
+
|
|
20
|
+
# Order in which top-level commands appear in `dataproduct --help`.
|
|
21
|
+
COMMAND_ORDER = ["init", "lint", "publish"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OrderedCommands(TyperGroup):
|
|
25
|
+
def list_commands(self, ctx: Context) -> Iterable[str]:
|
|
26
|
+
known = set(COMMAND_ORDER)
|
|
27
|
+
return [c for c in COMMAND_ORDER if c in self.commands] + [c for c in self.commands if c not in known]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(
|
|
31
|
+
cls=OrderedCommands,
|
|
32
|
+
no_args_is_help=True,
|
|
33
|
+
add_completion=False,
|
|
34
|
+
pretty_exceptions_show_locals=False,
|
|
35
|
+
help="CLI for data products following the Open Data Product Standard (ODPS).",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def version_callback(value: bool) -> None:
|
|
40
|
+
if value:
|
|
41
|
+
try:
|
|
42
|
+
version = metadata.version("dataproduct-cli")
|
|
43
|
+
except metadata.PackageNotFoundError:
|
|
44
|
+
version = "0.0.0"
|
|
45
|
+
console.print(version)
|
|
46
|
+
raise typer.Exit()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.callback()
|
|
50
|
+
def common(
|
|
51
|
+
ctx: typer.Context,
|
|
52
|
+
version: Annotated[
|
|
53
|
+
Optional[bool],
|
|
54
|
+
typer.Option("--version", callback=version_callback, is_eager=True, help="Print the version and exit."),
|
|
55
|
+
] = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""dataproduct CLI."""
|
|
58
|
+
load_dotenv(find_dotenv(usecwd=True))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def enable_debug_logging(debug: bool, otherwise_disable_stderr: bool = False) -> None:
|
|
62
|
+
if debug:
|
|
63
|
+
logging.basicConfig(level=logging.DEBUG, stream=sys.stderr, format="%(asctime)s %(levelname)s %(message)s")
|
|
64
|
+
elif otherwise_disable_stderr:
|
|
65
|
+
logging.disable(logging.CRITICAL)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_output_format(output_format: Optional[OutputFormat], output: Optional[Path]) -> Optional[OutputFormat]:
|
|
69
|
+
if output_format is not None:
|
|
70
|
+
return output_format
|
|
71
|
+
if output is not None:
|
|
72
|
+
return OutputFormat.junit if str(output).endswith(".xml") else OutputFormat.json
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Register the commands (each module attaches itself to `app`).
|
|
77
|
+
from dataproduct import command_init, command_lint, command_publish # noqa: E402, F401
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main() -> None:
|
|
81
|
+
app()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from typing_extensions import Annotated
|
|
5
|
+
|
|
6
|
+
from dataproduct.cli import app, console, debug_option, enable_debug_logging
|
|
7
|
+
from dataproduct.init.init_template import get_init_template
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.command(
|
|
11
|
+
name="init",
|
|
12
|
+
epilog="Example: dataproduct init dataproduct.odps.yaml",
|
|
13
|
+
)
|
|
14
|
+
def init(
|
|
15
|
+
location: Annotated[
|
|
16
|
+
str, typer.Argument(help="The location of the data product file to create.")
|
|
17
|
+
] = "dataproduct.odps.yaml",
|
|
18
|
+
template: Annotated[str, typer.Option(help="URL or path of a template or data product")] = None,
|
|
19
|
+
overwrite: Annotated[bool, typer.Option(help="Replace the existing data product file")] = False,
|
|
20
|
+
debug: debug_option = None,
|
|
21
|
+
):
|
|
22
|
+
"""
|
|
23
|
+
Create a new data product file.
|
|
24
|
+
"""
|
|
25
|
+
enable_debug_logging(debug)
|
|
26
|
+
|
|
27
|
+
if not overwrite and os.path.exists(location):
|
|
28
|
+
console.print("File already exists, use --overwrite to overwrite")
|
|
29
|
+
raise typer.Exit(code=1)
|
|
30
|
+
template_str = get_init_template(template)
|
|
31
|
+
with open(location, "w") as f:
|
|
32
|
+
f.write(template_str)
|
|
33
|
+
console.print("📄 data product written to " + location)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from typing_extensions import Annotated
|
|
5
|
+
|
|
6
|
+
from dataproduct.cli import app, console, debug_option, enable_debug_logging, resolve_output_format
|
|
7
|
+
from dataproduct.config import cli_config
|
|
8
|
+
from dataproduct.data_product import DataProduct
|
|
9
|
+
from dataproduct.output.output_format import OutputFormat
|
|
10
|
+
from dataproduct.output.result_writer import write_result
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command(
|
|
14
|
+
name="lint",
|
|
15
|
+
epilog="Example: dataproduct lint dataproduct.odps.yaml",
|
|
16
|
+
)
|
|
17
|
+
def lint(
|
|
18
|
+
location: Annotated[
|
|
19
|
+
str,
|
|
20
|
+
typer.Argument(help="The location (url or local path) of the data product yaml."),
|
|
21
|
+
] = "dataproduct.odps.yaml",
|
|
22
|
+
schema: Annotated[
|
|
23
|
+
str,
|
|
24
|
+
typer.Option("--json-schema", help="The location (url or path) of the ODPS JSON Schema"),
|
|
25
|
+
] = None,
|
|
26
|
+
output: Annotated[
|
|
27
|
+
Path,
|
|
28
|
+
typer.Option(
|
|
29
|
+
help="File path to write the results to (e.g. './TEST-dataproduct.xml'). "
|
|
30
|
+
"If omitted, results are printed to stdout."
|
|
31
|
+
),
|
|
32
|
+
] = None,
|
|
33
|
+
output_format: Annotated[
|
|
34
|
+
OutputFormat,
|
|
35
|
+
typer.Option(help="The result format. Accepted values: json, junit."),
|
|
36
|
+
] = None,
|
|
37
|
+
all_errors: Annotated[
|
|
38
|
+
bool,
|
|
39
|
+
typer.Option(
|
|
40
|
+
"--all-errors",
|
|
41
|
+
help="Report all JSON Schema validation errors instead of stopping after the first one.",
|
|
42
|
+
),
|
|
43
|
+
] = False,
|
|
44
|
+
debug: debug_option = None,
|
|
45
|
+
):
|
|
46
|
+
"""
|
|
47
|
+
Validate that the data product is correctly formatted (against the ODPS JSON Schema).
|
|
48
|
+
"""
|
|
49
|
+
enable_debug_logging(debug, otherwise_disable_stderr=True)
|
|
50
|
+
|
|
51
|
+
output_format = resolve_output_format(output_format, output)
|
|
52
|
+
run = DataProduct(
|
|
53
|
+
config=cli_config(),
|
|
54
|
+
data_product_file=location,
|
|
55
|
+
schema_location=schema,
|
|
56
|
+
all_errors=all_errors,
|
|
57
|
+
).lint()
|
|
58
|
+
write_result(run, console, output_format, output)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from typing_extensions import Annotated
|
|
3
|
+
|
|
4
|
+
from dataproduct.cli import app, console, debug_option, enable_debug_logging
|
|
5
|
+
from dataproduct.config import cli_config
|
|
6
|
+
from dataproduct.integration.entropy_data import (
|
|
7
|
+
DataProductPublishError,
|
|
8
|
+
publish_data_product_to_entropy_data,
|
|
9
|
+
)
|
|
10
|
+
from dataproduct.lint.resolve import resolve_data_product_dict
|
|
11
|
+
from dataproduct.model.exceptions import DataProductException
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command(
|
|
15
|
+
name="publish",
|
|
16
|
+
epilog="Example: dataproduct publish dataproduct.odps.yaml",
|
|
17
|
+
)
|
|
18
|
+
def publish(
|
|
19
|
+
location: Annotated[
|
|
20
|
+
str,
|
|
21
|
+
typer.Argument(help="The location (url or local path) of the data product yaml."),
|
|
22
|
+
] = "dataproduct.odps.yaml",
|
|
23
|
+
schema: Annotated[
|
|
24
|
+
str,
|
|
25
|
+
typer.Option("--json-schema", help="The location (url or path) of the ODPS JSON Schema"),
|
|
26
|
+
] = None,
|
|
27
|
+
ssl_verification: Annotated[
|
|
28
|
+
bool,
|
|
29
|
+
typer.Option(help="SSL verification when publishing the data product."),
|
|
30
|
+
] = True,
|
|
31
|
+
debug: debug_option = None,
|
|
32
|
+
):
|
|
33
|
+
"""
|
|
34
|
+
Publish the data product to Entropy Data.
|
|
35
|
+
"""
|
|
36
|
+
enable_debug_logging(debug)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
data_product_dict = resolve_data_product_dict(location, config=cli_config())
|
|
40
|
+
publish_data_product_to_entropy_data(
|
|
41
|
+
data_product_dict=data_product_dict,
|
|
42
|
+
ssl_verification=ssl_verification,
|
|
43
|
+
config=cli_config(),
|
|
44
|
+
)
|
|
45
|
+
except (DataProductPublishError, DataProductException) as e:
|
|
46
|
+
console.print(f"[red]Failed publishing data product. Error: {e}[/red]")
|
|
47
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Programmatic configuration for hosts and API keys.
|
|
2
|
+
|
|
3
|
+
Every value the CLI reads from ``ENTROPY_DATA_*`` (and the legacy
|
|
4
|
+
``DATAMESH_MANAGER_*`` / ``DATACONTRACT_MANAGER_*``) environment variables can
|
|
5
|
+
also be provided programmatically via :class:`Config` (or a plain dict keyed by
|
|
6
|
+
the env var names). Reads fall back to the process environment.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from typing import Optional, Union
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Config:
|
|
14
|
+
def __init__(self, values: Optional[dict] = None):
|
|
15
|
+
self._values = dict(values or {})
|
|
16
|
+
|
|
17
|
+
def getenv(self, key: str) -> Optional[str]:
|
|
18
|
+
value = self._values.get(key)
|
|
19
|
+
if value is not None:
|
|
20
|
+
return value
|
|
21
|
+
return os.environ.get(key)
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def resolve(cls, config: "Optional[Union[Config, dict]]" = None) -> "Config":
|
|
25
|
+
if config is None:
|
|
26
|
+
return cls()
|
|
27
|
+
if isinstance(config, Config):
|
|
28
|
+
return config
|
|
29
|
+
if isinstance(config, dict):
|
|
30
|
+
return cls(config)
|
|
31
|
+
raise TypeError(f"Unsupported config type: {type(config)!r}")
|
|
32
|
+
|
|
33
|
+
# Hosts
|
|
34
|
+
def get_entropy_data_host(self) -> Optional[str]:
|
|
35
|
+
return self.getenv("ENTROPY_DATA_HOST")
|
|
36
|
+
|
|
37
|
+
def get_datamesh_manager_host(self) -> Optional[str]:
|
|
38
|
+
return self.getenv("DATAMESH_MANAGER_HOST")
|
|
39
|
+
|
|
40
|
+
def get_datacontract_manager_host(self) -> Optional[str]:
|
|
41
|
+
return self.getenv("DATACONTRACT_MANAGER_HOST")
|
|
42
|
+
|
|
43
|
+
# API keys
|
|
44
|
+
def get_entropy_data_api_key(self) -> Optional[str]:
|
|
45
|
+
return self.getenv("ENTROPY_DATA_API_KEY")
|
|
46
|
+
|
|
47
|
+
def get_datamesh_manager_api_key(self) -> Optional[str]:
|
|
48
|
+
return self.getenv("DATAMESH_MANAGER_API_KEY")
|
|
49
|
+
|
|
50
|
+
def get_datacontract_manager_api_key(self) -> Optional[str]:
|
|
51
|
+
return self.getenv("DATACONTRACT_MANAGER_API_KEY")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Simple process-wide "current" config set from the CLI entry point.
|
|
55
|
+
_cli_config = Config()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def set_cli_config(config: Config) -> None:
|
|
59
|
+
global _cli_config
|
|
60
|
+
_cli_config = config
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cli_config() -> Config:
|
|
64
|
+
return _cli_config
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
__all__ = ["Config", "cli_config", "set_cli_config"]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Core library entry point, parallel to datacontract-cli's ``DataContract``.
|
|
2
|
+
|
|
3
|
+
from dataproduct.data_product import DataProduct
|
|
4
|
+
|
|
5
|
+
run = DataProduct(data_product_file="dataproduct.odps.yaml").lint()
|
|
6
|
+
DataProduct(data_product_file="dataproduct.odps.yaml").publish()
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Optional, Union
|
|
10
|
+
|
|
11
|
+
from dataproduct.config import Config
|
|
12
|
+
from dataproduct.integration.entropy_data import publish_data_product_to_entropy_data
|
|
13
|
+
from dataproduct.lint.files import read_resource
|
|
14
|
+
from dataproduct.lint.schema import fetch_schema
|
|
15
|
+
from dataproduct.lint.validate import parse_yaml, validate_against_schema
|
|
16
|
+
from dataproduct.model.exceptions import DataProductException
|
|
17
|
+
from dataproduct.model.run import Check, ResultEnum, Run
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DataProduct:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
data_product_file: Optional[str] = None,
|
|
24
|
+
data_product_str: Optional[str] = None,
|
|
25
|
+
schema_location: Optional[str] = None,
|
|
26
|
+
all_errors: bool = False,
|
|
27
|
+
config: "Optional[Union[Config, dict]]" = None,
|
|
28
|
+
):
|
|
29
|
+
self._data_product_file = data_product_file
|
|
30
|
+
self._data_product_str = data_product_str
|
|
31
|
+
self._schema_location = schema_location
|
|
32
|
+
self._all_errors = all_errors
|
|
33
|
+
self._config = Config.resolve(config)
|
|
34
|
+
|
|
35
|
+
def _load_dict(self) -> dict:
|
|
36
|
+
if self._data_product_file is not None:
|
|
37
|
+
content = read_resource(self._data_product_file, self._config)
|
|
38
|
+
elif self._data_product_str is not None:
|
|
39
|
+
content = self._data_product_str
|
|
40
|
+
else:
|
|
41
|
+
raise DataProductException(
|
|
42
|
+
type="lint",
|
|
43
|
+
name="Load data product",
|
|
44
|
+
reason="No data product provided (file or string).",
|
|
45
|
+
)
|
|
46
|
+
return parse_yaml(content)
|
|
47
|
+
|
|
48
|
+
def lint(self) -> Run:
|
|
49
|
+
"""Validate the data product against the ODPS JSON Schema (schema-only)."""
|
|
50
|
+
run = Run.create_run()
|
|
51
|
+
run.log_info("Linting data product")
|
|
52
|
+
try:
|
|
53
|
+
data = self._load_dict()
|
|
54
|
+
run.dataProductId = data.get("id")
|
|
55
|
+
run.dataProductVersion = data.get("version")
|
|
56
|
+
schema = fetch_schema(self._schema_location)
|
|
57
|
+
checks = validate_against_schema(data, schema, self._all_errors)
|
|
58
|
+
if checks:
|
|
59
|
+
run.checks.extend(checks)
|
|
60
|
+
for check in checks:
|
|
61
|
+
run.log_error(str(check.reason))
|
|
62
|
+
else:
|
|
63
|
+
run.checks.append(
|
|
64
|
+
Check(
|
|
65
|
+
type="lint",
|
|
66
|
+
result=ResultEnum.passed,
|
|
67
|
+
name="Data product is syntactically valid",
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
except DataProductException as e:
|
|
71
|
+
run.checks.append(Check(type=e.type, result=e.result, name=e.name, reason=e.reason, engine=e.engine))
|
|
72
|
+
run.log_error(str(e))
|
|
73
|
+
except Exception as e:
|
|
74
|
+
run.checks.append(
|
|
75
|
+
Check(
|
|
76
|
+
type="general",
|
|
77
|
+
result=ResultEnum.error,
|
|
78
|
+
name="Check Data Product",
|
|
79
|
+
reason=str(e),
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
run.log_error(str(e))
|
|
83
|
+
run.finish()
|
|
84
|
+
return run
|
|
85
|
+
|
|
86
|
+
def publish(self, ssl_verification: bool = True) -> None:
|
|
87
|
+
"""Publish the data product to Entropy Data (no client-side lint in 0.1)."""
|
|
88
|
+
data = self._load_dict()
|
|
89
|
+
publish_data_product_to_entropy_data(
|
|
90
|
+
data_product_dict=data,
|
|
91
|
+
ssl_verification=ssl_verification,
|
|
92
|
+
config=self._config,
|
|
93
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import importlib.resources as resources
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
DEFAULT_DATA_PRODUCT_INIT_TEMPLATE = "odps-1.0.0.init.yaml"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_init_template(location: str = None) -> str:
|
|
10
|
+
"""Return the contents of an init template.
|
|
11
|
+
|
|
12
|
+
- ``None`` -> the bundled default template.
|
|
13
|
+
- an ``http(s)://`` URL -> the fetched body.
|
|
14
|
+
- anything else -> read as a local file path.
|
|
15
|
+
"""
|
|
16
|
+
if location is None:
|
|
17
|
+
logging.info("Use default bundled template " + DEFAULT_DATA_PRODUCT_INIT_TEMPLATE)
|
|
18
|
+
schemas = resources.files("dataproduct")
|
|
19
|
+
template = schemas.joinpath("schemas", DEFAULT_DATA_PRODUCT_INIT_TEMPLATE)
|
|
20
|
+
with template.open("r") as file:
|
|
21
|
+
return file.read()
|
|
22
|
+
elif location.startswith("http://") or location.startswith("https://"):
|
|
23
|
+
return requests.get(location).text
|
|
24
|
+
else:
|
|
25
|
+
with open(location, "r") as file:
|
|
26
|
+
return file.read()
|
|
File without changes
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Publish a data product to Entropy Data.
|
|
2
|
+
|
|
3
|
+
Confirmed API contract (see specs/003-publish.md):
|
|
4
|
+
``PUT {host}/api/dataproducts/{id}`` with ``x-api-key`` +
|
|
5
|
+
``content-type: application/json`` and a JSON ODPS body; ``200 OK`` on success.
|
|
6
|
+
Structurally identical to datacontract-cli's data-contract publish, with the
|
|
7
|
+
path changed from ``datacontracts`` to ``dataproducts``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
from dataproduct.config import Config
|
|
15
|
+
|
|
16
|
+
# Response header carrying the HTML location of the published data product.
|
|
17
|
+
RESPONSE_HEADER_LOCATION_HTML = "location-html"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def publish_data_product_to_entropy_data(
|
|
21
|
+
data_product_dict: dict, ssl_verification: bool, config: Config | None = None
|
|
22
|
+
) -> None:
|
|
23
|
+
config = Config.resolve(config)
|
|
24
|
+
api_key = _get_api_key(config)
|
|
25
|
+
host = _get_host(config)
|
|
26
|
+
headers = {"Content-Type": "application/json", "x-api-key": api_key}
|
|
27
|
+
|
|
28
|
+
id = data_product_dict.get("id")
|
|
29
|
+
if not id:
|
|
30
|
+
raise DataProductPublishError("The data product has no top-level 'id'; cannot publish.")
|
|
31
|
+
|
|
32
|
+
url = f"{host}/api/dataproducts/{id}"
|
|
33
|
+
response = requests.put(
|
|
34
|
+
url=url,
|
|
35
|
+
json=data_product_dict,
|
|
36
|
+
headers=headers,
|
|
37
|
+
verify=ssl_verification,
|
|
38
|
+
)
|
|
39
|
+
if response.status_code != 200:
|
|
40
|
+
display_host = _extract_hostname(host)
|
|
41
|
+
raise DataProductPublishError(f"Error publishing data product to {display_host}: {response.text}")
|
|
42
|
+
|
|
43
|
+
print("✅ Published data product successfully")
|
|
44
|
+
|
|
45
|
+
location_html = response.headers.get(RESPONSE_HEADER_LOCATION_HTML)
|
|
46
|
+
if location_html:
|
|
47
|
+
print(f"🚀 Open {location_html}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DataProductPublishError(Exception):
|
|
51
|
+
"""Raised when a publish attempt fails (missing key, bad id, non-200)."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _get_api_key(config: Config) -> str:
|
|
55
|
+
"""API key with fallback priority:
|
|
56
|
+
|
|
57
|
+
1. ``ENTROPY_DATA_API_KEY``
|
|
58
|
+
2. ``DATAMESH_MANAGER_API_KEY``
|
|
59
|
+
3. ``DATACONTRACT_MANAGER_API_KEY``
|
|
60
|
+
"""
|
|
61
|
+
api_key = (
|
|
62
|
+
config.get_entropy_data_api_key()
|
|
63
|
+
or config.get_datamesh_manager_api_key()
|
|
64
|
+
or config.get_datacontract_manager_api_key()
|
|
65
|
+
)
|
|
66
|
+
if api_key is None:
|
|
67
|
+
raise DataProductPublishError(
|
|
68
|
+
"Cannot publish, as neither ENTROPY_DATA_API_KEY, DATAMESH_MANAGER_API_KEY, "
|
|
69
|
+
"nor DATACONTRACT_MANAGER_API_KEY is set"
|
|
70
|
+
)
|
|
71
|
+
return api_key
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _get_host(config: Config) -> str:
|
|
75
|
+
"""Host with fallback priority: ENTROPY_DATA_HOST, DATAMESH_MANAGER_HOST,
|
|
76
|
+
DATACONTRACT_MANAGER_HOST, then the default ``https://api.entropy-data.com``."""
|
|
77
|
+
return (
|
|
78
|
+
config.get_entropy_data_host()
|
|
79
|
+
or config.get_datamesh_manager_host()
|
|
80
|
+
or config.get_datacontract_manager_host()
|
|
81
|
+
or "https://api.entropy-data.com"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _extract_hostname(url: str) -> str:
|
|
86
|
+
parsed = urlparse(url)
|
|
87
|
+
return parsed.netloc.split(":")[0] if parsed.netloc else url
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from dataproduct.config import Config
|
|
7
|
+
from dataproduct.model.exceptions import DataProductException
|
|
8
|
+
from dataproduct.model.run import ResultEnum
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def read_resource(location: str, config: Config | None = None) -> str:
|
|
12
|
+
"""Read the raw text of a data product from a URL or local path.
|
|
13
|
+
|
|
14
|
+
Raises :class:`DataProductException` (an ``error`` result, no traceback) if a
|
|
15
|
+
local file is missing or a URL cannot be fetched.
|
|
16
|
+
"""
|
|
17
|
+
location_str = str(location)
|
|
18
|
+
if location_str.startswith("http://") or location_str.startswith("https://"):
|
|
19
|
+
try:
|
|
20
|
+
response = requests.get(location_str)
|
|
21
|
+
response.raise_for_status()
|
|
22
|
+
except requests.RequestException as e:
|
|
23
|
+
raise DataProductException(
|
|
24
|
+
type="lint",
|
|
25
|
+
name=f"Reading data product from {location_str}",
|
|
26
|
+
reason=f"Failed to fetch '{location_str}': {e}",
|
|
27
|
+
result=ResultEnum.error,
|
|
28
|
+
)
|
|
29
|
+
return response.text
|
|
30
|
+
|
|
31
|
+
if not os.path.exists(location_str):
|
|
32
|
+
raise DataProductException(
|
|
33
|
+
type="lint",
|
|
34
|
+
name=f"Reading data product from {location_str}",
|
|
35
|
+
reason=f"The file '{location_str}' does not exist.",
|
|
36
|
+
result=ResultEnum.error,
|
|
37
|
+
)
|
|
38
|
+
return Path(location_str).read_text()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from typing import Any, Dict
|
|
2
|
+
|
|
3
|
+
from dataproduct.config import Config
|
|
4
|
+
from dataproduct.lint.files import read_resource
|
|
5
|
+
from dataproduct.lint.validate import parse_yaml
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve_data_product_dict(location: str, config: "Config | None" = None) -> Dict[str, Any]:
|
|
9
|
+
"""Read and parse the data product at ``location`` into a plain dict."""
|
|
10
|
+
content = read_resource(location, config)
|
|
11
|
+
return parse_yaml(content)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import importlib.resources as resources
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Union
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from dataproduct.model.exceptions import DataProductException
|
|
11
|
+
from dataproduct.model.run import ResultEnum
|
|
12
|
+
|
|
13
|
+
DEFAULT_DATA_PRODUCT_SCHEMA = "odps-1.0.0.schema.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fetch_schema(location: Union[str, Path] = None) -> Dict[str, Any]:
|
|
17
|
+
"""Fetch the ODPS JSON Schema to validate against.
|
|
18
|
+
|
|
19
|
+
``None`` uses the bundled ODPS v1.0.0 schema; otherwise ``location`` is a URL
|
|
20
|
+
or local path.
|
|
21
|
+
"""
|
|
22
|
+
if location is None:
|
|
23
|
+
logging.info("Use default bundled schema " + DEFAULT_DATA_PRODUCT_SCHEMA)
|
|
24
|
+
schemas = resources.files("dataproduct")
|
|
25
|
+
schema_file = schemas.joinpath("schemas", DEFAULT_DATA_PRODUCT_SCHEMA)
|
|
26
|
+
with schema_file.open("r") as file:
|
|
27
|
+
return json.load(file)
|
|
28
|
+
|
|
29
|
+
location_str = str(location)
|
|
30
|
+
if location_str.startswith("http://") or location_str.startswith("https://"):
|
|
31
|
+
logging.debug(f"Downloading schema from {location_str}")
|
|
32
|
+
response = requests.get(location_str)
|
|
33
|
+
return response.json()
|
|
34
|
+
|
|
35
|
+
if not os.path.exists(location_str):
|
|
36
|
+
raise DataProductException(
|
|
37
|
+
type="lint",
|
|
38
|
+
name=f"Reading schema from {location_str}",
|
|
39
|
+
reason=f"The file '{location_str}' does not exist.",
|
|
40
|
+
result=ResultEnum.error,
|
|
41
|
+
)
|
|
42
|
+
logging.debug(f"Loading JSON schema locally at {location_str}")
|
|
43
|
+
with open(location_str, "r") as file:
|
|
44
|
+
return json.load(file)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from typing import Any, Dict, List
|
|
2
|
+
|
|
3
|
+
import yaml
|
|
4
|
+
from jsonschema.validators import validator_for
|
|
5
|
+
|
|
6
|
+
from dataproduct.model.exceptions import DataProductException
|
|
7
|
+
from dataproduct.model.run import Check, ResultEnum
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_yaml(content: str) -> Dict[str, Any]:
|
|
11
|
+
"""Parse a data product YAML string into a mapping.
|
|
12
|
+
|
|
13
|
+
Raises :class:`DataProductException` (single ``error``, no traceback) on
|
|
14
|
+
malformed YAML or a non-mapping document.
|
|
15
|
+
"""
|
|
16
|
+
try:
|
|
17
|
+
data = yaml.safe_load(content)
|
|
18
|
+
except yaml.YAMLError as e:
|
|
19
|
+
raise DataProductException(
|
|
20
|
+
type="lint",
|
|
21
|
+
name="Parsing data product YAML",
|
|
22
|
+
reason=f"The data product is not valid YAML: {e}",
|
|
23
|
+
result=ResultEnum.error,
|
|
24
|
+
)
|
|
25
|
+
if not isinstance(data, dict):
|
|
26
|
+
raise DataProductException(
|
|
27
|
+
type="lint",
|
|
28
|
+
name="Parsing data product YAML",
|
|
29
|
+
reason="The data product must be a YAML mapping (object).",
|
|
30
|
+
result=ResultEnum.error,
|
|
31
|
+
)
|
|
32
|
+
return data
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_against_schema(data: Dict[str, Any], schema: Dict[str, Any], all_errors: bool = False) -> List[Check]:
|
|
36
|
+
"""Validate ``data`` against the ODPS JSON Schema.
|
|
37
|
+
|
|
38
|
+
Returns a list of ``error`` checks — empty when the document is valid. With
|
|
39
|
+
``all_errors=False`` (default) only the first violation is reported.
|
|
40
|
+
"""
|
|
41
|
+
validator_cls = validator_for(schema)
|
|
42
|
+
validator_cls.check_schema(schema)
|
|
43
|
+
validator = validator_cls(schema)
|
|
44
|
+
|
|
45
|
+
errors = sorted(validator.iter_errors(data), key=lambda e: list(e.absolute_path))
|
|
46
|
+
if not all_errors:
|
|
47
|
+
errors = errors[:1]
|
|
48
|
+
|
|
49
|
+
checks: List[Check] = []
|
|
50
|
+
for error in errors:
|
|
51
|
+
path = "/".join(str(p) for p in error.absolute_path) or "(root)"
|
|
52
|
+
checks.append(
|
|
53
|
+
Check(
|
|
54
|
+
type="lint",
|
|
55
|
+
result=ResultEnum.error,
|
|
56
|
+
name=f"Schema validation failed at '{path}'",
|
|
57
|
+
reason=error.message,
|
|
58
|
+
field=path,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
return checks
|