diff --git a/hathor/builder/builder.py b/hathor/builder/builder.py index 5c018b163..c3daad189 100644 --- a/hathor/builder/builder.py +++ b/hathor/builder/builder.py @@ -24,6 +24,7 @@ from hathor.event import EventManager from hathor.event.storage import EventMemoryStorage, EventRocksDBStorage, EventStorage from hathor.event.websocket import EventWebsocketFactory +from hathor.feature_activation.feature_service import FeatureService from hathor.indexes import IndexesManager, MemoryIndexesManager, RocksDBIndexesManager from hathor.manager import HathorManager from hathor.p2p.manager import ConnectionsManager @@ -63,6 +64,7 @@ class BuildArtifacts(NamedTuple): wallet: Optional[BaseWallet] rocksdb_storage: Optional[RocksDBStorage] stratum_factory: Optional[StratumFactory] + feature_service: FeatureService class Builder: @@ -189,6 +191,8 @@ def build(self) -> BuildArtifacts: if self._enable_stratum_server: stratum_factory = self._create_stratum_server(manager) + feature_service = self._create_feature_service() + self.artifacts = BuildArtifacts( peer_id=peer_id, settings=settings, @@ -203,6 +207,7 @@ def build(self) -> BuildArtifacts: wallet=wallet, rocksdb_storage=self._rocksdb_storage, stratum_factory=stratum_factory, + feature_service=feature_service ) return self.artifacts @@ -268,6 +273,9 @@ def _create_stratum_server(self, manager: HathorManager) -> StratumFactory: manager.metrics.stratum_factory = stratum_factory return stratum_factory + def _create_feature_service(self) -> FeatureService: + return FeatureService(feature_settings=self._settings.FEATURE_ACTIVATION) + def _get_or_create_rocksdb_storage(self) -> RocksDBStorage: assert self._rocksdb_path is not None diff --git a/hathor/builder/resources_builder.py b/hathor/builder/resources_builder.py index d5e04e382..8822a2ac9 100644 --- a/hathor/builder/resources_builder.py +++ b/hathor/builder/resources_builder.py @@ -23,6 +23,7 @@ from hathor.event.resources.event import EventResource from hathor.exception import BuilderError +from hathor.feature_activation.feature_service import FeatureService from hathor.prometheus import PrometheusMetricsExporter if TYPE_CHECKING: @@ -33,7 +34,12 @@ class ResourcesBuilder: - def __init__(self, manager: 'HathorManager', event_ws_factory: Optional['EventWebsocketFactory']) -> None: + def __init__( + self, + manager: 'HathorManager', + event_ws_factory: Optional['EventWebsocketFactory'], + feature_service: FeatureService + ) -> None: self.log = logger.new() self.manager = manager self.event_ws_factory = event_ws_factory @@ -42,6 +48,8 @@ def __init__(self, manager: 'HathorManager', event_ws_factory: Optional['EventWe self._built_status = False self._built_prometheus = False + self._feature_service = feature_service + def build(self, args: Namespace) -> Optional[server.Site]: if args.prometheus: self.create_prometheus(args) @@ -76,6 +84,7 @@ def create_resources(self, args: Namespace) -> server.Site: DebugRaiseResource, DebugRejectResource, ) + from hathor.feature_activation.resources.feature import FeatureResource from hathor.mining.ws import MiningWebsocketFactory from hathor.p2p.resources import ( AddPeersResource, @@ -189,6 +198,16 @@ def create_resources(self, args: Namespace) -> server.Site: (b'peers', AddPeersResource(self.manager), p2p_resource), (b'netfilter', NetfilterRuleResource(self.manager), p2p_resource), (b'readiness', HealthcheckReadinessResource(self.manager), p2p_resource), + # Feature Activation + ( + b'feature', + FeatureResource( + feature_settings=settings.FEATURE_ACTIVATION, + feature_service=self._feature_service, + tx_storage=self.manager.tx_storage + ), + root + ) ] # XXX: only enable UTXO search API if the index is enabled if args.utxo_index: diff --git a/hathor/cli/run_node.py b/hathor/cli/run_node.py index 30a3567e6..ec9dd6136 100644 --- a/hathor/cli/run_node.py +++ b/hathor/cli/run_node.py @@ -22,6 +22,7 @@ from hathor.conf import TESTNET_SETTINGS_FILEPATH, HathorSettings from hathor.exception import PreInitializationError +from hathor.feature_activation.feature_service import FeatureService logger = get_logger() # LOGGING_CAPTURE_STDOUT = True @@ -147,15 +148,17 @@ def prepare(self, args: Namespace, *, register_resources: bool = True) -> None: if args.stratum: self.reactor.listenTCP(args.stratum, self.manager.stratum_factory) + from hathor.conf import HathorSettings + settings = HathorSettings() + + feature_service = FeatureService(feature_settings=settings.FEATURE_ACTIVATION) + if register_resources: - resources_builder = ResourcesBuilder(self.manager, builder.event_ws_factory) + resources_builder = ResourcesBuilder(self.manager, builder.event_ws_factory, feature_service) status_server = resources_builder.build(args) if args.status: self.reactor.listenTCP(args.status, status_server) - from hathor.conf import HathorSettings - settings = HathorSettings() - from hathor.builder.builder import BuildArtifacts self.artifacts = BuildArtifacts( peer_id=self.manager.my_peer, @@ -171,6 +174,7 @@ def prepare(self, args: Namespace, *, register_resources: bool = True) -> None: wallet=self.manager.wallet, rocksdb_storage=getattr(builder, 'rocksdb_storage', None), stratum_factory=self.manager.stratum_factory, + feature_service=feature_service ) def start_sentry_if_possible(self, args: Namespace) -> None: diff --git a/hathor/feature_activation/feature_service.py b/hathor/feature_activation/feature_service.py index c70c206ed..acce373d5 100644 --- a/hathor/feature_activation/feature_service.py +++ b/hathor/feature_activation/feature_service.py @@ -16,15 +16,15 @@ from hathor.feature_activation.model.criteria import Criteria from hathor.feature_activation.model.feature_description import FeatureDescription from hathor.feature_activation.model.feature_state import FeatureState -from hathor.feature_activation.settings import Settings +from hathor.feature_activation.settings import Settings as FeatureSettings from hathor.transaction import Block class FeatureService: - __slots__ = ('_settings',) + __slots__ = ('_feature_settings',) - def __init__(self, *, settings: Settings) -> None: - self._settings = settings + def __init__(self, *, feature_settings: FeatureSettings) -> None: + self._feature_settings = feature_settings def is_feature_active(self, *, block: Block, feature: Feature) -> bool: """Returns whether a Feature is active at a certain block.""" @@ -42,8 +42,8 @@ def get_state(self, *, block: Block, feature: Feature) -> FeatureState: # All blocks within the same evaluation interval have the same state, that is, the state is only defined for # the block in each interval boundary. Therefore, we get the state of the previous boundary. height = block.get_height() - offset_to_boundary = height % self._settings.evaluation_interval - offset_to_previous_boundary = offset_to_boundary or self._settings.evaluation_interval + offset_to_boundary = height % self._feature_settings.evaluation_interval + offset_to_previous_boundary = offset_to_boundary or self._feature_settings.evaluation_interval previous_boundary_height = height - offset_to_previous_boundary previous_boundary_block = _get_ancestor_at_height(block=block, height=previous_boundary_height) previous_state = self.get_state(block=previous_boundary_block, feature=feature) @@ -69,7 +69,9 @@ def _calculate_new_state( criteria = self._get_criteria(feature=feature) assert not boundary_block.is_genesis, 'cannot calculate new state for genesis' - assert height % self._settings.evaluation_interval == 0, 'cannot calculate new state for a non-boundary block' + assert height % self._feature_settings.evaluation_interval == 0, ( + 'cannot calculate new state for a non-boundary block' + ) if previous_state is FeatureState.DEFINED: if height >= criteria.start_height: @@ -90,9 +92,10 @@ def _calculate_new_state( # Get the count for this block's parent. Since this is a boundary block, its parent count represents the # previous evaluation interval count. - counts = boundary_block.get_parent_feature_activation_bit_counts() + parent_block = boundary_block.get_block_parent() + counts = parent_block.get_feature_activation_bit_counts() count = counts[criteria.bit] - threshold = criteria.threshold if criteria.threshold is not None else self._settings.default_threshold + threshold = criteria.get_threshold(self._feature_settings) if ( height < criteria.timeout_height @@ -113,7 +116,7 @@ def _calculate_new_state( def _get_criteria(self, *, feature: Feature) -> Criteria: """Get the Criteria defined for a specific Feature.""" - criteria = self._settings.features.get(feature) + criteria = self._feature_settings.features.get(feature) if not criteria: raise ValueError(f"Criteria not defined for feature '{feature}'.") @@ -127,7 +130,7 @@ def get_bits_description(self, *, block: Block) -> dict[Feature, FeatureDescript criteria=criteria, state=self.get_state(block=block, feature=feature) ) - for feature, criteria in self._settings.features.items() + for feature, criteria in self._feature_settings.features.items() } diff --git a/hathor/feature_activation/model/criteria.py b/hathor/feature_activation/model/criteria.py index 87f489713..17fdf4d72 100644 --- a/hathor/feature_activation/model/criteria.py +++ b/hathor/feature_activation/model/criteria.py @@ -12,13 +12,16 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Any, ClassVar, Optional +from typing import TYPE_CHECKING, Any, ClassVar, Optional from pydantic import Field, NonNegativeInt, validator from hathor import version from hathor.utils.pydantic import BaseModel +if TYPE_CHECKING: + from hathor.feature_activation.settings import Settings as FeatureSettings + class Criteria(BaseModel, validate_all=True): """ @@ -55,6 +58,10 @@ class Criteria(BaseModel, validate_all=True): activate_on_timeout: bool = False version: str = Field(..., regex=version.BUILD_VERSION_REGEX) + def get_threshold(self, feature_settings: 'FeatureSettings') -> int: + """Returns the configured threshold, or the default threshold if it is None.""" + return self.threshold if self.threshold is not None else feature_settings.default_threshold + @validator('bit') def _validate_bit(cls, bit: int) -> int: """Validates that the bit is lower than the max_signal_bits.""" diff --git a/hathor/feature_activation/resources/__init__.py b/hathor/feature_activation/resources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/hathor/feature_activation/resources/feature.py b/hathor/feature_activation/resources/feature.py new file mode 100644 index 000000000..9079ae266 --- /dev/null +++ b/hathor/feature_activation/resources/feature.py @@ -0,0 +1,154 @@ +# Copyright 2023 Hathor Labs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Optional + +from twisted.web.http import Request + +from hathor.api_util import Resource, set_cors +from hathor.cli.openapi_files.register import register_resource +from hathor.feature_activation.feature import Feature +from hathor.feature_activation.feature_service import FeatureService +from hathor.feature_activation.model.feature_state import FeatureState +from hathor.feature_activation.settings import Settings as FeatureSettings +from hathor.transaction.storage import TransactionStorage +from hathor.utils.api import Response + + +@register_resource +class FeatureResource(Resource): + __slots__ = () + + isLeaf = True + + def __init__( + self, + *, + feature_settings: FeatureSettings, + feature_service: FeatureService, + tx_storage: TransactionStorage + ) -> None: + super().__init__() + self._feature_settings = feature_settings + self._feature_service = feature_service + self.tx_storage = tx_storage + + def render_GET(self, request: Request) -> bytes: + request.setHeader(b'content-type', b'application/json; charset=utf-8') + set_cors(request, 'GET') + + best_block = self.tx_storage.get_best_block() + bit_counts = best_block.get_feature_activation_bit_counts() + features = [] + + for feature, criteria in self._feature_settings.features.items(): + state = self._feature_service.get_state(block=best_block, feature=feature) + threshold_count = criteria.get_threshold(self._feature_settings) + threshold_percentage = threshold_count / self._feature_settings.evaluation_interval + acceptance_percentage = None + + if state is FeatureState.STARTED: + acceptance_count = bit_counts[criteria.bit] + acceptance_percentage = acceptance_count / self._feature_settings.evaluation_interval + + feature_response = GetFeatureResponse( + name=feature, + state=state.name, + acceptance=acceptance_percentage, + threshold=threshold_percentage, + start_height=criteria.start_height, + minimum_activation_height=criteria.minimum_activation_height, + timeout_height=criteria.timeout_height, + activate_on_timeout=criteria.activate_on_timeout, + version=criteria.version + ) + + features.append(feature_response) + + response = GetFeaturesResponse( + block_hash=best_block.hash_hex, + block_height=best_block.get_height(), + features=features + ) + + return response.json_dumpb() + + +class GetFeatureResponse(Response, use_enum_values=True): + name: Feature + state: str + acceptance: Optional[float] + threshold: float + start_height: int + minimum_activation_height: int + timeout_height: int + activate_on_timeout: bool + version: str + + +class GetFeaturesResponse(Response): + block_hash: str + block_height: int + features: list[GetFeatureResponse] + + +FeatureResource.openapi = { + '/feature': { + 'x-visibility': 'private', + 'get': { + 'operationId': 'feature', + 'summary': 'Feature Activation', + 'description': 'Returns information about features in the Feature Activation process', + 'responses': { + '200': { + 'description': 'Success', + 'content': { + 'application/json': { + 'examples': { + 'success': { + 'block_hash': '00000000083580e5b299e9cb271fd5977103897e8640fcd5498767b6cefba6f5', + 'block_height': 123, + 'features': [ + { + 'name': 'NOP_FEATURE_1', + 'state': 'ACTIVE', + 'acceptance': None, + 'threshold': 0.75, + 'start_height': 0, + 'minimum_activation_height': 0, + 'timeout_height': 100, + 'activate_on_timeout': False, + 'version': '0.1.0' + }, + { + 'name': 'NOP_FEATURE_2', + 'state': 'STARTED', + 'acceptance': 0.25, + 'threshold': 0.5, + 'start_height': 200, + 'minimum_activation_height': 0, + 'timeout_height': 300, + 'activate_on_timeout': False, + 'version': '0.2.0' + } + ] + } + } + } + } + } + } + } + } +} diff --git a/hathor/transaction/block.py b/hathor/transaction/block.py index 2ede7fca8..e85546653 100644 --- a/hathor/transaction/block.py +++ b/hathor/transaction/block.py @@ -140,7 +140,9 @@ def _get_previous_feature_activation_bit_counts(self) -> list[int]: if is_boundary_block: return [] - return self.get_parent_feature_activation_bit_counts() + parent_block = self.get_block_parent() + + return parent_block.get_feature_activation_bit_counts() def get_next_block_best_chain_hash(self) -> Optional[bytes]: """Return the hash of the next (child/left-to-right) block in the best blockchain. @@ -386,12 +388,12 @@ def get_height(self) -> int: """Returns the block's height.""" return self.get_metadata().height - def get_parent_feature_activation_bit_counts(self) -> list[int]: - """Returns the parent block's feature_activation_bit_counts metadata attribute.""" - parent_metadata = self.get_block_parent().get_metadata() - assert parent_metadata.feature_activation_bit_counts is not None, 'Blocks must always have this attribute set.' + def get_feature_activation_bit_counts(self) -> list[int]: + """Returns the block's feature_activation_bit_counts metadata attribute.""" + metadata = self.get_metadata() + assert metadata.feature_activation_bit_counts is not None, 'Blocks must always have this attribute set.' - return parent_metadata.feature_activation_bit_counts + return metadata.feature_activation_bit_counts def _get_feature_activation_bit_list(self) -> list[int]: """ diff --git a/tests/feature_activation/test_feature_service.py b/tests/feature_activation/test_feature_service.py index d31aad08d..fb8a8377f 100644 --- a/tests/feature_activation/test_feature_service.py +++ b/tests/feature_activation/test_feature_service.py @@ -23,7 +23,7 @@ from hathor.feature_activation.model.criteria import Criteria from hathor.feature_activation.model.feature_description import FeatureDescription from hathor.feature_activation.model.feature_state import FeatureState -from hathor.feature_activation.settings import Settings +from hathor.feature_activation.settings import Settings as FeatureSettings from hathor.transaction import Block from hathor.transaction.storage import TransactionStorage @@ -80,11 +80,11 @@ def block_mocks() -> list[Block]: @pytest.fixture def service() -> FeatureService: - settings = Settings( + feature_settings = FeatureSettings( evaluation_interval=4, default_threshold=3 ) - service = FeatureService(settings=settings) + service = FeatureService(feature_settings=feature_settings) return service @@ -119,7 +119,7 @@ def test_get_state_from_defined( start_height: int, expected_state: FeatureState ) -> None: - settings = Settings.construct( + feature_settings = FeatureSettings.construct( evaluation_interval=4, features={ Feature.NOP_FEATURE_1: Criteria.construct( @@ -130,7 +130,7 @@ def test_get_state_from_defined( ) } ) - service = FeatureService(settings=settings) + service = FeatureService(feature_settings=feature_settings) block = block_mocks[block_height] result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) @@ -141,7 +141,7 @@ def test_get_state_from_defined( @pytest.mark.parametrize('block_height', [8, 9, 10, 11, 12, 13]) @pytest.mark.parametrize('timeout_height', [4, 8]) def test_get_state_from_started_to_failed(block_mocks: list[Block], block_height: int, timeout_height: int) -> None: - settings = Settings.construct( + feature_settings = FeatureSettings.construct( evaluation_interval=4, features={ Feature.NOP_FEATURE_1: Criteria.construct( @@ -153,7 +153,7 @@ def test_get_state_from_started_to_failed(block_mocks: list[Block], block_height ) } ) - service = FeatureService(settings=settings) + service = FeatureService(feature_settings=feature_settings) block = block_mocks[block_height] result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) @@ -170,7 +170,7 @@ def test_get_state_from_started_to_active_on_timeout( timeout_height: int, minimum_activation_height: int ) -> None: - settings = Settings.construct( + feature_settings = FeatureSettings.construct( evaluation_interval=4, features={ Feature.NOP_FEATURE_1: Criteria.construct( @@ -183,7 +183,7 @@ def test_get_state_from_started_to_active_on_timeout( ) } ) - service = FeatureService(settings=settings) + service = FeatureService(feature_settings=feature_settings) block = block_mocks[block_height] result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) @@ -200,7 +200,7 @@ def test_get_state_from_started_to_active_on_default_threshold( minimum_activation_height: int, default_threshold: int ) -> None: - settings = Settings.construct( + feature_settings = FeatureSettings.construct( evaluation_interval=4, default_threshold=default_threshold, features={ @@ -214,7 +214,7 @@ def test_get_state_from_started_to_active_on_default_threshold( ) } ) - service = FeatureService(settings=settings) + service = FeatureService(feature_settings=feature_settings) block = block_mocks[block_height] result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) @@ -231,7 +231,7 @@ def test_get_state_from_started_to_active_on_custom_threshold( minimum_activation_height: int, custom_threshold: int ) -> None: - settings = Settings.construct( + feature_settings = FeatureSettings.construct( evaluation_interval=4, features={ Feature.NOP_FEATURE_1: Criteria.construct( @@ -244,7 +244,7 @@ def test_get_state_from_started_to_active_on_custom_threshold( ) } ) - service = FeatureService(settings=settings) + service = FeatureService(feature_settings=feature_settings) block = block_mocks[block_height] result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) @@ -268,7 +268,7 @@ def test_get_state_from_started_to_started( timeout_height: int, minimum_activation_height: int ) -> None: - settings = Settings.construct( + feature_settings = FeatureSettings.construct( evaluation_interval=4, features={ Feature.NOP_FEATURE_1: Criteria.construct( @@ -281,7 +281,7 @@ def test_get_state_from_started_to_started( ) } ) - service = FeatureService(settings=settings) + service = FeatureService(feature_settings=feature_settings) block = block_mocks[block_height] result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) @@ -291,7 +291,7 @@ def test_get_state_from_started_to_started( @pytest.mark.parametrize('block_height', [12, 13, 14, 15]) def test_get_state_from_active(block_mocks: list[Block], block_height: int) -> None: - settings = Settings.construct( + feature_settings = FeatureSettings.construct( evaluation_interval=4, features={ Feature.NOP_FEATURE_1: Criteria.construct( @@ -303,7 +303,7 @@ def test_get_state_from_active(block_mocks: list[Block], block_height: int) -> N ) } ) - service = FeatureService(settings=settings) + service = FeatureService(feature_settings=feature_settings) block = block_mocks[block_height] result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) @@ -313,7 +313,7 @@ def test_get_state_from_active(block_mocks: list[Block], block_height: int) -> N @pytest.mark.parametrize('block_height', [12, 13, 14, 15]) def test_is_feature_active(block_mocks: list[Block], block_height: int) -> None: - settings = Settings.construct( + feature_settings = FeatureSettings.construct( evaluation_interval=4, features={ Feature.NOP_FEATURE_1: Criteria.construct( @@ -325,7 +325,7 @@ def test_is_feature_active(block_mocks: list[Block], block_height: int) -> None: ) } ) - service = FeatureService(settings=settings) + service = FeatureService(feature_settings=feature_settings) block = block_mocks[block_height] result = service.is_feature_active(block=block, feature=Feature.NOP_FEATURE_1) @@ -335,7 +335,7 @@ def test_is_feature_active(block_mocks: list[Block], block_height: int) -> None: @pytest.mark.parametrize('block_height', [12, 13, 14, 15]) def test_get_state_from_failed(block_mocks: list[Block], block_height: int) -> None: - settings = Settings.construct( + feature_settings = FeatureSettings.construct( evaluation_interval=4, features={ Feature.NOP_FEATURE_1: Criteria.construct( @@ -346,7 +346,7 @@ def test_get_state_from_failed(block_mocks: list[Block], block_height: int) -> N ) } ) - service = FeatureService(settings=settings) + service = FeatureService(feature_settings=feature_settings) block = block_mocks[block_height] result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) @@ -366,13 +366,13 @@ def test_get_state_undefined_feature(block_mocks: list[Block], service: FeatureS def test_get_bits_description(): criteria_mock_1 = Criteria.construct() criteria_mock_2 = Criteria.construct() - settings = Settings.construct( + feature_settings = FeatureSettings.construct( features={ Feature.NOP_FEATURE_1: criteria_mock_1, Feature.NOP_FEATURE_2: criteria_mock_2 } ) - service = FeatureService(settings=settings) + service = FeatureService(feature_settings=feature_settings) def get_state(self: FeatureService, *, block: Block, feature: Feature) -> FeatureState: states = { diff --git a/tests/feature_activation/test_settings.py b/tests/feature_activation/test_settings.py index 717801159..8e5f0e555 100644 --- a/tests/feature_activation/test_settings.py +++ b/tests/feature_activation/test_settings.py @@ -16,7 +16,7 @@ from pydantic import ValidationError from hathor.feature_activation.feature import Feature -from hathor.feature_activation.settings import FeatureInterval, Settings, _find_overlap +from hathor.feature_activation.settings import FeatureInterval, Settings as FeatureSettings, _find_overlap @pytest.mark.parametrize( @@ -58,7 +58,7 @@ ) def test_valid_settings(features): data = dict(features=features) - Settings(**data) + FeatureSettings(**data) @pytest.mark.parametrize( @@ -117,7 +117,7 @@ def test_valid_settings(features): def test_conflicting_bits(features): with pytest.raises(ValidationError) as e: data = dict(features=features) - Settings(**data) + FeatureSettings(**data) errors = e.value.errors() assert errors[0]['msg'] == 'At least one pair of Features have the same bit configured for an overlapping ' \ @@ -134,7 +134,7 @@ def test_conflicting_bits(features): def test_default_threshold(evaluation_interval, default_threshold, error): with pytest.raises(ValidationError) as e: data = dict(evaluation_interval=evaluation_interval, default_threshold=default_threshold) - Settings(**data) + FeatureSettings(**data) errors = e.value.errors() assert errors[0]['msg'] == error diff --git a/tests/others/test_cli_builder.py b/tests/others/test_cli_builder.py index ddb67eaae..37ee39726 100644 --- a/tests/others/test_cli_builder.py +++ b/tests/others/test_cli_builder.py @@ -1,3 +1,5 @@ +from unittest.mock import Mock + import pytest from hathor.builder import CliBuilder, ResourcesBuilder @@ -28,7 +30,7 @@ def _build_with_error(self, cmd_args: list[str], err_msg: str) -> None: args = self.parser.parse_args(cmd_args) with self.assertRaises(BuilderError) as cm: manager = self.builder.create_manager(self.reactor, args) - self.resources_builder = ResourcesBuilder(manager, self.builder.event_ws_factory) + self.resources_builder = ResourcesBuilder(manager, self.builder.event_ws_factory, Mock()) self.resources_builder.build(args) self.assertEqual(err_msg, str(cm.exception)) @@ -36,7 +38,7 @@ def _build(self, cmd_args: list[str]) -> HathorManager: args = self.parser.parse_args(cmd_args) manager = self.builder.create_manager(self.reactor, args) self.assertIsNotNone(manager) - self.resources_builder = ResourcesBuilder(manager, self.builder.event_ws_factory) + self.resources_builder = ResourcesBuilder(manager, self.builder.event_ws_factory, Mock()) self.resources_builder.build(args) return manager diff --git a/tests/resources/feature/__init__.py b/tests/resources/feature/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/resources/feature/test_feature.py b/tests/resources/feature/test_feature.py new file mode 100644 index 000000000..fa586df1d --- /dev/null +++ b/tests/resources/feature/test_feature.py @@ -0,0 +1,107 @@ +# Copyright 2023 Hathor Labs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest.mock import Mock + +import pytest + +from hathor.feature_activation.feature import Feature +from hathor.feature_activation.feature_service import FeatureService +from hathor.feature_activation.model.criteria import Criteria +from hathor.feature_activation.model.feature_state import FeatureState +from hathor.feature_activation.resources.feature import FeatureResource +from hathor.feature_activation.settings import Settings as FeatureSettings +from hathor.transaction import Block +from hathor.transaction.storage import TransactionStorage +from tests.resources.base_resource import StubSite + + +@pytest.fixture +def web(): + best_block = Mock(spec_set=Block) + best_block.get_feature_activation_bit_counts = Mock(return_value=[0, 1, 0, 0]) + best_block.hash_hex = 'some_hash' + best_block.get_height = Mock(return_value=123) + + tx_storage = Mock(spec_set=TransactionStorage) + tx_storage.get_best_block = Mock(return_value=best_block) + + def get_state(*, block: Block, feature: Feature) -> FeatureState: + return FeatureState.ACTIVE if feature is Feature.NOP_FEATURE_1 else FeatureState.STARTED + + feature_service = Mock(spec_set=FeatureService) + feature_service.get_state = Mock(side_effect=get_state) + + feature_settings = FeatureSettings( + evaluation_interval=4, + default_threshold=3, + features={ + Feature.NOP_FEATURE_1: Criteria( + bit=0, + start_height=0, + timeout_height=100, + version='0.1.0' + ), + Feature.NOP_FEATURE_2: Criteria( + bit=1, + start_height=200, + threshold=2, + timeout_height=300, + version='0.2.0' + ) + } + ) + + feature_resource = FeatureResource( + feature_settings=feature_settings, + feature_service=feature_service, + tx_storage=tx_storage + ) + + return StubSite(feature_resource) + + +def test_get_features(web): + response = web.get('feature') + result = response.result.json_value() + expected = dict( + block_hash='some_hash', + block_height=123, + features=[ + dict( + name='NOP_FEATURE_1', + state='ACTIVE', + acceptance=None, + threshold=0.75, + start_height=0, + minimum_activation_height=0, + timeout_height=100, + activate_on_timeout=False, + version='0.1.0' + ), + dict( + name='NOP_FEATURE_2', + state='STARTED', + acceptance=0.25, + threshold=0.5, + start_height=200, + minimum_activation_height=0, + timeout_height=300, + activate_on_timeout=False, + version='0.2.0' + ) + ] + ) + + assert result == expected