Skip to content

Commit b30659c

Browse files
committed
Refactor config validation and introduce Config model
Extract validation logic from schema.py into dedicated validation.py module. The new Config class provides structured access to configuration with typed properties (main, service_configs, services) instead of returning a raw dict. Key changes: - Normalize config structure with flavors/services during parsing - Introduce get_service_instances() to streamline service instantiation - Improve error formatting for validation errors
1 parent 5be9c9a commit b30659c

24 files changed

Lines changed: 646 additions & 456 deletions

bugwarrior/collect.py

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
import copy
2+
from functools import cache
23
from importlib.metadata import entry_points
34
import logging
45
import multiprocessing
56
import time
7+
from typing import TYPE_CHECKING
68

79
from jinja2 import Template
810
from taskw.task import Task
911

12+
if TYPE_CHECKING:
13+
from bugwarrior.config.validation import Config
14+
from bugwarrior.services import Service
15+
1016
log = logging.getLogger(__name__)
1117

1218
# Sentinels for process completion status
1319
SERVICE_FINISHED_OK = 0
1420
SERVICE_FINISHED_ERROR = 1
1521

1622

17-
def get_service(service_name: str):
23+
@cache
24+
def get_service(service_name: str) -> type["Service"]:
1825
try:
1926
(service,) = entry_points(group='bugwarrior.service', name=service_name)
2027
except ValueError as e:
@@ -33,16 +40,22 @@ def get_service(service_name: str):
3340
return service.load()
3441

3542

36-
def _aggregate_issues(conf, main_section, target, queue):
43+
def get_service_instances(conf: "Config") -> list["Service"]:
44+
return [
45+
get_service(service_config.service)(service_config, conf.main)
46+
for service_config in conf.service_configs
47+
]
48+
49+
50+
def _aggregate_issues(service: "Service", queue: multiprocessing.Queue):
3751
"""This worker function is separated out from the main
3852
:func:`aggregate_issues` func only so that we can use multiprocessing
3953
on it for speed reasons.
4054
"""
4155

4256
start = time.time()
43-
57+
target = service.config.target
4458
try:
45-
service = get_service(conf[target].service)(conf[target], conf[main_section])
4659
issue_count = 0
4760
for issue in service.issues():
4861
queue.put(issue)
@@ -67,24 +80,23 @@ def _aggregate_issues(conf, main_section, target, queue):
6780
log.info(f"Done with [{target}] in {duration}.")
6881

6982

70-
def aggregate_issues(conf, main_section, debug):
83+
def aggregate_issues(conf: "Config", debug: bool):
7184
"""Return all issues from every target."""
7285
log.info("Starting to aggregate remote issues.")
7386

74-
# Create and call service objects for every target in the config
75-
targets = conf[main_section].targets
76-
7787
queue = multiprocessing.Queue()
7888

79-
log.info("Spawning %i workers." % len(targets))
89+
services = get_service_instances(conf)
90+
91+
log.info("Spawning %i workers." % len(services))
8092

8193
if debug:
82-
for target in targets:
83-
_aggregate_issues(conf, main_section, target, queue)
94+
for service in services:
95+
_aggregate_issues(service, queue)
8496
else:
85-
for target in targets:
97+
for service in services:
8698
proc = multiprocessing.Process(
87-
target=_aggregate_issues, args=(conf, main_section, target, queue)
99+
target=_aggregate_issues, args=(service, queue)
88100
)
89101
proc.start()
90102

@@ -94,7 +106,7 @@ def aggregate_issues(conf, main_section, debug):
94106
# and tell some of our workers some incomplete things.
95107
time.sleep(1)
96108

97-
currently_running = len(targets)
109+
currently_running = len(services)
98110
while currently_running > 0:
99111
issue = queue.get(True)
100112
try:

bugwarrior/command.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging
44
import os
55
import sys
6+
from typing import TYPE_CHECKING
67

78
import click
89
from lockfile import LockTimeout
@@ -12,6 +13,8 @@
1213
from bugwarrior.config import get_config_path, get_keyring, load_config
1314
from bugwarrior.db import get_defined_udas_as_strings, synchronize
1415

16+
if TYPE_CHECKING:
17+
from bugwarrior.config.validation import Config
1518
log = logging.getLogger(__name__)
1619

1720

@@ -25,7 +28,9 @@ def _get_section_name(flavor):
2528
return 'general'
2629

2730

28-
def _try_load_config(main_section, interactive=False, quiet=False):
31+
def _try_load_config(
32+
main_section: str, interactive: bool = False, quiet: bool = False
33+
) -> "Config":
2934
try:
3035
return load_config(main_section, interactive, quiet)
3136
except OSError:
@@ -99,17 +104,15 @@ def pull(dry_run, flavor, interactive, debug, quiet):
99104
main_section = _get_section_name(flavor)
100105
config = _try_load_config(main_section, interactive, quiet)
101106

102-
lockfile_path = os.path.join(
103-
config[main_section].data.path, 'bugwarrior.lockfile'
104-
)
107+
lockfile_path = os.path.join(config.main.data.path, 'bugwarrior.lockfile')
105108
lockfile = PIDLockFile(lockfile_path)
106109
lockfile.acquire(timeout=10)
107110
try:
108111
# Get all the issues. This can take a while.
109-
issue_generator = aggregate_issues(config, main_section, debug)
112+
issue_generator = aggregate_issues(config, debug)
110113

111114
# Stuff them in the taskwarrior db as necessary
112-
synchronize(issue_generator, config, main_section, dry_run)
115+
synchronize(issue_generator, config, dry_run)
113116
finally:
114117
lockfile.release()
115118
except LockTimeout:
@@ -138,11 +141,12 @@ def vault():
138141

139142
def targets():
140143
config = _try_load_config('general')
141-
for target in config['general'].targets:
142-
service_class = get_service(config[target].service)
143-
for value in [v for v in dict(config[target]).values() if isinstance(v, str)]:
144-
if '@oracle:use_keyring' in value:
145-
yield service_class.get_keyring_service(config[target])
144+
for service_config in config.service_configs:
145+
for value in dict(service_config).values():
146+
if isinstance(value, str) and '@oracle:use_keyring' in value:
147+
yield get_service(service_config.service).get_keyring_service(
148+
service_config
149+
)
146150

147151

148152
@vault.command()
@@ -214,7 +218,7 @@ def uda(flavor):
214218
main_section = _get_section_name(flavor)
215219
conf = _try_load_config(main_section)
216220
print("# Bugwarrior UDAs")
217-
for uda in get_defined_udas_as_strings(conf, main_section):
221+
for uda in get_defined_udas_as_strings(conf):
218222
print(uda)
219223
print("# END Bugwarrior UDAs")
220224

bugwarrior/config/load.py

Lines changed: 77 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
import codecs
21
import configparser
32
import logging
43
import os
4+
from pathlib import Path
5+
from typing import Any
56

67
try:
78
import tomllib # python>=3.11
89
except ImportError:
910
import tomli as tomllib # backport
1011

11-
from . import schema
12+
from bugwarrior.config.validation import Config, validate_config
1213

1314
# The name of the environment variable that can be used to ovewrite the path
1415
# to the bugwarriorrc file
@@ -52,58 +53,87 @@ def get_config_path():
5253
return paths[0]
5354

5455

56+
def format_config(config: dict) -> dict[str, Any]:
57+
# Build flavors from 'flavor' table
58+
flavors = {
59+
name: flavor_config for name, flavor_config in config.pop("flavor", {}).items()
60+
}
61+
62+
# Handle 'general' as top-level key (TOML format, tests)
63+
if "general" in config:
64+
flavors["general"] = config.pop("general")
65+
66+
services = {
67+
section: {**config.pop(section), "target": section}
68+
for section in list(config)
69+
if section not in {"hooks", "notifications"}
70+
}
71+
72+
return {
73+
"flavors": flavors,
74+
"services": services,
75+
**config, # remaining: "hooks" and "notifications" (if present)
76+
}
77+
78+
79+
def parse_toml_file(configpath: str) -> dict:
80+
with open(configpath, 'rb') as file:
81+
return tomllib.load(file)
82+
83+
84+
def parse_ini_file(configpath: str) -> dict:
85+
rawconfig = BugwarriorConfigParser()
86+
with open(configpath, encoding="utf-8") as buff:
87+
rawconfig.read_file(buff)
88+
89+
config = {"flavor": {}}
90+
for section in rawconfig.sections():
91+
if section in ['hooks', 'notifications']:
92+
config[section] = dict(rawconfig[section])
93+
elif section == 'general' or section.startswith('flavor.'):
94+
name = section.removeprefix('flavor.')
95+
config["flavor"][name] = {
96+
key.replace('.', '_'): value
97+
for key, value in rawconfig[section].items()
98+
}
99+
100+
# All other sections are assumed to be services
101+
else:
102+
service = rawconfig[section].pop('service')
103+
service_prefix = 'ado' if service == 'azuredevops' else service
104+
config[section] = {'service': service}
105+
for key, value in rawconfig[section].items():
106+
try:
107+
prefix, unprefixed_key = key.split('.')
108+
except ValueError: # missing prefix
109+
prefix = None
110+
unprefixed_key = key
111+
if prefix != service_prefix:
112+
raise SystemExit(
113+
f"[{section}]\n{key} <-expected prefix "
114+
f"'{service_prefix}': did you mean "
115+
f"'{service_prefix}.{unprefixed_key}'?"
116+
)
117+
config[section][unprefixed_key] = value
118+
119+
return config
120+
121+
55122
def parse_file(configpath: str) -> dict:
56-
if os.path.splitext(configpath)[-1] == '.toml':
57-
with open(configpath, 'rb') as f:
58-
config = tomllib.load(f)
59-
# Flatten flavors into top-level sections (if they're unquoted).
60-
for k, v in config.get('flavor', {}).items():
61-
config[f'flavor.{k}'] = v
62-
config.pop('flavor', None)
123+
if Path(configpath).suffix == '.toml':
124+
config = parse_toml_file(configpath)
63125
else:
64-
rawconfig = BugwarriorConfigParser()
65-
with codecs.open(configpath, "r", "utf-8") as buff:
66-
rawconfig.read_file(buff)
67-
config = {}
68-
for section in rawconfig.sections():
69-
if section in ['hooks', 'notifications']:
70-
config[section] = dict(rawconfig[section])
71-
elif section == 'general':
72-
config[section] = {
73-
k.replace('log.', 'log_'): v for k, v in rawconfig[section].items()
74-
}
75-
elif section.startswith('flavor.'):
76-
config[section] = {
77-
k.replace('.', '_'): v for k, v in rawconfig[section].items()
78-
}
79-
else:
80-
service = rawconfig[section].pop('service')
81-
service_prefix = 'ado' if service == 'azuredevops' else service
82-
config[section] = {'service': service}
83-
for k, v in rawconfig[section].items():
84-
try:
85-
prefix, key = k.split('.')
86-
except ValueError: # missing prefix
87-
prefix = None
88-
key = k
89-
if prefix != service_prefix:
90-
raise SystemExit(
91-
f"[{section}]\n{k} <-expected prefix "
92-
f"'{service_prefix}': did you mean "
93-
f"'{service_prefix}.{key}'?"
94-
)
95-
config[section][key] = v
96-
return config
126+
config = parse_ini_file(configpath)
127+
return format_config(config)
97128

98129

99-
def load_config(main_section, interactive, quiet) -> dict:
130+
def load_config(main_section, interactive, quiet) -> Config:
100131
configpath = get_config_path()
101132
rawconfig = parse_file(configpath)
102-
rawconfig[main_section]['interactive'] = interactive
103-
config = schema.validate_config(rawconfig, main_section, configpath)
133+
rawconfig['flavors'][main_section]['interactive'] = interactive
134+
config = validate_config(rawconfig, main_section, configpath)
104135
configure_logging(
105-
config[main_section].log_file,
106-
'WARNING' if quiet else config[main_section].log_level,
136+
config.main.log_file, 'WARNING' if quiet else config.main.log_level
107137
)
108138
return config
109139

0 commit comments

Comments
 (0)