Skip to content

Commit bbbb611

Browse files
committed
Merge remote-tracking branch 'origin/configs'
2 parents ce4ff32 + 147c013 commit bbbb611

File tree

3 files changed

+174
-1
lines changed

3 files changed

+174
-1
lines changed

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ pip install -e .
5555
- [Save](#save)
5656
- [Load](#load)
5757
- [Load from dict](#load-from-dict)
58+
+ [Loading from configuration files](#loading-from-configuration-files)
5859

5960
## Tap is Python-native
6061
To see this, let's look at an example:
@@ -446,3 +447,22 @@ args.from_dict({
446447
```
447448

448449
Note: As with `load`, all required arguments must be present in the dictionary if not already set in the Tap object. All values in the provided dictionary will overwrite values currently in the Tap object.
450+
451+
### Loading from configuration files
452+
Configuration files can be loaded along with arguments with the optional flag `config_files: List[str]`. Arguments passed in from the command line overwrite arguments from the configuration files. Arguments in configuration files that appear later in the list overwrite the arguments in previous configuration files.
453+
454+
For example, if you have the config file `my_config.txt`
455+
```
456+
--arg1 1
457+
--arg2 two
458+
```
459+
then you can write
460+
```python
461+
from tap import Tap
462+
463+
class Args(Tap):
464+
arg1: int
465+
arg2: str
466+
467+
args = Args(config_files=['my_config']).parse_args()
468+
```

tap/tap.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from argparse import ArgumentParser, _SubParsersAction
1+
from argparse import ArgumentParser
22
from collections import OrderedDict
33
from copy import deepcopy
44
import json
@@ -53,6 +53,7 @@ def __init__(self,
5353
*args,
5454
underscores_to_dashes: bool = False,
5555
explicit_bool: bool = False,
56+
config_files: Optional[List[str]] = None,
5657
**kwargs):
5758
"""Initializes the Tap instance.
5859
@@ -61,6 +62,10 @@ def __init__(self,
6162
:param explicit_bool: Booleans can be specified on the command line as "--arg True" or "--arg False"
6263
rather than "--arg". Additionally, booleans can be specified by prefixes of True and False
6364
with any capitalization as well as 1 or 0.
65+
:param config_files: A list of paths to configuration files containing the command line arguments
66+
(e.g., '--arg1 a1 --arg2 a2'). Arguments passed in from the command line
67+
overwrite arguments from the configuration files. Arguments in configuration files
68+
that appear later in the list overwrite the arguments in previous configuration files.
6469
:param kwargs: Keyword arguments passed to the super class ArgumentParser.
6570
"""
6671
# Whether the Tap object has been initialized
@@ -96,6 +101,9 @@ def __init__(self,
96101
# Stores all of the subparsers
97102
self._subparsers = None
98103

104+
# Load in the configuration files
105+
self.args_from_configs = self._load_from_config_files(config_files)
106+
99107
# Perform additional configuration such as modifying add_arguments or adding subparsers
100108
self._configure()
101109

@@ -377,6 +385,12 @@ def parse_args(self: TapType,
377385
if self._parsed:
378386
raise ValueError('parse_args can only be called once.')
379387

388+
# Collect arguments from all of the configs
389+
config_args = [arg for args_from_config in self.args_from_configs for arg in args_from_config.split()]
390+
391+
# Add config args at lower precedence and extract args from the command line if they are not passed explicitly
392+
args = config_args + (sys.argv[1:] if args is None else list(args))
393+
380394
# Parse args using super class ArgumentParser's parse_args or parse_known_args function
381395
if known_only:
382396
default_namespace, self.extra_args = super(Tap, self).parse_known_args(args)
@@ -602,6 +616,25 @@ def load(self,
602616

603617
return self
604618

619+
def _load_from_config_files(self, config_files: Optional[List[str]]) -> List[str]:
620+
"""Loads arguments from a list of configuration files containing command line arguments.
621+
622+
:param config_files: A list of paths to configuration files containing the command line arguments
623+
(e.g., '--arg1 a1 --arg2 a2'). Arguments passed in from the command line
624+
overwrite arguments from the configuration files. Arguments in configuration files
625+
that appear later in the list overwrite the arguments in previous configuration files.
626+
:return: A list of the contents of each config file in order of increasing precedence (highest last).
627+
"""
628+
args_from_config = []
629+
630+
if config_files is not None:
631+
# Read arguments from all configs from the lowest precedence config to the highest
632+
for file in config_files:
633+
with open(file) as f:
634+
args_from_config.append(f.read().strip())
635+
636+
return args_from_config
637+
605638
def __str__(self) -> str:
606639
"""Returns a string representation of self.
607640

tests/test_load_config_files.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import sys
2+
from tempfile import NamedTemporaryFile
3+
import unittest
4+
from unittest import TestCase
5+
6+
from tap import Tap
7+
8+
9+
class LoadConfigFilesTests(TestCase):
10+
11+
def setUp(self) -> None:
12+
class DevNull:
13+
def write(self, msg):
14+
pass
15+
self.dev_null = DevNull()
16+
17+
def test_file_does_not_exist(self) -> None:
18+
class EmptyTap(Tap):
19+
pass
20+
21+
with self.assertRaises(FileNotFoundError):
22+
EmptyTap(config_files=['nope']).parse_args([])
23+
24+
def test_single_config(self) -> None:
25+
class SimpleTap(Tap):
26+
a: int
27+
b: str = 'b'
28+
29+
with NamedTemporaryFile() as f:
30+
f.write(b'--a 1')
31+
f.flush()
32+
args = SimpleTap(config_files=[f.name]).parse_args([])
33+
34+
self.assertEqual(args.a, 1)
35+
self.assertEqual(args.b, 'b')
36+
37+
def test_single_config_overwriting(self) -> None:
38+
class SimpleOverwritingTap(Tap):
39+
a: int
40+
b: str = 'b'
41+
42+
with NamedTemporaryFile() as f:
43+
f.write(b'--a 1 --b two')
44+
f.flush()
45+
args = SimpleOverwritingTap(config_files=[f.name]).parse_args('--a 2'.split())
46+
47+
self.assertEqual(args.a, 2)
48+
self.assertEqual(args.b, 'two')
49+
50+
def test_single_config_known_only(self) -> None:
51+
class KnownOnlyTap(Tap):
52+
a: int
53+
b: str = 'b'
54+
55+
with NamedTemporaryFile() as f:
56+
f.write(b'--a 1 --c seeNothing')
57+
f.flush()
58+
args = KnownOnlyTap(config_files=[f.name]).parse_args([], known_only=True)
59+
60+
self.assertEqual(args.a, 1)
61+
self.assertEqual(args.b, 'b')
62+
self.assertEqual(args.extra_args, ['--c', 'seeNothing'])
63+
64+
def test_single_config_required_still_required(self) -> None:
65+
class KnownOnlyTap(Tap):
66+
a: int
67+
b: str = 'b'
68+
69+
with NamedTemporaryFile() as f, self.assertRaises(SystemExit):
70+
sys.stderr = self.dev_null
71+
f.write(b'--b fore')
72+
f.flush()
73+
KnownOnlyTap(config_files=[f.name]).parse_args([])
74+
75+
def test_multiple_configs(self) -> None:
76+
class MultipleTap(Tap):
77+
a: int
78+
b: str = 'b'
79+
80+
with NamedTemporaryFile() as f1, NamedTemporaryFile() as f2:
81+
f1.write(b'--b two')
82+
f1.flush()
83+
f2.write(b'--a 1')
84+
f2.flush()
85+
args = MultipleTap(config_files=[f1.name, f2.name]).parse_args([])
86+
87+
self.assertEqual(args.a, 1)
88+
self.assertEqual(args.b, 'two')
89+
90+
def test_multiple_configs_overwriting(self) -> None:
91+
class MultipleOverwritingTap(Tap):
92+
a: int
93+
b: str = 'b'
94+
c: str = 'c'
95+
96+
with NamedTemporaryFile() as f1, NamedTemporaryFile() as f2:
97+
f1.write(b'--a 1 --b two')
98+
f1.flush()
99+
f2.write(b'--a 2 --c see')
100+
f2.flush()
101+
args = MultipleOverwritingTap(config_files=[f1.name, f2.name]).parse_args('--b four'.split())
102+
103+
self.assertEqual(args.a, 2)
104+
self.assertEqual(args.b, 'four')
105+
self.assertEqual(args.c, 'see')
106+
107+
def test_junk_config(self) -> None:
108+
class JunkConfigTap(Tap):
109+
a: int
110+
b: str = 'b'
111+
112+
with NamedTemporaryFile() as f1, self.assertRaises(SystemExit):
113+
sys.stderr = self.dev_null
114+
f1.write(b'is not a file that can reasonably be parsed')
115+
f1.flush()
116+
JunkConfigTap(config_files=[f1.name]).parse_args()
117+
118+
119+
if __name__ == '__main__':
120+
unittest.main()

0 commit comments

Comments
 (0)