Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/source/dev_documentation/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@
- Chain simulator block production is now gated by the `AUTO_GENERATE_BLOCKS` flag: transaction waits, `WaitStep` (`for_blocks`) and `ChainSimulatorSetTokenBalanceStep` only drive blocks when it is enabled (the default). With it disabled, MxOps waits for the auto-producing simulator instead, polling at a clamped rate
- Chain simulator default `API_RATE_LIMIT` increased to 100 (from 2) and `STORAGE_ITERATION_BATCH_SIZE` to 5000 (from 1000) for faster localhost operations
- `AccountCloneStep` internal methods extracted into reusable module-level functions
- A custom config file is now merged on top of the packaged defaults instead of fully replacing them: it only needs to specify the values it wants to override, and any option or section it omits is inherited from the defaults

### Fixed

- Chain simulator explorer and lite-wallet containers failing on restart due to non-idempotent nginx config in upstream images (added `--force-recreate` to `docker compose up`)
- Custom config file specified through the `MXOPS_CONFIG` environment variable was unusable (the path was returned as a `str`, raising an `AttributeError` when loaded)

## 3.1.0 - 2026-02-11

Expand Down
29 changes: 23 additions & 6 deletions mxops/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,39 @@ class _Config:

def __init__(self, network: NetworkEnum, config_path: Path | None = None):
"""
Initialise the configuration instance by reading the specified config file.
Initialise the configuration instance.

The packaged default config is always loaded first and, when provided, the
custom config file is layered on top: values it defines override the defaults
per-option while every option and section it does not mention is inherited
from the defaults. As a consequence, a misspelled section or option in the
custom config is silently ignored (the default value is served instead)
rather than raising an error.

:param network: which network is to be considered when reading the config values
:type network: NetworkEnum
:param config_path: path to the config file
:param config_path: path to a custom config file to merge over the defaults
:type config_path: Path
"""
self.__network = network
self.__config = ConfigParser()

# always load the packaged defaults first so that a custom config only
# needs to specify the values it wants to override
default_config = files("mxops.resources").joinpath("default_config.ini")
self.__config.read_string(default_config.read_text(encoding="utf-8"))

if config_path is not None:
with open(config_path.as_posix(), "r", encoding="utf-8") as config_file:
self.__config.read_file(config_file)
else:
default_config = files("mxops.resources").joinpath("default_config.ini")
self.__config.read_string(default_config.read_text())
# local import to avoid a circular import at module load time
# pylint: disable=import-outside-toplevel
from mxops.enums import LogGroupEnum
from mxops.utils.logger import get_logger

get_logger(LogGroupEnum.CONFIG).debug(
"Loaded custom config from %s on top of the defaults", config_path
)

self.__network_config: NetworkConfig | None = None

Expand Down Expand Up @@ -162,7 +179,7 @@ def find_config_path() -> Path | None:

if path is not None:
if os.path.exists(path):
return path
return Path(path)
raise ValueError("MXOPS_CONFIG env var does not direct to an existing path")

# then check if a config file is present in the working directory
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "mxops"
version = "3.2.0-dev4"
version = "3.2.0-dev5"
authors = [
{name="Etienne Wallet"},
]
Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 3.2.0-dev4
current_version = 3.2.0-dev5
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(-(?P<release>[^-0-9]+)(?P<build>\d+))?
serialize =
{major}.{minor}.{patch}-{release}{build}
Expand Down
2 changes: 2 additions & 0 deletions tests/data/configs/partial_config.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[DEV]
PROXY=https://my-custom-devnet-gateway.example.com
104 changes: 104 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""
author: Etienne Wallet

Tests for the config loading, in particular the merge of a custom config file
on top of the packaged defaults.
"""

import os
from pathlib import Path
from unittest import mock

import pytest

from mxops.config.config import Config, _Config
from mxops.enums import NetworkEnum

PARTIAL_CONFIG_PATH = Path(__file__).parent / "data" / "configs" / "partial_config.ini"


def test_custom_config_overrides_default_option():
"""
A value defined in the custom config overrides the packaged default
"""
config = _Config(NetworkEnum.DEV, PARTIAL_CONFIG_PATH)
assert config.get("PROXY") == "https://my-custom-devnet-gateway.example.com"


def test_custom_config_inherits_untouched_option():
"""
An option not specified in the custom config falls back to the default
of the same section
"""
config = _Config(NetworkEnum.DEV, PARTIAL_CONFIG_PATH)
assert config.get("API") == "https://devnet-api.multiversx.com"


def test_custom_config_keeps_untouched_section():
"""
A section absent from the custom config is still served from the defaults
(regression: this previously raised NoSectionError)
"""
config = _Config(NetworkEnum.DEV, PARTIAL_CONFIG_PATH)
assert config.get("PROXY", network=NetworkEnum.MAIN) == (
"https://gateway.multiversx.com"
)


def test_default_config_without_custom_path():
"""
Without a custom config path, the packaged defaults are resolved
"""
config = _Config(NetworkEnum.LOCAL, None)
assert config.get("PROXY") == "http://localhost:7950"


def test_custom_config_inherits_default_section_option():
"""
An option defined in the [DEFAULT] section of the packaged config is still
inherited after merging a custom config
"""
config = _Config(NetworkEnum.DEV, PARTIAL_CONFIG_PATH)
assert config.get("TX_TIMEOUT") == "100"


def test_custom_config_can_override_default_section_option(tmp_path):
"""
A custom config can override an option that lives in the [DEFAULT] section
"""
cfg = tmp_path / "custom_config.ini"
cfg.write_text("[DEFAULT]\nTX_TIMEOUT=42\n")
config = _Config(NetworkEnum.DEV, cfg)
assert config.get("TX_TIMEOUT") == "42"


def test_custom_config_can_add_new_option(tmp_path):
"""
A custom config can add an option that does not exist in the defaults
"""
cfg = tmp_path / "custom_config.ini"
cfg.write_text("[DEV]\nCUSTOM_KEY=hello\n")
config = _Config(NetworkEnum.DEV, cfg)
assert config.get("CUSTOM_KEY") == "hello"


def test_find_config_path_from_env_returns_path(tmp_path):
"""
When MXOPS_CONFIG points to an existing file, find_config_path returns it as
a Path (regression: it previously returned a str, breaking _Config)
"""
cfg = tmp_path / "mxops_config.ini"
cfg.write_text("[DEV]\nPROXY=http://envtest:9999\n")
with mock.patch.dict(os.environ, {"MXOPS_CONFIG": str(cfg)}):
result = Config.find_config_path()
assert isinstance(result, Path)
assert result == Path(str(cfg))


def test_find_config_path_from_env_missing_raises():
"""
When MXOPS_CONFIG points to a missing file, find_config_path raises
"""
with mock.patch.dict(os.environ, {"MXOPS_CONFIG": "/no/such/file.ini"}):
with pytest.raises(ValueError):
Config.find_config_path()