Skip to content

Commit e5c4e9b

Browse files
committed
feat(config): env var override
1 parent 21cbc31 commit e5c4e9b

File tree

3 files changed

+113
-21
lines changed

3 files changed

+113
-21
lines changed

docs/USAGE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ You should see the following files and folders for an initial setup:
6767

6868
`joinmarket.cfg` is the main configuration file for Joinmarket and has a lot of settings, several of which you'll want to edit or at least examine.
6969
This will be discussed in several of the sections below.
70+
71+
> **Environment variable overrides**
72+
> Configuration values can be overridden using environment variables prefixed with `JM_`. Format: `JM_SECTION_KEY` (e.g., `JM_POLICY_TX_FEES`) or `JM_SECTION_SUBSECTION_KEY` for sections with subsections like `MESSAGING` (e.g., `JM_MESSAGING_ONION_TYPE`). When environment variables are present, they take precedence over the config file.
73+
7074
The `wallets/` directory is where wallet files, extension (by default) of `.jmdat` are stored after you create them. They are encrypted and store important information; without them, it is possible to recover your coins with the seedphrase, but can be a hassle, so keep the file safe.
7175
The `logs/` directory contains a log file for each bot you run (Maker or Taker), with debug information. You'll rarely need to read these files unless you encounter a problem; deleting them regularly is recommended (and never dangerous). However there are other log files kept here, in particular one called `yigen-statement.csv` which records all transactions your Maker bot does over time. This can be useful for keeping track. Additionally, tumbles have a `TUMBLE.schedule` and `TUMBLE.log` file here which can be very useful; don't delete these.
7276
The `cmtdata/` directory stores technical information that you will not need to read.

src/jmclient/configure.py

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import atexit
2-
import io
32
import logging
43
import os
54
import re
@@ -91,8 +90,10 @@ def jm_single() -> AttributeDict:
9190
'POLICY': ['absurd_fee_per_kb', 'taker_utxo_retries',
9291
'taker_utxo_age', 'taker_utxo_amtpercent']}
9392

94-
_DEFAULT_INTEREST_RATE = "0.015"
93+
_ENV_VAR_PREFIX = "JM_"
94+
_SECTIONS_WITH_SUBSECTIONS = {"MESSAGING"}
9595

96+
_DEFAULT_INTEREST_RATE = "0.015"
9697
_DEFAULT_BONDLESS_MAKERS_ALLOWANCE = "0.125"
9798

9899
defaultconfig = \
@@ -673,9 +674,28 @@ def _remove_unwanted_default_settings(config: ConfigParser) -> None:
673674
if section.startswith('MESSAGING:'):
674675
config.remove_section(section)
675676

676-
def load_program_config(config_path: str = "", bs: Optional[str] = None,
677-
plugin_services: List[JMPluginService] = []) -> None:
678-
global_singleton.config.read_file(io.StringIO(defaultconfig))
677+
678+
def override_from_environment(config: ConfigParser) -> ConfigParser:
679+
for key, value in os.environ.items():
680+
if key.startswith(_ENV_VAR_PREFIX):
681+
key = key.removeprefix(_ENV_VAR_PREFIX)
682+
section, key = key.split("_", 1)
683+
if section in _SECTIONS_WITH_SUBSECTIONS:
684+
sub, key = key.split("_", 1)
685+
section = f"{section}:{sub.lower()}"
686+
key = key.lower()
687+
if not config.has_section(section):
688+
config.add_section(section)
689+
log.info(f"Overriding [{section}] {key}={value}")
690+
config.set(section, key, value)
691+
return config
692+
693+
694+
def _should_override_from_environment() -> bool:
695+
return any(key.startswith(_ENV_VAR_PREFIX) for key in os.environ.keys())
696+
697+
698+
def set_paths(config_path: str = "") -> None:
679699
if not config_path:
680700
config_path = lookup_appdata_folder(global_singleton.APPNAME)
681701
# we set the global home directory, but keep the config_path variable
@@ -692,29 +712,50 @@ def load_program_config(config_path: str = "", bs: Optional[str] = None,
692712
if not os.path.exists(os.path.join(global_singleton.datadir, "cmtdata")):
693713
os.makedirs(os.path.join(global_singleton.datadir, "cmtdata"))
694714
global_singleton.config_location = os.path.join(
695-
global_singleton.datadir, global_singleton.config_location)
715+
global_singleton.datadir, global_singleton.config_location
716+
)
717+
696718

697-
_remove_unwanted_default_settings(global_singleton.config)
719+
def read_config_file() -> bool:
698720
try:
699-
loadedFiles = global_singleton.config.read(
700-
[global_singleton.config_location])
721+
loaded = global_singleton.config.read([global_singleton.config_location])
701722
except UnicodeDecodeError:
702-
jmprint("Error loading `joinmarket.cfg`, invalid file format.",
703-
"info")
723+
jmprint("Error loading `joinmarket.cfg`, invalid file format.", "info")
704724
sys.exit(EXIT_FAILURE)
725+
return len(loaded) == 1
726+
727+
728+
def write_config_file(config: str = defaultconfig) -> bool:
729+
with open(global_singleton.config_location, "w") as configfile:
730+
configfile.write(config)
731+
jmprint(
732+
"Created a new `joinmarket.cfg`. Please review and adopt the "
733+
"settings and restart joinmarket.",
734+
"info",
735+
)
736+
sys.exit(EXIT_FAILURE)
737+
738+
739+
def load_program_config(
740+
config_path: str = "",
741+
bs: Optional[str] = None,
742+
plugin_services: List[JMPluginService] = [],
743+
) -> None:
744+
set_paths(config_path)
745+
global_singleton.config.read_string(defaultconfig)
746+
if _should_override_from_environment():
747+
override_from_environment(global_singleton.config)
748+
else:
749+
_remove_unwanted_default_settings(global_singleton.config)
750+
# Create default config file if not found
751+
if not read_config_file():
752+
write_config_file()
705753

706754
# Hack required for bitcoin-rpc-no-history and probably others
707755
# (historicaly electrum); must be able to enforce a different blockchain
708756
# interface even in default/new load.
709757
if bs:
710758
global_singleton.config.set("BLOCKCHAIN", "blockchain_source", bs)
711-
# Create default config file if not found
712-
if len(loadedFiles) != 1:
713-
with open(global_singleton.config_location, "w") as configfile:
714-
configfile.write(defaultconfig)
715-
jmprint("Created a new `joinmarket.cfg`. Please review and adopt the "
716-
"settings and restart joinmarket.", "info")
717-
sys.exit(EXIT_FAILURE)
718759

719760
loglevel = global_singleton.config.get("LOGGING", "console_log_level")
720761
try:

test/jmclient/test_configure.py

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
'''test configure module.'''
22

3+
from configparser import ConfigParser
4+
from unittest.mock import Mock
5+
36
import pytest
4-
from jmclient import load_test_config, jm_single
5-
from jmclient.configure import get_blockchain_interface_instance
7+
8+
import jmclient.configure as configure
9+
from jmclient import jm_single, load_test_config
10+
from jmclient.configure import (
11+
get_blockchain_interface_instance,
12+
override_from_environment,
13+
)
614

715
pytestmark = pytest.mark.usefixtures("setup_regtest_bitcoind")
816

917

1018
def test_attribute_dict():
1119
from jmclient.configure import AttributeDict
12-
ad = AttributeDict(foo=1, bar=2, baz={"x":3, "y":4})
20+
21+
ad = AttributeDict(foo=1, bar=2, baz={"x": 3, "y": 4})
1322
assert ad.foo == 1
1423
assert ad.bar == 2
1524
assert ad.baz.x == 3
@@ -35,3 +44,41 @@ def test_blockchain_sources():
3544
else:
3645
get_blockchain_interface_instance(jm_single().config)
3746
load_test_config()
47+
48+
49+
@pytest.fixture
50+
def overrides(monkeypatch):
51+
overrides = {
52+
"JM_BLOCKCHAIN_BLOCKCHAIN_SOURCE": "no-blockchain",
53+
"JM_POLICY_TX_FEES": "12345678",
54+
"JM_MESSAGING_ONION_TYPE": "lorem-ipsum",
55+
}
56+
for key, value in overrides.items():
57+
monkeypatch.setenv(key, value)
58+
return overrides
59+
60+
61+
def test_override_from_environment(overrides):
62+
config = ConfigParser()
63+
override_from_environment(config)
64+
assert (
65+
config.get("BLOCKCHAIN", "blockchain_source")
66+
== overrides["JM_BLOCKCHAIN_BLOCKCHAIN_SOURCE"]
67+
)
68+
assert config.get("POLICY", "tx_fees") == overrides["JM_POLICY_TX_FEES"]
69+
assert config.get("MESSAGING:onion", "type") == overrides["JM_MESSAGING_ONION_TYPE"]
70+
71+
72+
def test_load_program_config_overrides(overrides):
73+
load_test_config()
74+
assert jm_single().config.get("POLICY", "tx_fees") == overrides["JM_POLICY_TX_FEES"]
75+
76+
77+
def test_load_program_config_no_overrides(monkeypatch):
78+
mocked_override_from_environment = Mock(override_from_environment)
79+
monkeypatch.setattr(
80+
configure, "override_from_environment", mocked_override_from_environment
81+
)
82+
assert configure._should_override_from_environment() is False
83+
load_test_config()
84+
mocked_override_from_environment.assert_not_called()

0 commit comments

Comments
 (0)