zendt 0.1.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.
zendt/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from zendt.main import apply, flow, rules_list
2
+
3
+ __all__ = ['apply', 'flow', 'rules_list']
zendt/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from zendt.cli import app
2
+
3
+ if __name__ == '__main__':
4
+ app()
zendt/cli.py ADDED
@@ -0,0 +1,167 @@
1
+ import sys
2
+ from typing import Annotated
3
+
4
+ import pandas as pd
5
+ import typer
6
+
7
+ from zendt.config.create import create_config
8
+ from zendt.config.edit import edit_config
9
+ from zendt.engine.output import write_output
10
+ from zendt.errors import ZendtError
11
+ from zendt.main import apply, flow, rules_list
12
+
13
+ app = typer.Typer(
14
+ name='zendt',
15
+ help='Apply JDM (GoRules) rules to data from the terminal.',
16
+ no_args_is_help=True,
17
+ )
18
+
19
+ SourceOption = typer.Option(
20
+ None,
21
+ '--source',
22
+ '-s',
23
+ help='GitHub URL, local path, or id of a configured source.',
24
+ )
25
+
26
+ FindOption = typer.Option(
27
+ None,
28
+ '--find',
29
+ '-f',
30
+ help='Search for rules whose name contains the specified word.',
31
+ )
32
+
33
+ VersionOption = typer.Option(
34
+ None,
35
+ '--version',
36
+ '-v',
37
+ help='Rule version to apply for git repositories.',
38
+ )
39
+
40
+ AuthOption = typer.Option(
41
+ None,
42
+ '--authentication',
43
+ '-a',
44
+ help=(
45
+ 'Name of the environment variable that holds the token '
46
+ '(private repositories).'
47
+ ),
48
+ )
49
+
50
+ InputOption = typer.Option(
51
+ None,
52
+ '--input',
53
+ '-i',
54
+ help='Input file to process.',
55
+ )
56
+
57
+ OutputOption = typer.Option(
58
+ None,
59
+ '--output',
60
+ '-o',
61
+ help='Output file to save.',
62
+ )
63
+
64
+ PrintHeadOption = typer.Option(
65
+ False,
66
+ '--printhead',
67
+ '-ph',
68
+ help='Print a preview of the result on screen.',
69
+ )
70
+
71
+ CreateConfigOption = typer.Option(
72
+ False,
73
+ '--create',
74
+ '-c',
75
+ help='Create a zendt.json configuration file.',
76
+ )
77
+
78
+ EditConfigOption = typer.Option(
79
+ False,
80
+ '--edit',
81
+ '-e',
82
+ help='Edit the zendt.json configuration file.',
83
+ )
84
+
85
+
86
+ def echo_head(frame: pd.DataFrame) -> None:
87
+ """Preview on the terminal.
88
+
89
+ Keep it off stdout when that stream carries the csv.
90
+ """
91
+ typer.echo(frame.head(), err=not sys.stdout.isatty())
92
+
93
+
94
+ @app.command('config')
95
+ def config(
96
+ create: bool = CreateConfigOption,
97
+ edit: bool = EditConfigOption,
98
+ ) -> None:
99
+ """Manage the zendt.json configuration file."""
100
+ if create:
101
+ create_config()
102
+ elif edit:
103
+ edit_config()
104
+
105
+
106
+ @app.command('list')
107
+ def list_rules_command(
108
+ source: str = SourceOption,
109
+ find: str = FindOption,
110
+ version: str = VersionOption,
111
+ authentication: str = AuthOption,
112
+ ) -> None:
113
+ """List the JDM rules found in SOURCE."""
114
+ try:
115
+ names = rules_list(source, find, version, authentication)
116
+ except ZendtError as exc:
117
+ typer.echo(str(exc), err=True)
118
+ raise typer.Exit(code=exc.exit_code) from exc
119
+
120
+ typer.echo('Rules found in provided sources:')
121
+ for name in names:
122
+ typer.echo(name)
123
+
124
+
125
+ @app.command('apply')
126
+ def apply_command(
127
+ rules: Annotated[
128
+ list[str], typer.Argument(help='Names of the rules to apply.')
129
+ ],
130
+ source: str = SourceOption,
131
+ authentication: str = AuthOption,
132
+ version: str = VersionOption,
133
+ input: str = InputOption,
134
+ output: str = OutputOption,
135
+ printhead: bool = PrintHeadOption,
136
+ ) -> None:
137
+ """Apply the JDM rules passed as arguments."""
138
+ try:
139
+ result = apply(rules, source, authentication, version, input)
140
+ write_output(result, output)
141
+ if printhead:
142
+ echo_head(result)
143
+ except ZendtError as exc:
144
+ typer.echo(str(exc), err=True)
145
+ raise typer.Exit(code=exc.exit_code) from exc
146
+
147
+
148
+ @app.command('flow')
149
+ def flow_command(
150
+ flow_id: Annotated[str, typer.Argument(help='Configured flow ID.')],
151
+ input: str = InputOption,
152
+ output: str = OutputOption,
153
+ printhead: bool = PrintHeadOption,
154
+ ) -> None:
155
+ """Apply a pre-configured flow."""
156
+ try:
157
+ result = flow(flow_id, input)
158
+ except ZendtError as exc:
159
+ typer.echo(str(exc), err=True)
160
+ raise typer.Exit(code=exc.exit_code) from exc
161
+
162
+ destination = output
163
+ if destination is None:
164
+ destination = result.attrs.get('output')
165
+ write_output(result, destination)
166
+ if printhead:
167
+ echo_head(result)
File without changes
zendt/config/create.py ADDED
@@ -0,0 +1,166 @@
1
+ from pathlib import Path
2
+
3
+ import typer
4
+
5
+ from zendt.config.helper import save_config
6
+ from zendt.utils.paths import is_remote
7
+
8
+
9
+ def create_source() -> dict:
10
+ """Create a new source."""
11
+ typer.echo('Enter a unique name for your source (ID):')
12
+ source_id = typer.prompt('Source ID')
13
+
14
+ typer.echo('Enter the source location (local path or git URL):')
15
+ source_location = typer.prompt('Source location')
16
+
17
+ if is_remote(source_location):
18
+ is_private = typer.confirm('Is this a private repository?')
19
+ if is_private:
20
+ typer.echo(
21
+ 'Enter the name of the environment variable for the '
22
+ 'authentication token [do not enter the token]:'
23
+ )
24
+ source_authentication = typer.prompt('Source Authentication')
25
+ else:
26
+ source_authentication = None
27
+
28
+ version = typer.confirm(
29
+ 'Would you like to specify a version for this source? '
30
+ 'If no, the ref in the URL is used, or HEAD when the URL has none.'
31
+ )
32
+ if version:
33
+ typer.echo('Enter the source version:')
34
+ source_version = typer.prompt('Source Version')
35
+ else:
36
+ source_version = None
37
+ else:
38
+ source_authentication = None
39
+ source_version = None
40
+
41
+ source = {}
42
+ source['id'] = source_id
43
+ source['source'] = source_location
44
+ if source_authentication:
45
+ source['authentication'] = source_authentication
46
+ if source_version:
47
+ source['version'] = source_version
48
+
49
+ return source
50
+
51
+
52
+ def take_source(sources: list[dict], source: dict) -> bool:
53
+ """Append a source when its id is usable.
54
+
55
+ False leaves the list unchanged.
56
+ """
57
+ source_id = source.get('id', '')
58
+ if not source_id or '/' in source_id:
59
+ typer.echo(
60
+ "Source id is required and cannot contain '/'. "
61
+ 'Source was not added.'
62
+ )
63
+ return False
64
+ if any(item.get('id') == source_id for item in sources):
65
+ typer.echo(
66
+ f"Source '{source_id}' already exists. Source was not added."
67
+ )
68
+ return False
69
+ sources.append(source)
70
+ return True
71
+
72
+
73
+ def create_flow() -> dict:
74
+ """Create a new flow."""
75
+ flow_id = typer.prompt('Enter a unique name for your flow (ID):')
76
+ flow_description = typer.prompt('Describe your flow in a few words:')
77
+
78
+ flow_rules = [
79
+ rule.strip()
80
+ for rule in typer.prompt(
81
+ 'Enter the rule(s) for your flow, comma separated:'
82
+ ).split(',')
83
+ ]
84
+
85
+ input_file = typer.confirm(
86
+ 'Would you like to specify a input file for your flow? '
87
+ 'Accepted formats: csv, csv.gz, xlsx, json'
88
+ )
89
+ if input_file:
90
+ flow_input = typer.prompt('Flow Input Path [example: data/input.csv]:')
91
+ else:
92
+ flow_input = None
93
+
94
+ output_file = typer.confirm(
95
+ 'Would you like to specify a output file for your flow? '
96
+ 'Accepted formats: csv, csv.gz, xlsx, json'
97
+ )
98
+ if output_file:
99
+ flow_output = typer.prompt(
100
+ 'Flow Output Path [example: data/output.csv]:'
101
+ )
102
+ else:
103
+ flow_output = None
104
+
105
+ flow = {}
106
+ flow['id'] = flow_id
107
+ flow['description'] = flow_description
108
+ flow['rules'] = flow_rules
109
+ if flow_input:
110
+ flow['input'] = flow_input
111
+ if flow_output:
112
+ flow['output'] = flow_output
113
+
114
+ return flow
115
+
116
+
117
+ def take_flow(flows: list[dict], flow: dict) -> bool:
118
+ """Append a flow when its id and rules are usable.
119
+
120
+ False leaves the list unchanged.
121
+ """
122
+ flow_id = flow.get('id', '')
123
+ if not flow_id:
124
+ typer.echo('Flow id is required. Flow was not added.')
125
+ return False
126
+ if any(item.get('id') == flow_id for item in flows):
127
+ typer.echo(f"Flow '{flow_id}' already exists. Flow was not added.")
128
+ return False
129
+ flow['rules'] = [rule for rule in flow.get('rules') or [] if rule]
130
+ if not flow['rules']:
131
+ typer.echo('A flow needs at least one rule. Flow was not added.')
132
+ return False
133
+ flows.append(flow)
134
+ return True
135
+
136
+
137
+ def create_config() -> None:
138
+ """Create a new config file."""
139
+
140
+ path = Path.cwd() / 'zendt.json'
141
+ if path.exists():
142
+ if not typer.confirm(
143
+ f'Config file already exists at {path}. '
144
+ 'Would you like to overwrite it?'
145
+ ):
146
+ typer.echo('Config file not overwritten.')
147
+ return
148
+
149
+ config = {
150
+ 'sources': [],
151
+ 'flows': [],
152
+ }
153
+
154
+ typer.echo("Let's create the first source...")
155
+ while True:
156
+ if take_source(config['sources'], create_source()):
157
+ if not typer.confirm('Do you want to add another source?'):
158
+ break
159
+
160
+ if typer.confirm('Would you like to create a flow?'):
161
+ while True:
162
+ if take_flow(config['flows'], create_flow()):
163
+ if not typer.confirm('Do you want to add another flow?'):
164
+ break
165
+
166
+ save_config(config)
zendt/config/edit.py ADDED
@@ -0,0 +1,252 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import typer
5
+
6
+ from zendt.config.create import (
7
+ create_flow,
8
+ create_source,
9
+ take_flow,
10
+ take_source,
11
+ )
12
+ from zendt.config.helper import save_config
13
+ from zendt.utils.paths import is_remote
14
+
15
+
16
+ def open_config() -> dict | None:
17
+ """Load zendt.json from the current directory."""
18
+ path = Path.cwd() / 'zendt.json'
19
+ if not path.exists():
20
+ typer.echo(
21
+ 'Config file not found. Please create it first using '
22
+ "'zendt config --create'."
23
+ )
24
+ return None
25
+ try:
26
+ config = json.loads(path.read_text(encoding='utf-8'))
27
+ except Exception as exc:
28
+ typer.echo(f'Error loading config file: {exc}')
29
+ return None
30
+
31
+ if not isinstance(config, dict) or not isinstance(
32
+ config.get('sources'), list
33
+ ):
34
+ typer.echo("Config file is missing a 'sources' array.")
35
+ return None
36
+ if 'flows' not in config:
37
+ config['flows'] = []
38
+ elif not isinstance(config['flows'], list):
39
+ typer.echo("Config file 'flows' must be an array.")
40
+ return None
41
+ return config
42
+
43
+
44
+ def edit_config() -> None:
45
+ """Edit sources and flows in zendt.json."""
46
+ config = open_config()
47
+ if config is None:
48
+ return
49
+
50
+ while True:
51
+ typer.echo('What would you like to edit?')
52
+ typer.echo('1. Sources')
53
+ typer.echo('2. Flows')
54
+ typer.echo('3. Save and exit')
55
+ choice = typer.prompt('Enter your choice').strip()
56
+ if choice == '1':
57
+ edit_sources(config)
58
+ elif choice == '2':
59
+ edit_flows(config)
60
+ elif choice == '3':
61
+ if save_config(config):
62
+ return
63
+ else:
64
+ typer.echo('Invalid choice.')
65
+
66
+
67
+ def edit_sources(config: dict) -> None:
68
+ """Create, delete, or edit a source."""
69
+ list_items(config['sources'], 'sources')
70
+ typer.echo('1. Create')
71
+ typer.echo('2. Delete')
72
+ typer.echo('3. Edit')
73
+ typer.echo('4. Back')
74
+ choice = typer.prompt('Enter your choice').strip()
75
+ if choice == '1':
76
+ add_source(config)
77
+ elif choice == '2':
78
+ delete_by_id(config['sources'], 'source', keep_one=True)
79
+ elif choice == '3':
80
+ edit_source(config)
81
+ elif choice == '4':
82
+ return
83
+ else:
84
+ typer.echo('Invalid choice.')
85
+
86
+
87
+ def edit_flows(config: dict) -> None:
88
+ """Create, delete, or edit a flow."""
89
+ list_items(config['flows'], 'flows')
90
+ typer.echo('1. Create')
91
+ typer.echo('2. Delete')
92
+ typer.echo('3. Edit')
93
+ typer.echo('4. Back')
94
+ choice = typer.prompt('Enter your choice').strip()
95
+ if choice == '1':
96
+ add_flow(config)
97
+ elif choice == '2':
98
+ delete_by_id(config['flows'], 'flow')
99
+ elif choice == '3':
100
+ edit_flow(config)
101
+ elif choice == '4':
102
+ return
103
+ else:
104
+ typer.echo('Invalid choice.')
105
+
106
+
107
+ def list_items(items: list[dict], kind: str) -> None:
108
+ if not items:
109
+ typer.echo(f'No {kind} yet.')
110
+ return
111
+ typer.echo(f'Current {kind}:')
112
+ for item in items:
113
+ item_id = item.get('id', '?')
114
+ if kind == 'sources':
115
+ typer.echo(f' {item_id}: {item.get("source", "")}')
116
+ else:
117
+ rules = ', '.join(item.get('rules') or [])
118
+ typer.echo(f' {item_id}: {rules}')
119
+
120
+
121
+ def find_by_id(items: list[dict], item_id: str) -> dict | None:
122
+ for item in items:
123
+ if item.get('id') == item_id:
124
+ return item
125
+ return None
126
+
127
+
128
+ def add_source(config: dict) -> None:
129
+ take_source(config['sources'], create_source())
130
+
131
+
132
+ def add_flow(config: dict) -> None:
133
+ take_flow(config['flows'], create_flow())
134
+
135
+
136
+ def delete_by_id(
137
+ items: list[dict], kind: str, *, keep_one: bool = False
138
+ ) -> None:
139
+ item_id = typer.prompt(f'Enter the {kind} ID').strip()
140
+ item = find_by_id(items, item_id)
141
+ if item is None:
142
+ typer.echo(f'{kind.capitalize()} not found.')
143
+ return
144
+ if keep_one and len(items) <= 1:
145
+ typer.echo(
146
+ 'At least one source is required. Add another source '
147
+ 'before deleting this one.'
148
+ )
149
+ return
150
+ if not typer.confirm(f"Delete {kind} '{item_id}'?"):
151
+ typer.echo(f'{kind.capitalize()} was not deleted.')
152
+ return
153
+ items.remove(item)
154
+
155
+
156
+ def prompt_keep(
157
+ label: str, current: str | None, *, allow_clear: bool = False
158
+ ) -> str | None:
159
+ """Ask for a new value. Enter keeps the current one.
160
+
161
+ '-' clears optional fields.
162
+ """
163
+ shown = current if current else '(none)'
164
+ hint = "enter to keep, '-' to clear" if allow_clear else 'enter to keep'
165
+ value = typer.prompt(
166
+ f'{label} [{hint}: {shown}]',
167
+ default=current or '',
168
+ show_default=False,
169
+ ).strip()
170
+ if allow_clear and value == '-':
171
+ return None
172
+ if not value:
173
+ return current
174
+ return value
175
+
176
+
177
+ def set_optional(item: dict, key: str, value: str | None) -> None:
178
+ if value:
179
+ item[key] = value
180
+ else:
181
+ item.pop(key, None)
182
+
183
+
184
+ def edit_source(config: dict) -> None:
185
+ source_id = typer.prompt('Enter the source ID').strip()
186
+ source = find_by_id(config['sources'], source_id)
187
+ if source is None:
188
+ typer.echo('Source not found.')
189
+ return
190
+
191
+ typer.echo(f'Editing source {source_id}...')
192
+ location = prompt_keep('Source location', source.get('source'))
193
+ if not location:
194
+ typer.echo('Source location is required. This source was not changed.')
195
+ return
196
+ source['source'] = location
197
+
198
+ if not is_remote(location):
199
+ had_remote_fields = 'authentication' in source or 'version' in source
200
+ source.pop('authentication', None)
201
+ source.pop('version', None)
202
+ if had_remote_fields:
203
+ typer.echo(
204
+ 'Authentication and version were removed because '
205
+ 'this source is local.'
206
+ )
207
+ return
208
+
209
+ authentication = prompt_keep(
210
+ 'Source authentication (env var name, not the token)',
211
+ source.get('authentication'),
212
+ allow_clear=True,
213
+ )
214
+ version = prompt_keep(
215
+ 'Source version', source.get('version'), allow_clear=True
216
+ )
217
+ set_optional(source, 'authentication', authentication)
218
+ set_optional(source, 'version', version)
219
+
220
+
221
+ def edit_flow(config: dict) -> None:
222
+ flow_id = typer.prompt('Enter the flow ID').strip()
223
+ flow = find_by_id(config['flows'], flow_id)
224
+ if flow is None:
225
+ typer.echo('Flow not found.')
226
+ return
227
+
228
+ typer.echo(f'Editing flow {flow_id}...')
229
+ description = prompt_keep(
230
+ 'Description', flow.get('description'), allow_clear=True
231
+ )
232
+ rules_value = prompt_keep(
233
+ 'Rules (comma separated)', ', '.join(flow.get('rules') or [])
234
+ )
235
+ rules = [
236
+ part.strip() for part in (rules_value or '').split(',') if part.strip()
237
+ ]
238
+ if not rules:
239
+ typer.echo(
240
+ 'A flow needs at least one rule. This flow was not changed.'
241
+ )
242
+ return
243
+ set_optional(flow, 'description', description)
244
+ flow['rules'] = rules
245
+ flow_input = prompt_keep(
246
+ 'Input path', flow.get('input'), allow_clear=True
247
+ )
248
+ flow_output = prompt_keep(
249
+ 'Output path', flow.get('output'), allow_clear=True
250
+ )
251
+ set_optional(flow, 'input', flow_input)
252
+ set_optional(flow, 'output', flow_output)
zendt/config/helper.py ADDED
@@ -0,0 +1,26 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import typer
5
+
6
+ from zendt.errors import ConfigError
7
+ from zendt.rules.configs import validate_parameters
8
+
9
+
10
+ def save_config(config: dict) -> bool:
11
+ """Write zendt.json. Returns False when the file was left unchanged."""
12
+ try:
13
+ validate_parameters(config)
14
+ except ConfigError as exc:
15
+ typer.echo(str(exc))
16
+ typer.echo('Config file was not saved.')
17
+ return False
18
+
19
+ path = Path.cwd() / 'zendt.json'
20
+ try:
21
+ path.write_text(json.dumps(config, indent=4), encoding='utf-8')
22
+ except Exception as exc:
23
+ typer.echo(f'Error writing config file: {exc}')
24
+ return False
25
+ typer.echo('Saved zendt.json.')
26
+ return True
File without changes