Skip to content

Commit 7010475

Browse files
committed
fix: add usage check for lookup_chain and enhance error handling
1 parent bd20fa9 commit 7010475

File tree

2 files changed

+92
-33
lines changed

2 files changed

+92
-33
lines changed

src/dotcat/dotcat.py

Lines changed: 68 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@
2626

2727
ParsedData = Union[Dict[str, Any], List[Any]]
2828

29-
LIST_ACCESS_SYMBOL = '@'
30-
SLICE_SYMBOL = ':'
29+
LIST_ACCESS_SYMBOL = "@"
30+
SLICE_SYMBOL = ":"
3131

3232
######################################################################
3333
# Output formatting functions
@@ -79,6 +79,7 @@ def bold(text: str) -> str:
7979

8080
class ParseError(Exception):
8181
"""Custom exception for parsing errors."""
82+
8283
pass
8384

8485

@@ -93,6 +94,7 @@ def parse_ini(file: StringIO) -> Dict[str, Dict[str, str]]:
9394
The parsed content as a dictionary.
9495
"""
9596
from configparser import ConfigParser
97+
9698
config = ConfigParser()
9799
config.read_file(file)
98100
return {s: dict(config.items(s)) for s in config.sections()}
@@ -109,11 +111,11 @@ def parse_yaml(file: StringIO) -> ParsedData:
109111
The parsed content.
110112
"""
111113
import yaml
114+
112115
try:
113116
return yaml.safe_load(file)
114117
except yaml.YAMLError as e:
115-
raise ParseError(
116-
f"[ERROR] {file.name}: Unable to parse YAML file: {str(e)}")
118+
raise ParseError(f"[ERROR] {file.name}: Unable to parse YAML file: {str(e)}")
117119

118120

119121
def parse_json(file: StringIO) -> ParsedData:
@@ -127,11 +129,11 @@ def parse_json(file: StringIO) -> ParsedData:
127129
The parsed content.
128130
"""
129131
import json
132+
130133
try:
131134
return json.load(file)
132135
except json.JSONDecodeError as e:
133-
raise ParseError(
134-
f"[ERROR] {file.name}: Unable to parse JSON file: {str(e)}")
136+
raise ParseError(f"[ERROR] {file.name}: Unable to parse JSON file: {str(e)}")
135137

136138

137139
def parse_toml(file: StringIO) -> ParsedData:
@@ -145,18 +147,18 @@ def parse_toml(file: StringIO) -> ParsedData:
145147
The parsed content.
146148
"""
147149
import toml
150+
148151
try:
149152
return toml.load(file)
150153
except toml.TomlDecodeError as e:
151-
raise ParseError(
152-
f"[ERROR] {file.name}: Unable to parse TOML file: {str(e)}")
154+
raise ParseError(f"[ERROR] {file.name}: Unable to parse TOML file: {str(e)}")
153155

154156

155157
FORMATS = [
156-
(['.json'], parse_json),
157-
(['.yaml', '.yml'], parse_yaml),
158-
(['.toml'], parse_toml),
159-
(['.ini'], parse_ini)
158+
([".json"], parse_json),
159+
([".yaml", ".yml"], parse_yaml),
160+
([".toml"], parse_toml),
161+
([".ini"], parse_ini),
160162
]
161163

162164

@@ -174,7 +176,7 @@ def parse_file(filename: str) -> ParsedData:
174176
parsers = [parser for fmts, parser in FORMATS if ext in fmts]
175177

176178
try:
177-
with open(filename, 'r') as file:
179+
with open(filename, "r") as file:
178180
content = file.read().strip()
179181
if not content:
180182
raise ValueError(f"[ERROR] {filename}: File is empty")
@@ -190,6 +192,7 @@ def parse_file(filename: str) -> ParsedData:
190192
except Exception as e:
191193
raise ValueError(f"[ERROR] {filename}: Unable to parse file: {str(e)}")
192194

195+
193196
######################################################################
194197
# Formatting
195198
######################################################################
@@ -207,34 +210,38 @@ def format_output(data: Any, output_format: str) -> str:
207210
The formatted output.
208211
"""
209212

210-
if output_format == 'raw':
213+
if output_format == "raw":
211214
return str(data)
212-
if output_format in ('formatted', 'json'):
215+
if output_format in ("formatted", "json"):
213216
import json
214217

215218
def date_converter(o):
216219
if isinstance(o, (date, datetime)):
217220
return o.isoformat()
218221
return o
219222

220-
indent = 4 if output_format == 'formatted' else None
223+
indent = 4 if output_format == "formatted" else None
221224
return json.dumps(data, indent=indent, default=date_converter)
222-
elif output_format == 'yaml':
225+
elif output_format == "yaml":
223226
import yaml
227+
224228
return yaml.dump(data, default_flow_style=False)
225-
elif output_format == 'toml':
229+
elif output_format == "toml":
226230
import toml
231+
227232
# Check if it's a list of dicts
228233
if isinstance(data, list) and all(isinstance(item, dict) for item in data):
229234
# If it's a list of dictionaries, wrap it in a dictionary with a key like "items"
230235
return toml.dumps({"items": data}) # Wrap the list
231236
else:
232237
return toml.dumps(data) # Handle other cases as before
233238

234-
elif output_format == 'ini':
239+
elif output_format == "ini":
235240
config = ConfigParser()
236-
if not isinstance(data, dict) or not all(isinstance(v, dict) for v in data.values()):
237-
data = {'default': data}
241+
if not isinstance(data, dict) or not all(
242+
isinstance(v, dict) for v in data.values()
243+
):
244+
data = {"default": data}
238245
for section, values in data.items():
239246
config[section] = values
240247
output = StringIO()
@@ -243,6 +250,7 @@ def date_converter(o):
243250
else:
244251
return str(data)
245252

253+
246254
######################################################################
247255
# Data access functions
248256
######################################################################
@@ -266,6 +274,7 @@ def access_list(data: Any, key: str, index: str) -> Any:
266274
else:
267275
return data.get(key)[int(index)]
268276

277+
269278
def from_attr_chain(data: Dict[str, Any], lookup_chain: str) -> Any:
270279
"""
271280
Accesses a nested dictionary value with an attribute chain encoded by a dot-separated string.
@@ -278,22 +287,22 @@ def from_attr_chain(data: Dict[str, Any], lookup_chain: str) -> Any:
278287
The value at the specified nested key, or None if the key doesn't exist.
279288
"""
280289
if data is None:
281-
chain = lookup_chain.split('.')[0]
282-
raise KeyError(
283-
f"[ERROR] key '{bold({chain})}' not found in {italics('')}")
290+
chain = lookup_chain.split(".")[0]
291+
raise KeyError(f"[ERROR] key '{bold({chain})}' not found in {italics('')}")
284292
found_keys = []
285-
for key in lookup_chain.split('.'):
293+
for key in lookup_chain.split("."):
286294
if LIST_ACCESS_SYMBOL in key:
287295
key, index = key.split(LIST_ACCESS_SYMBOL)
288296
data = access_list(data, key, index)
289297
else:
290298
data = data.get(key)
291299
if data is None:
292-
keys = '.'.join(found_keys)
300+
keys = ".".join(found_keys)
293301
raise KeyError(f"[ERROR] key '{key}' not found in { keys}")
294302
found_keys.append(key)
295303
return data
296304

305+
297306
######################################################################
298307
# Argument parsing, main, and run functions
299308
######################################################################
@@ -310,17 +319,37 @@ def parse_args(args: List[str]) -> Tuple[str, str, str, bool]:
310319
The filename, lookup chain, output format, and check_install flag.
311320
"""
312321
parser = argparse.ArgumentParser(add_help=False)
313-
parser.add_argument('file', type=str, nargs='?', help='The file to read from')
314-
parser.add_argument('dot_separated_key', type=str, nargs='?', help='The dot-separated key to look up')
315-
parser.add_argument('--output', type=str, default='raw', help='The output format (raw, formatted, json, yaml, toml, ini)')
316-
parser.add_argument('--check-install', action='store_true', help='Check if required packages are installed')
322+
parser.add_argument("file", type=str, nargs="?", help="The file to read from")
323+
parser.add_argument(
324+
"dot_separated_key",
325+
type=str,
326+
nargs="?",
327+
help="The dot-separated key to look up",
328+
)
329+
parser.add_argument(
330+
"--output",
331+
type=str,
332+
default="raw",
333+
help="The output format (raw, formatted, json, yaml, toml, ini)",
334+
)
335+
parser.add_argument(
336+
"--check-install",
337+
action="store_true",
338+
help="Check if required packages are installed",
339+
)
317340

318341
if args is None or len(args) < 1:
319342
print(USAGE)
320343
sys.exit(2)
321344

322345
parsed_args = parser.parse_args(args)
323-
return parsed_args.file, parsed_args.dot_separated_key, parsed_args.output, parsed_args.check_install
346+
return (
347+
parsed_args.file,
348+
parsed_args.dot_separated_key,
349+
parsed_args.output,
350+
parsed_args.check_install,
351+
)
352+
324353

325354
def run(args: List[str] = None) -> None:
326355
"""
@@ -336,6 +365,11 @@ def run(args: List[str] = None) -> None:
336365
check_install()
337366
return
338367

368+
# Check if lookup_chain is provided
369+
if lookup_chain is None:
370+
print(USAGE)
371+
sys.exit(2) # Invalid usage
372+
339373
# gets the parsed data
340374
try:
341375
data = parse_file(filename)
@@ -361,10 +395,11 @@ def main() -> None:
361395
"""
362396
run(sys.argv[1:])
363397

398+
364399
def check_install():
365-
import json, yaml, toml
366400
print("Dotcat is good to go.")
367401
return
368402

369-
if __name__ == '__main__':
403+
404+
if __name__ == "__main__":
370405
main()

tests/test_exec.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,27 @@ def test_check_install():
114114
sys.stdout = sys.__stdout__
115115
actual_output = captured_output.getvalue().strip()
116116
assert actual_output == "Dotcat is good to go."
117+
118+
119+
def test_file_without_dot_pattern():
120+
test_args = ["tests/fixtures/test.json"]
121+
captured_output = StringIO()
122+
sys.stdout = captured_output
123+
with pytest.raises(SystemExit) as pytest_wrapped_e:
124+
run(test_args)
125+
sys.stdout = sys.__stdout__
126+
expected_output = """
127+
dotcat
128+
Read values, including nested values, from structured data files (JSON, YAML, TOML, INI)
129+
130+
USAGE:
131+
dotcat <file> <dot_separated_key>
132+
133+
EXAMPLE:
134+
dotcat config.json python.editor.tabSize
135+
dotcat somefile.toml a.b.c
136+
""".strip()
137+
actual_output = remove_ansi_escape_sequences(captured_output.getvalue().strip())
138+
assert actual_output == expected_output
139+
assert pytest_wrapped_e.type == SystemExit
140+
assert pytest_wrapped_e.value.code == 2

0 commit comments

Comments
 (0)