datakiln 0.3.0__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.
datakiln/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Build a database from a repository of files."""
datakiln/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """
2
+ Runs the command line with python -m datakiln.
3
+ """
4
+ from datakiln.cli import main
5
+
6
+
7
+ main()
datakiln/arguments.py ADDED
@@ -0,0 +1,76 @@
1
+ """
2
+ Options that the commands share, and the log they write.
3
+ """
4
+ import logging
5
+ import sys
6
+ from argparse import ArgumentParser, BooleanOptionalAction
7
+
8
+
9
+ def add_directory(parser: ArgumentParser):
10
+ """Adds the directory of the dataset, which every command takes.
11
+
12
+ :param parser: Parser of a command.
13
+ :type parser: argparse.ArgumentParser
14
+ """
15
+ parser.add_argument("directory", metavar="directory", type=str,
16
+ help="Path of the directory of the dataset.")
17
+
18
+
19
+ def add_log(parser: ArgumentParser):
20
+ """Adds the option to show the log, which commands write to a file.
21
+
22
+ :param parser: Parser of a command.
23
+ :type parser: argparse.ArgumentParser
24
+ """
25
+ parser.add_argument("--stdout", action="store_true",
26
+ help="Output logs to standard output as well as to "
27
+ "the log file.")
28
+
29
+
30
+ def add_include(parser: ArgumentParser, what: str):
31
+ """Adds the option that limits a command to some of the things it works
32
+ on.
33
+
34
+ :param parser: Parser of a command.
35
+ :type parser: argparse.ArgumentParser
36
+
37
+ :param what: What the command does with the names, as a sentence.
38
+ :type what: str
39
+ """
40
+ parser.add_argument("--include", metavar="name", type=str, nargs="+",
41
+ help=what)
42
+
43
+
44
+ def add_export(parser: ArgumentParser):
45
+ """Adds the options that tell what a command that reads from a source
46
+ writes to the dataset.
47
+
48
+ :param parser: Parser of a command.
49
+ :type parser: argparse.ArgumentParser
50
+ """
51
+ parser.add_argument("--data", action=BooleanOptionalAction, default=True,
52
+ help="Whether to write the data files.")
53
+ parser.add_argument("--metadata", action=BooleanOptionalAction,
54
+ default=False,
55
+ help="Whether to write the metadata files of the "
56
+ "tables.")
57
+
58
+
59
+ def setup_logging(stdout: bool, log_file: str):
60
+ """Sets up logging for a command.
61
+
62
+ :param stdout: Whether to output logs to standard output.
63
+ :type stdout: bool
64
+
65
+ :param log_file: Path to the log file.
66
+ :type log_file: str
67
+ """
68
+ handlers = [logging.FileHandler(log_file, mode="w")]
69
+ if stdout:
70
+ handlers.append(logging.StreamHandler(sys.stdout))
71
+
72
+ logging.basicConfig(
73
+ level=logging.INFO, datefmt="%Y-%m-%d %H:%M:%S",
74
+ format="%(asctime)s %(name)-12s %(levelname)-8s %(message)s",
75
+ handlers=handlers
76
+ )
@@ -0,0 +1,110 @@
1
+ """
2
+ Creates table and loads data.
3
+ """
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ from datakiln.arguments import add_directory, add_include, add_log
8
+ from datakiln.metadata.database import DatabaseMeta, Session
9
+
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def add_arguments(parser):
15
+ """Adds the options of the command.
16
+
17
+ :param parser: Parser of the command.
18
+ :type parser: argparse.ArgumentParser
19
+ """
20
+ add_directory(parser)
21
+ add_log(parser)
22
+ add_include(parser, "Build only these tables and scripts, and "
23
+ "what they need.")
24
+ parser.add_argument("--grant", metavar="grant", type=str, nargs='+',
25
+ help="Grant read permissions to specified users.")
26
+ parser.add_argument("--rebuild-all", action="store_true",
27
+ help="Rebuild all tables and scripts, even if "
28
+ "up to date.")
29
+ parser.add_argument("--prune", action="store_true",
30
+ help="Drop the objects removed from the repo.")
31
+
32
+
33
+ def run(args):
34
+ """Creates the tables, loads the data and runs the scripts.
35
+
36
+ :param args: Arguments of the command.
37
+ :type args: argparse.Namespace
38
+ """
39
+
40
+ # Get absolute of specified path
41
+ directory = Path(args.directory).absolute()
42
+
43
+ # Get database parameters
44
+ db_params = DatabaseMeta.load_env(directory)
45
+
46
+ # Tables and scripts to build, with what they use
47
+ database = DatabaseMeta(directory, db_params["database"])
48
+ include = None
49
+ if args.include:
50
+ selected = database.graph.find(args.include)
51
+ include = database.graph.requirements(selected)
52
+
53
+ # Connect to database
54
+ session = Session.create(**db_params)
55
+
56
+ # Create metadata
57
+ database.create_metadata(session)
58
+ database.load_stale(session)
59
+
60
+ # Create role for database read access
61
+ database.create_role(session)
62
+
63
+ # Stage and flush to database
64
+ updated = []
65
+ for table in database.graph.tables:
66
+ if include is not None and table not in include:
67
+ continue
68
+
69
+ # Skip the tables that did not change, unless all are rebuilt. A table
70
+ # is also rebuilt if a table it references is, since that drops its
71
+ # foreign keys
72
+ if (
73
+ not args.rebuild_all
74
+ and not table.needs_update(session)
75
+ and database.graph.dependencies(table).isdisjoint(updated)
76
+ ):
77
+ logger.info("Table '%s'.'%s': Already up to date.",
78
+ table.schema, table.name)
79
+ continue
80
+ table.pull()
81
+ table.create(session)
82
+ table.stage_meta.load(session)
83
+ updated.append(table)
84
+
85
+ # Run transforms
86
+ database.execute_directory("transform", session, max_statements=0)
87
+
88
+ # Load to final schema and flush to database
89
+ for table in updated:
90
+ table.resolve_columns(session)
91
+ table.create_uniques(session)
92
+ table.load(session)
93
+ table.enforce_fks(session)
94
+ table.create_indexes(session)
95
+ table.create_metadata(session)
96
+ database.forget_dependents(session, [table])
97
+ session.commit()
98
+
99
+ # Run views
100
+ session.close()
101
+ database.execute_derive(
102
+ session, force=args.rebuild_all, include=include)
103
+
104
+ # Objects whose metadata or script was removed
105
+ database.prune(session, drop=args.prune)
106
+
107
+ # Grant read permissions on entire database
108
+ database.grant(session, args.grant)
109
+ session.commit()
110
+ session.close()
datakiln/build_dbms.py ADDED
@@ -0,0 +1,136 @@
1
+ """
2
+ Builds table metadata from DBMS.
3
+ """
4
+ import logging
5
+ from getpass import getuser
6
+ from pathlib import Path
7
+
8
+ from datakiln.metadata.database import DatabaseMeta, DRIVER
9
+ from datakiln.arguments import (
10
+ add_directory, add_export, add_include, add_log
11
+ )
12
+ from datakiln.extraction.models import ExtractMeta, ExtractGraph
13
+ from datakiln.extraction.strategies import (
14
+ StreamStrategy, TempTableStrategy, ParallelExecutor
15
+ )
16
+ from datakiln.extraction.exporters import MetadataExtractor
17
+
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def add_arguments(parser):
23
+ """Adds the options of the command.
24
+
25
+ :param parser: Parser of the command.
26
+ :type parser: argparse.ArgumentParser
27
+ """
28
+ add_directory(parser)
29
+ add_log(parser)
30
+ add_include(parser, "Extract only these tables, and the ones they "
31
+ "need.")
32
+ add_export(parser)
33
+ parser.add_argument('--dialect', metavar="dialect", type=str,
34
+ required=True, choices=DRIVER.keys(),
35
+ help='Dialect of the source DBMS.')
36
+ parser.add_argument('--host', metavar="host", type=str,
37
+ required=True, help='Hostname of the source DBMS.')
38
+ parser.add_argument('--port', metavar="port", type=str,
39
+ required=True,
40
+ help='Port to use when connecting to the source '
41
+ 'DBMS.')
42
+ parser.add_argument('--user', metavar="user", type=str,
43
+ required=False, default=getuser(),
44
+ help='Username to use when connecting to the source '
45
+ 'DBMS.')
46
+ parser.add_argument('--password', metavar="password", type=str,
47
+ required=False,
48
+ help='Password to use when connecting to the source '
49
+ 'DBMS.')
50
+ parser.add_argument('--database', metavar="database", type=str,
51
+ required=True,
52
+ help='Database to connect to in the source DBMS.')
53
+ parser.add_argument('--read-only', action='store_true', default=False,
54
+ help='Run in read-only mode (no temp tables).')
55
+ parser.add_argument('--parallel', type=int, default=4,
56
+ help='Number of parallel extraction jobs.')
57
+
58
+
59
+ def run(args):
60
+ # pylint: disable=too-many-locals, too-many-branches, too-many-statements
61
+ """Downloads data and creates sample metadata for tables.
62
+
63
+ :param args: Arguments of the command.
64
+ :type args: argparse.Namespace
65
+ """
66
+ directory = Path(args.directory).absolute()
67
+
68
+ # Create output directory
69
+ meta_dir = Path(directory) / "metadata"
70
+ meta_dir.mkdir(exist_ok=True)
71
+
72
+ # Connect parameters
73
+ db_params = {
74
+ "dialect": args.dialect,
75
+ "user": args.user,
76
+ "password": args.password,
77
+ "host": args.host,
78
+ "database": args.database,
79
+ "port": args.port
80
+ }
81
+
82
+ # Load all extraction metadata
83
+ tasks = []
84
+ for file in sorted(directory.glob("extract/*.json")):
85
+ tasks.append(ExtractMeta.from_json(file))
86
+
87
+ # Build dependency graph of the tables to export, and what they need
88
+ graph = ExtractGraph.select(tasks, args.include)
89
+
90
+ # Initialize execution strategy
91
+ if args.read_only:
92
+ strategy = StreamStrategy(db_params, directory)
93
+ else:
94
+ strategy = TempTableStrategy(db_params, directory)
95
+
96
+ # Track exported tables for metadata generation
97
+ exported_schemas = {}
98
+ for task in graph.sort():
99
+ if task in graph.export:
100
+ if task.schema not in exported_schemas:
101
+ exported_schemas[task.schema] = []
102
+ exported_schemas[task.schema].append(task.table)
103
+
104
+ if args.data:
105
+ # Create data directory if not exists
106
+ data_dir = directory / "data"
107
+ data_dir.mkdir(exist_ok=True)
108
+
109
+ # Initialize and run parallel executor
110
+ executor = ParallelExecutor(strategy, graph, max_workers=args.parallel)
111
+ try:
112
+ executor.execute(data_dir=data_dir)
113
+ finally:
114
+ strategy.cleanup()
115
+
116
+ # Skip metadata generation
117
+ if not args.metadata:
118
+ return
119
+
120
+ # Get target database params
121
+ db_params = DatabaseMeta.load_env(directory)
122
+
123
+ # Generate table metadata
124
+ with strategy.session() as session:
125
+ for schema, tables in exported_schemas.items():
126
+ # Retrieve metadata from DBMS
127
+ metadata = MetadataExtractor.get_metadata(session, schema, tables)
128
+
129
+ for _, table in metadata.tables.items():
130
+ # Generate table metadata
131
+ logger.info("Table '%s': Generating metadata.", table)
132
+ MetadataExtractor.generate_table_meta(
133
+ meta_dir,
134
+ table,
135
+ db_params["dialect"]
136
+ )
@@ -0,0 +1,165 @@
1
+ """
2
+ Generate metadata from data file.
3
+ """
4
+ import json
5
+ import logging
6
+ from pathlib import Path
7
+ from itertools import starmap
8
+ from functools import partial
9
+
10
+ import pandas as pd
11
+ from slugify import slugify
12
+
13
+ from datakiln.arguments import add_directory, add_log
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ SUPPORTED_EXTENSIONS = {
19
+ ".csv.gz": partial(pd.read_csv, engine="python", nrows=10000),
20
+ ".csv": partial(pd.read_csv, engine="python", nrows=10000),
21
+ ".xlsx": partial(pd.read_excel, sheet_name=None, nrows=10000)
22
+ }
23
+
24
+
25
+ def dtype_to_psql(dtype):
26
+ """Define PostgreSQL type from pandas inferred type.
27
+
28
+ :param dtype: Type inferred by pandas.
29
+ :type dtype: type
30
+
31
+ :return: PostgreSQL data type.
32
+ :rtype: str
33
+ """
34
+ if dtype == object:
35
+ return "TEXT"
36
+ if dtype == int:
37
+ return "INTEGER"
38
+ if dtype == float:
39
+ return "DOUBLE PRECISION"
40
+ if dtype == bool:
41
+ return "BOOLEAN"
42
+ return None
43
+
44
+
45
+ def dtype_to_meta(column, dtype):
46
+ """Generates metadata for column from tuple.
47
+
48
+ :param column: Column name as it is in data file.
49
+ :type column: str
50
+
51
+ :param dtype: Type inferred by pandas.
52
+ :type dtype: type
53
+
54
+ :return: Column metadata.
55
+ :rtype: dict
56
+ """
57
+ return {
58
+ "name": column,
59
+ "rename": slugify(column, separator="_"),
60
+ "dtype": {
61
+ "postgresql": dtype_to_psql(dtype)
62
+ }
63
+ }
64
+
65
+
66
+ def generate_table_meta(meta_dir, table_name, data):
67
+ """Generates metadata for column from tuple.
68
+
69
+ :param meta_dir: Medatada directory.
70
+ :type meta_dir: Path
71
+
72
+ :param table_name: Name of the table.
73
+ :type table_name: str
74
+
75
+ :param data: DataFrame containing sample data for the table.
76
+ :type data: pandas.DataFrame
77
+
78
+ :return: None
79
+ """
80
+ # Ensure table name is database friendly
81
+ table_name = slugify(table_name, separator="_")
82
+
83
+ # Remove trailing white spaces from data
84
+ data = data.map(lambda x: x.strip() if isinstance(x, str) else x)
85
+
86
+ # Generate metadata
87
+ meta = {
88
+ "name": table_name,
89
+ "schema": "public",
90
+ "encoding": "utf-8",
91
+ "columns": list(starmap(dtype_to_meta, data.dtypes.items())),
92
+ "constraints": {
93
+ "primary_key": ["meta_row_id"]
94
+ }
95
+ }
96
+
97
+ # Write metadata to file
98
+ meta_path = meta_dir / f"{table_name}.json"
99
+ with open(meta_path, "w", encoding="utf-8") as file:
100
+ json.dump(meta, file, indent=4)
101
+
102
+
103
+ def add_arguments(parser):
104
+ """Adds the options of the command.
105
+
106
+ :param parser: Parser of the command.
107
+ :type parser: argparse.ArgumentParser
108
+ """
109
+ add_directory(parser)
110
+ add_log(parser)
111
+ parser.add_argument('--delimiter', metavar="delimiter", type=str,
112
+ required=False,
113
+ help='Character to treat as delimiter.')
114
+
115
+
116
+ def run(args):
117
+ """Generates metadata from the data files.
118
+
119
+ :param args: Arguments of the command.
120
+ :type args: argparse.Namespace
121
+ """
122
+
123
+ # Get absolute path for specified directory
124
+ directory = Path(args.directory).absolute()
125
+
126
+ # Ensure metadata directory exists
127
+ meta_dir = directory / "metadata"
128
+ meta_dir.mkdir(exist_ok=True)
129
+
130
+ # Generate metadata for each data file
131
+ for data_path in directory.glob("data/*.*"):
132
+ # Skip if the extension is not supported
133
+ extension = "".join(data_path.suffixes).lower()
134
+ if extension not in SUPPORTED_EXTENSIONS:
135
+ continue
136
+ logger.info("File '%s': Generating metadata.", data_path)
137
+
138
+ # Read file
139
+ params = {}
140
+ reader = SUPPORTED_EXTENSIONS[extension]
141
+ if args.delimiter and reader.func == pd.read_csv:
142
+ params["delimiter"] = args.delimiter
143
+ data = reader(data_path.absolute(), **params)
144
+
145
+ # Excel files can have more than one sheet
146
+ # so we handle the data as a dictionary
147
+ table_name = data_path.name[:-len(extension)]
148
+ if isinstance(data, pd.DataFrame):
149
+ data = {table_name: data}
150
+ # Only consider tables with header row
151
+ # when multiple tables are returned
152
+ elif isinstance(data, dict):
153
+ data = {
154
+ name: df
155
+ for name, df in data.items()
156
+ if not df.columns.str.startswith("Unnamed:").all()
157
+ }
158
+
159
+ # If only one table is left, use file name
160
+ if len(data) == 1:
161
+ data = {data_path.stem: list(data.values())[0]}
162
+
163
+ # Generate metadata
164
+ for table_name, df in data.items():
165
+ generate_table_meta(meta_dir, table_name, df)