streamline 1.2.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.
streamline/__init__.py ADDED
@@ -0,0 +1 @@
1
+
streamline/cli.py ADDED
@@ -0,0 +1,326 @@
1
+ import argparse
2
+ import asyncio
3
+ import logging
4
+ import yaml
5
+ import sys
6
+ import os
7
+ import re
8
+
9
+ from . import utils
10
+ from . import executors
11
+ from . import generators
12
+ from . import consumers
13
+ from . import streamers
14
+ from .core import pipe
15
+
16
+ logger = logging.getLogger(__file__)
17
+
18
+ SHORTHAND_PATTERN = r'^(?P<target_attr>[-\[\]_\w]+=)?(?P<streamer>[-\[\]_.\w]+)\((?P<input_extract>[-\[\]._*\w]+), ?(?P<output_extract>[-._*\w\[\]]+)\)$'
19
+
20
+
21
+ class OptionsProcessor():
22
+ def __init__(self, args):
23
+ self.args = args
24
+
25
+ def parse(self, parser):
26
+ # Support '--' separator between commands
27
+ if self.args and self.args[0] == '--':
28
+ self.args = self.args[1:]
29
+
30
+ parsed_args, remaining = parser.parse_known_args(self.args)
31
+ self.args = remaining
32
+ return parsed_args
33
+
34
+ def has_remaining_args(self):
35
+ return len(self.args) > 0
36
+
37
+ def remaining_args(self):
38
+ return self.args.copy()
39
+
40
+
41
+ def _parser_main():
42
+ cmd_parser = argparse.ArgumentParser(prog='streamline', add_help=False)
43
+ cmd_parser.add_argument(
44
+ '--generator',
45
+ help='Entry Generator Module',
46
+ )
47
+ cmd_parser.add_argument(
48
+ '--consumer',
49
+ help='Entry Consumer/Writer Module',
50
+ )
51
+ cmd_parser.add_argument(
52
+ '-s', '--streamers',
53
+ help='Additional streamers to apply (-s is optional)',
54
+ nargs='*',
55
+ )
56
+ cmd_parser.add_argument(
57
+ '-h', '--help',
58
+ action='store_true',
59
+ help='Print help',
60
+ )
61
+ cmd_parser.add_argument(
62
+ '-p', '--progress',
63
+ choices=['buffer', 'stream-output', 'streaming'],
64
+ help='Print progress to stdout. ("buffer": buffers input and output, "stream-output" buffers only input, "stream" for no buffering at all)',
65
+ )
66
+ cmd_parser.add_argument(
67
+ '-w', '--workers',
68
+ type=int,
69
+ help='Number of concurrent workers for any one async execution module to have',
70
+ )
71
+ cmd_parser.add_argument(
72
+ '-y', '--yaml',
73
+ help='Take options from a yaml or json config file',
74
+ )
75
+ return cmd_parser
76
+
77
+ def _generator_parser(generator):
78
+ if not hasattr(generator, 'args'):
79
+ return None
80
+
81
+ generator_parser = argparse.ArgumentParser(
82
+ prog=generator.__name__,
83
+ add_help=False,
84
+ usage='streamline [streamers...] --generator %(prog)s [generator options]',
85
+ )
86
+ generator.args(generator_parser)
87
+ return generator_parser
88
+
89
+ def _consumer_parser(consumer):
90
+ if not hasattr(consumer, 'args'):
91
+ return None
92
+
93
+ consumer_parser = argparse.ArgumentParser(
94
+ prog=consumer.__name__,
95
+ add_help=False,
96
+ usage='streamline [streamers...] --consumer %(prog)s [consumer options]',
97
+ )
98
+ consumer.args(consumer_parser)
99
+ return consumer_parser
100
+
101
+ def _streamer_parser(Streamer):
102
+ streamer_parser = argparse.ArgumentParser(
103
+ prog=Streamer.__name__,
104
+ add_help=False,
105
+ usage='streamline -s %(prog)s -- [options]',
106
+ )
107
+ if hasattr(Streamer, 'args'):
108
+ Streamer.args(streamer_parser)
109
+ return streamer_parser
110
+
111
+
112
+ def load_config(*sources, ignore_nulls=True):
113
+ config = {}
114
+ for source in sources:
115
+ if source is None:
116
+ continue
117
+ if not isinstance(source, dict):
118
+ source = source.__dict__
119
+ if ignore_nulls:
120
+ source = utils.strip_nulls(source)
121
+ config.update(source)
122
+ return config
123
+
124
+ def wrap_streamer(streamer, input_path=None, output_path=None, target=None):
125
+ if not (input_path or output_path or target):
126
+ return [streamer]
127
+
128
+ if target and not (input_path or output_path):
129
+ combiner = streamers.Combiner(path=target, target=-2, source=-1)
130
+ return [streamer, combiner]
131
+
132
+ subpipe = []
133
+ # Compose the sub-command
134
+ subpipe.append(streamers.history_push)
135
+ if input_path != 'value':
136
+ input_extract = streamers.ExtractionStreamer(
137
+ selector=input_path,
138
+ )
139
+ subpipe.append(input_extract.stream)
140
+ subpipe.append(streamer)
141
+ if output_path != 'value':
142
+ output_extract = streamers.ExtractionStreamer(
143
+ selector=output_path
144
+ )
145
+ subpipe.append(output_extract.stream)
146
+ subpipe.append(streamers.history_pop)
147
+ if target:
148
+ combiner = streamers.Combiner(path=target, target=-2, source=-1)
149
+ subpipe.append(combiner.stream)
150
+ return subpipe
151
+
152
+ def streamline_command(args):
153
+ if args and '-s' not in args:
154
+ args.insert(0, '-s'),
155
+ options_processor = OptionsProcessor(args)
156
+ main_parser = _parser_main()
157
+ main_args = options_processor.parse(main_parser)
158
+
159
+ yaml_config = {}
160
+ use_yaml = main_args.yaml is not None
161
+ if main_args.yaml:
162
+ with open(main_args.yaml, 'r') as f:
163
+ yaml_config = yaml.safe_load(f.read())
164
+
165
+ yaml_generator_config = yaml_config.get('generator', {})
166
+ yaml_consumer_config = yaml_config.get('consumer', {})
167
+ yaml_streamers = yaml_config.get('streamers', [])
168
+ command_config = load_config(
169
+ {
170
+ 'generator': 'file',
171
+ 'consumer': 'file',
172
+ 'workers': streamers.AsyncExecutor.DEFAULT_WORKERS,
173
+ 'streamers': [],
174
+ },
175
+ {
176
+ 'generator': yaml_generator_config.get('name', None),
177
+ 'consumer': yaml_consumer_config.get('name', None),
178
+ },
179
+ main_args,
180
+ ignore_nulls=True,
181
+ )
182
+ ae_args = {'workers': command_config.get('workers')}
183
+
184
+ # Load Generator & Consumer
185
+ Generator = generators.load_generator(command_config['generator'])
186
+ if 'options' in yaml_generator_config:
187
+ generator_options = yaml_generator_config.get('options')
188
+ else:
189
+ generator_options = options_processor.parse(_generator_parser(Generator)).__dict__
190
+ generator = Generator(**generator_options)
191
+
192
+ Consumer = consumers.load_consumer(command_config['consumer'])
193
+ if 'options' in yaml_consumer_config:
194
+ consumer_options = yaml_consumer_config.get('options')
195
+ else:
196
+ consumer_options = options_processor.parse(_consumer_parser(Consumer)).__dict__
197
+ consumer = Consumer(**consumer_options)
198
+
199
+ if main_args.help:
200
+ print('=' * 15 + ' Streamline ' + '=' * 15)
201
+ print('\n')
202
+ main_parser.print_help()
203
+ streamer_list = command_config.get('streamers', [])
204
+ if streamer_list:
205
+ for streamer_name in streamer_list:
206
+ print('\n\n')
207
+ print('=' * 15 + ' Streamer::{} '.format(streamer_name) + '=' * 15)
208
+ print('\n')
209
+ load_streamer(streamer_name, print_help=True, ae_args=ae_args)
210
+ else:
211
+ print('\n')
212
+ print('=' * 15 + ' Streamers ' + '=' * 15)
213
+ for streamer_name, streamer in streamers.STREAMERS.items():
214
+ description = getattr(streamer, '_arg_description', 'An undocumented module')
215
+ example = getattr(streamer, '_arg_example', '')
216
+ invocation = 'streamline -s {} --'.format(streamer_name)
217
+ print('\n::{}::\n\tDescription: {}\n\tExample: {} {}'.format(
218
+ streamer_name,
219
+ description,
220
+ invocation,
221
+ example,
222
+ ))
223
+
224
+ return
225
+
226
+ # Parse streamers
227
+ command_streamers = []
228
+ if use_yaml:
229
+ for streamer_conf in yaml_streamers:
230
+ streamer = load_streamer(
231
+ streamer_conf['name'],
232
+ options=streamer_conf.get('options', {}),
233
+ ae_args=ae_args,
234
+ )
235
+ sub_pipeline = wrap_streamer(
236
+ streamer,
237
+ input_path=streamer_conf.get('input'),
238
+ output_path=streamer_conf.get('output'),
239
+ target=streamer_conf.get('target'),
240
+ )
241
+ command_streamers.extend(sub_pipeline)
242
+ else:
243
+ for streamer_name in command_config.get('streamers',[]):
244
+ # Support shorthand syntax "attr=streamer(input.path, output.path)"
245
+ shorthand_match = re.match(SHORTHAND_PATTERN, streamer_name)
246
+ if shorthand_match:
247
+ shorthand_options = shorthand_match.groupdict()
248
+
249
+ # Load the main streamer
250
+ streamer_name = shorthand_options.get('streamer')
251
+ main_streamer = load_streamer(shorthand_options['streamer'], options_processor, ae_args=ae_args)
252
+
253
+ target_attr = shorthand_options.get('target_attr', '')
254
+ if target_attr:
255
+ target_attr = target_attr[:-1]
256
+
257
+ command_streamers.extend(wrap_streamer(
258
+ main_streamer,
259
+ input_path=shorthand_options.get('input_extract', None),
260
+ output_path=shorthand_options.get('output_extract', None),
261
+ target=target_attr,
262
+ ))
263
+ else:
264
+ streamer = load_streamer(streamer_name, options_processor, ae_args=ae_args)
265
+ command_streamers.append(streamer)
266
+
267
+ # Ensure we don't have any extra arguments
268
+ if options_processor.has_remaining_args():
269
+ sys.stderr.write('Extra arguments found: {}\n'.format(' '.join(options_processor.remaining_args())))
270
+ sys.exit(2)
271
+
272
+ # Override streamers
273
+ progress_option = command_config.get('progress', None)
274
+ if command_config.get('progress', None):
275
+ buffer_start = progress_option in ('buffer', 'stream-output')
276
+ buffer_end = progress_option in ('buffer')
277
+ progress = streamers.ProgressStreamer(buffer_start=buffer_start, buffer_end=buffer_end)
278
+ command_streamers = [progress.streamer_start, *command_streamers, progress.streamer_end]
279
+
280
+ future = pipe(generator.stream(), command_streamers, consumer=consumer.stream)
281
+ asyncio.run(future)
282
+
283
+ def load_streamer(path, options_processor=None, options=None, print_help=False, ae_args=None):
284
+ kwargs = {}
285
+ if options:
286
+ kwargs.update(options)
287
+ if path is None:
288
+ return None
289
+ elif '.' in path:
290
+ Streamer = utils.import_obj(path)
291
+ else:
292
+ Streamer = streamers.STREAMERS.get(path)
293
+ if Streamer is None:
294
+ raise ValueError('Invalid streamer: {}'.format(path))
295
+
296
+ if print_help:
297
+ _streamer_parser(Streamer).print_help()
298
+ return None
299
+
300
+ if options_processor:
301
+ streamer_parser = _streamer_parser(Streamer)
302
+ streamer_args = options_processor.parse(streamer_parser)
303
+ kwargs.update(load_config(streamer_args.__dict__))
304
+
305
+
306
+ if hasattr(Streamer, 'async_handler'):
307
+ # This is really a handler that needs wrapped with AsyncExecutor
308
+ Executor = Streamer
309
+ if Executor and hasattr(Executor, 'handle'):
310
+ executor = Executor(**kwargs).handle
311
+ else:
312
+ executor = Executor
313
+
314
+ # Now build the wrapper
315
+ ae = streamers.AsyncExecutor(executor, **ae_args)
316
+ return ae.stream
317
+ else:
318
+ if type(Streamer) == type:
319
+ return Streamer(**kwargs).stream
320
+ return Streamer
321
+
322
+
323
+ def main():
324
+ """Target entry point for standard pyproject.toml scripts."""
325
+ args = sys.argv[1:]
326
+ streamline_command(args)
@@ -0,0 +1,169 @@
1
+ import json
2
+ import csv
3
+ import os
4
+
5
+ from . import utils
6
+
7
+ def stringify_all(source):
8
+ return [utils.force_string(v) for v in source]
9
+
10
+ class FileWriter():
11
+ DEFAULT_OUTPUT = '-'
12
+ DELIMITER = '\n'
13
+
14
+ @classmethod
15
+ def args(cls, parser):
16
+ parser.add_argument(
17
+ '--output',
18
+ default=cls.DEFAULT_OUTPUT,
19
+ help='Set target of output (Default stdout)',
20
+ )
21
+
22
+ def __init__(self, output=None):
23
+ self.target_name = output
24
+ self.target = None
25
+ self.target_template = None
26
+ self.first_written = False
27
+
28
+ self.force_closing_newline = os.environ.get('STREAMLINE_CLOSING_NEWLINE')
29
+
30
+ def _output_value(self, entry):
31
+ return utils.force_string(entry.value)
32
+
33
+ async def stream(self, source):
34
+ output = ''
35
+ if '{' in self.target_name:
36
+ self.target_template = self.target_name
37
+ else:
38
+ self.target = utils.get_file_io(self.target_name, write=True)
39
+ async for entry in source:
40
+ output = self._output_value(entry)
41
+ if self.target_template:
42
+
43
+ file_name = self.target_template.format(input=entry.original_value, index=entry.index)
44
+ with open(file_name, 'w') as target_file:
45
+ target_file.write(output)
46
+ else:
47
+ if self.force_closing_newline:
48
+ output = output + self.DELIMITER
49
+ elif self.first_written:
50
+ output = self.DELIMITER + output
51
+ else:
52
+ self.first_written = True
53
+ self.target.write(output)
54
+ if hasattr(self.target, 'flush'):
55
+ self.target.flush()
56
+
57
+ if self.target and hasattr(self.target, 'close'):
58
+ self.target.close()
59
+
60
+ class CSVWriter():
61
+ DEFAULT_OUTPUT = '-'
62
+
63
+ @classmethod
64
+ def args(cls, parser):
65
+ parser.add_argument('--output', help='Set target of output (Default stdout)', default=cls.DEFAULT_OUTPUT)
66
+ parser.add_argument(
67
+ '--input-column',
68
+ action='store_true',
69
+ default=False,
70
+ help='Automatically add a column for input value',
71
+ )
72
+
73
+ def __init__(self, output=DEFAULT_OUTPUT, input_column=False):
74
+ self.target_name = output
75
+ self.input_column = input_column
76
+
77
+ def _parse_fields(self, entry):
78
+ if not isinstance(entry.value, dict):
79
+ return None
80
+ else:
81
+ return entry.value.keys()
82
+
83
+ def _get_values(self, entry, fields):
84
+ if fields is None:
85
+ return stringify_all((entry.original_value, entry.value))
86
+
87
+ # Return blanks for all invalid rows
88
+ if not isinstance(entry.value, dict):
89
+ return [''] * len(fields)
90
+
91
+ values = [entry.value.get(field, '') for field in fields]
92
+ if self.input_column:
93
+ values.insert(0, entry.original_value)
94
+ return stringify_all(values)
95
+
96
+ async def stream(self, source):
97
+ self.target = utils.get_file_io(self.target_name, write=True)
98
+ writer = csv.writer(self.target, 'unix', quoting=csv.QUOTE_MINIMAL)
99
+
100
+ fields = None
101
+ header_written = False
102
+ async for entry in source:
103
+ # Write csv column names
104
+ if not header_written:
105
+ fields = self._parse_fields(entry)
106
+ if fields is None:
107
+ writer.writerow(['input', 'value'])
108
+ else:
109
+ header = list(fields)
110
+ if self.input_column:
111
+ header.insert(0, 'input')
112
+ writer.writerow(header)
113
+ header_written = True
114
+
115
+ # Write values
116
+ entry_values = self._get_values(entry, fields)
117
+ writer.writerow(entry_values)
118
+
119
+ if hasattr(self.target, 'close'):
120
+ self.target.close()
121
+
122
+ class JsonWriter():
123
+ DEFAULT_OUTPUT = '-'
124
+
125
+ @classmethod
126
+ def args(cls, parser):
127
+ parser.add_argument(
128
+ '--output',
129
+ default=cls.DEFAULT_OUTPUT,
130
+ help='Set target of output (Default stdout)',
131
+ )
132
+
133
+ def __init__(self, output=DEFAULT_OUTPUT):
134
+ self.target_name = output
135
+
136
+ async def stream(self, source):
137
+ self.target = utils.get_file_io(self.target_name, write=True)
138
+ self.target.write('[\n')
139
+ first = True
140
+ async for entry in source:
141
+ if not first:
142
+ self.target.write(',\n ')
143
+ else:
144
+ self.target.write(' ')
145
+ self.target.write(json.dumps(entry.value))
146
+ first = False
147
+ self.target.write('\n]')
148
+
149
+ if hasattr(self.target, 'close'):
150
+ self.target.close()
151
+
152
+
153
+ CONSUMERS = {
154
+ 'file': FileWriter,
155
+ 'csv': CSVWriter,
156
+ 'json': JsonWriter,
157
+ }
158
+
159
+ def load_consumer(path):
160
+ if path is None:
161
+ return None
162
+ elif '.' in path:
163
+ Consumer = utils.import_obj(path)
164
+ else:
165
+ Consumer = CONSUMERS.get(path)
166
+ if Consumer is None:
167
+ raise ValueError('Invalid consumer: {}'.format(path))
168
+ return Consumer
169
+
streamline/core.py ADDED
@@ -0,0 +1,45 @@
1
+ import asyncio
2
+
3
+ async def drain(generator):
4
+ """ A no-op drain of a generator """
5
+ items = []
6
+ async for item in generator:
7
+ items.append(item)
8
+ return items
9
+
10
+ async def transync(synchronous_iter):
11
+ """
12
+ This is a converter which takes a synchronous iterator and yields the items
13
+ asynchronously which allows a standard pipe to convert to an async pipe.
14
+ """
15
+ for item in synchronous_iter:
16
+ yield item
17
+
18
+ async def static_pipe(stream, inputs):
19
+ source = transync(inputs)
20
+ outputs = await drain(stream(source))
21
+ return outputs
22
+
23
+ async def pipe(generator, streamers, consumer=None):
24
+ """
25
+ The "pipe" function is the central piece of a stream, the pipe connects
26
+ the generators together in a chain allowing each to pass the result of the
27
+ previous to the next.
28
+
29
+ The first generator is special as it does not take an input stream.
30
+
31
+ The last piece of the stream is the consumer which is not a generator but
32
+ is expected to drain the generator given to it, writing any valued output.
33
+ """
34
+ if consumer is None:
35
+ consumer = drain
36
+
37
+ pipe = generator
38
+ for streamer in streamers:
39
+ pipe = streamer(pipe)
40
+
41
+ await consumer(pipe)
42
+
43
+ def sync_exec(future):
44
+ result = asyncio.run(future)
45
+ return result
streamline/entries.py ADDED
@@ -0,0 +1,71 @@
1
+ import copy
2
+
3
+ class EntryFactory():
4
+ def __init__(self, error_value=None):
5
+ self.error_value = error_value
6
+ self.index = 0
7
+
8
+ def __call__(self, value):
9
+ entry = Entry(
10
+ value,
11
+ error_value=self.error_value,
12
+ index=self.index
13
+ )
14
+ self.index += 1
15
+ return entry
16
+
17
+ class Entry():
18
+ def __init__(self, value=None, index=None, error_value=None):
19
+ self.index = index
20
+ self.history = [[value]]
21
+ self.errors = []
22
+ self.error_value = error_value
23
+
24
+ def push(self, value=None):
25
+ if value is None:
26
+ value = self.value
27
+ self.history.append([value])
28
+
29
+ def pop(self):
30
+ if len(self.history) == 1:
31
+ raise ValueError('Attempted to pop an entry history that is only 1 level deep!')
32
+ value = self.value
33
+ self.history = self.history[:-1]
34
+ self.value = value
35
+
36
+ def reset(self):
37
+ self.history = [[self.history[0][0]]]
38
+
39
+ def collapse(self):
40
+ self.history[-1] = [self.history[-1][-1]]
41
+
42
+ def get_history(self):
43
+ return self.history[-1]
44
+
45
+ @property
46
+ def original_value(self):
47
+ return self.history[-1][0]
48
+
49
+ def get_value(self):
50
+ return self.history[-1][-1]
51
+
52
+ def set_value(self, new_value):
53
+ self.history[-1].append(new_value)
54
+
55
+ def error(self, e):
56
+ self.errors.append(e)
57
+ self.value = self.error_value
58
+
59
+ def clone(self):
60
+ new_clone = Entry(index=self.index, error_value=self.error_value)
61
+ new_clone.errors = self.errors.copy()
62
+ new_clone.history = [h.copy() for h in self.history]
63
+ return new_clone
64
+
65
+ value = property(get_value, set_value)
66
+
67
+ def entry_wrap(items):
68
+ return [Entry(item) for item in items]
69
+
70
+ def entry_unwrap(entries):
71
+ return [entry.value for entry in entries]