Skip to content

Commit 4f45ddd

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

File tree

3 files changed

+102
-20
lines changed

3 files changed

+102
-20
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: 61 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,29 @@ 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(config: ConfigParser | None) -> ConfigParser | None:
679+
if not any(key.startswith(_ENV_VAR_PREFIX) for key in os.environ.keys()):
680+
return config
681+
if not config:
682+
config = ConfigParser(strict=False)
683+
config.read_string(defaultconfig)
684+
for key, value in os.environ.items():
685+
if key.startswith(_ENV_VAR_PREFIX):
686+
key = key.removeprefix(_ENV_VAR_PREFIX)
687+
section, key = key.split("_", 1)
688+
if section in _SECTIONS_WITH_SUBSECTIONS:
689+
sub, key = key.split("_", 1)
690+
section = f"{section}:{sub.lower()}"
691+
key = key.lower()
692+
if not config.has_section(section):
693+
config.add_section(section)
694+
log.info(f"Overriding [{section}] {key}={value}")
695+
config.set(section, key, value)
696+
return config
697+
698+
699+
def _set_paths(config_path: str = "") -> None:
679700
if not config_path:
680701
config_path = lookup_appdata_folder(global_singleton.APPNAME)
681702
# we set the global home directory, but keep the config_path variable
@@ -692,29 +713,51 @@ def load_program_config(config_path: str = "", bs: Optional[str] = None,
692713
if not os.path.exists(os.path.join(global_singleton.datadir, "cmtdata")):
693714
os.makedirs(os.path.join(global_singleton.datadir, "cmtdata"))
694715
global_singleton.config_location = os.path.join(
695-
global_singleton.datadir, global_singleton.config_location)
716+
global_singleton.datadir, global_singleton.config_location
717+
)
696718

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

706756
# Hack required for bitcoin-rpc-no-history and probably others
707757
# (historicaly electrum); must be able to enforce a different blockchain
708758
# interface even in default/new load.
709759
if bs:
710760
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)
718761

719762
loglevel = global_singleton.config.get("LOGGING", "console_log_level")
720763
try:

test/jmclient/test_configure.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
'''test configure module.'''
22

3+
import copy
4+
from configparser import ConfigParser
5+
36
import pytest
4-
from jmclient import load_test_config, jm_single
5-
from jmclient.configure import get_blockchain_interface_instance
7+
8+
from jmclient import jm_single, load_test_config
9+
from jmclient.configure import get_blockchain_interface_instance, override
610

711
pytestmark = pytest.mark.usefixtures("setup_regtest_bitcoind")
812

@@ -23,6 +27,8 @@ def test_load_config(tmpdir):
2327
load_test_config(config_path=str(tmpdir), bs="regtest")
2428
jm_single().config_location = "joinmarket.cfg"
2529
load_test_config()
30+
ref = copy.deepcopy(jm_single().config)
31+
assert override(jm_single().config) == ref
2632

2733

2834
def test_blockchain_sources():
@@ -35,3 +41,32 @@ def test_blockchain_sources():
3541
else:
3642
get_blockchain_interface_instance(jm_single().config)
3743
load_test_config()
44+
45+
46+
@pytest.fixture
47+
def overrides(monkeypatch):
48+
overrides = {
49+
"JM_BLOCKCHAIN_BLOCKCHAIN_SOURCE": "no-blockchain",
50+
"JM_POLICY_TX_FEES": "12345678",
51+
"JM_MESSAGING_ONION_TYPE": "lorem-ipsum",
52+
}
53+
for key, value in overrides.items():
54+
monkeypatch.setenv(key, value)
55+
return overrides
56+
57+
58+
def test_override(overrides):
59+
config = ConfigParser()
60+
override(config)
61+
assert (
62+
config.get("BLOCKCHAIN", "blockchain_source")
63+
== overrides["JM_BLOCKCHAIN_BLOCKCHAIN_SOURCE"]
64+
)
65+
assert config.get("POLICY", "tx_fees") == overrides["JM_POLICY_TX_FEES"]
66+
assert config.get("MESSAGING:onion", "type") == overrides["JM_MESSAGING_ONION_TYPE"]
67+
68+
69+
def test_load_program_config_overrides(overrides):
70+
load_test_config()
71+
assert jm_single().config.get("POLICY", "tx_fees") == overrides["JM_POLICY_TX_FEES"]
72+
assert jm_single().config.get("MESSAGING:onion", "socks5_port") == "9050"

0 commit comments

Comments
 (0)