diff --git a/algosdk/constants.py b/algosdk/constants.py index cf3c8a21..d0466e7a 100644 --- a/algosdk/constants.py +++ b/algosdk/constants.py @@ -9,7 +9,7 @@ """str: header key for algod requests""" INDEXER_AUTH_HEADER = "X-Indexer-API-Token" """str: header key for indexer requests""" -UNVERSIONED_PATHS = ["/health", "/versions", "/metrics", "/genesis"] +UNVERSIONED_PATHS = ["/health", "/versions", "/metrics", "/genesis", "/ready"] """str[]: paths that don't use the version path prefix""" NO_AUTH: List[str] = [] """str[]: requests that don't require authentication""" diff --git a/algosdk/v2client/algod.py b/algosdk/v2client/algod.py index 586b780e..c07d3296 100644 --- a/algosdk/v2client/algod.py +++ b/algosdk/v2client/algod.py @@ -113,6 +113,11 @@ def algod_request( try: return json.load(resp) except Exception as e: + # Some algod responses currently return a 200 OK + # but have an empty response. + # Do not return an error, and just return an empty response. + if resp.status == 200 and resp.length == 0: + return {} raise error.AlgodResponseError( "Failed to parse JSON response from algod" ) from e @@ -641,6 +646,78 @@ def simulate_raw_transactions( ) return self.simulate_transactions(request, **kwargs) + def get_sync_round(self, **kwargs: Any) -> AlgodResponseType: + """ + Get the minimum sync round for the ledger. + + Returns: + Dict[str, Any]: Response from algod + """ + req = "/ledger/sync" + return self.algod_request("GET", req, **kwargs) + + def set_sync_round(self, round: int, **kwargs: Any) -> AlgodResponseType: + """ + Set the minimum sync round for the ledger. + + Args: + round (int): Sync round + + Returns: + Dict[str, Any]: Response from algod + """ + req = f"/ledger/sync/{round}" + return self.algod_request("POST", req, **kwargs) + + def unset_sync_round(self, **kwargs: Any) -> AlgodResponseType: + """ + Unset the minimum sync round for the ledger. + + Returns: + Dict[str, Any]: Response from algod + """ + req = "/ledger/sync" + return self.algod_request("DELETE", req, **kwargs) + + def ready(self, **kwargs: Any) -> AlgodResponseType: + """ + Returns OK if the node is healthy and fully caught up. + + Returns: + Dict[str, Any]: Response from algod + """ + req = "/ready" + return self.algod_request("GET", req, **kwargs) + + def get_timestamp_offset(self, **kwargs: Any) -> AlgodResponseType: + """ + Get the timestamp offset in block headers. + This feature is only available in dev mode networks. + + Returns: + Dict[str, Any]: Response from algod + """ + req = "/devmode/blocks/offset" + return self.algod_request("GET", req, **kwargs) + + def set_timestamp_offset( + self, + offset: int, + **kwargs: Any, + ) -> AlgodResponseType: + """ + Set the timestamp offset in block headers. + This feature is only available in dev mode networks. + + Args: + offset (int): Block timestamp offset + + Returns: + Dict[str, Any]: Response from algod + """ + req = f"/devmode/blocks/offset/{offset}" + return self.algod_request("POST", req, **kwargs) + def _specify_round_string( block: Union[int, None], round_num: Union[int, None] diff --git a/tests/environment.py b/tests/environment.py index 691c3aaf..32a7ecc3 100644 --- a/tests/environment.py +++ b/tests/environment.py @@ -53,6 +53,14 @@ def do_POST(self): m = bytes(m, "ascii") self.wfile.write(m) + def do_DELETE(self): + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + m = json.dumps({"path": self.path}) + m = bytes(m, "ascii") + self.wfile.write(m) + def get_status_to_use(): f = open("tests/features/resources/mock_response_status", "r") diff --git a/tests/steps/other_v2_steps.py b/tests/steps/other_v2_steps.py index 4d51e471..9a04f7fd 100644 --- a/tests/steps/other_v2_steps.py +++ b/tests/steps/other_v2_steps.py @@ -906,6 +906,11 @@ def expect_path(context, path): assert exp_query == actual_query, f"{exp_query} != {actual_query}" +@then('expect the request to be "{method}" "{path}"') +def expect_request(context, method, path): + return expect_path(context, path) + + @then('expect error string to contain "{err:MaybeString}"') def expect_error(context, err): # TODO: this should actually do the claimed action @@ -1524,3 +1529,38 @@ def check_missing_signatures(context, group, path): "failure-message" ] assert missing_sig is True + + +@when("we make a GetLedgerStateDelta call against round {round}") +def get_ledger_state_delta_call(context, round): + context.response = context.acl.get_ledger_state_delta(round) + + +@when("we make a SetSyncRound call against round {round}") +def set_sync_round_call(context, round): + context.response = context.acl.set_sync_round(round) + + +@when("we make a GetSyncRound call") +def get_sync_round_call(context): + context.response = context.acl.get_sync_round() + + +@when("we make a UnsetSyncRound call") +def unset_sync_round_call(context): + context.response = context.acl.unset_sync_round() + + +@when("we make a Ready call") +def ready_call(context): + context.response = context.acl.ready() + + +@when("we make a SetBlockTimeStampOffset call against offset {offset}") +def set_block_timestamp_offset(context, offset): + context.response = context.acl.set_timestamp_offset(offset) + + +@when("we make a GetBlockTimeStampOffset call") +def get_block_timestamp_offset(context): + context.response = context.acl.get_timestamp_offset() diff --git a/tests/unit.tags b/tests/unit.tags index 04be53d3..b6b057ce 100644 --- a/tests/unit.tags +++ b/tests/unit.tags @@ -15,14 +15,19 @@ @unit.indexer.logs @unit.offline @unit.program_sanity_check +@unit.ready @unit.rekey @unit.responses @unit.responses.231 @unit.responses.blocksummary @unit.responses.participationupdates +@unit.responses.sync +@unit.responses.timestamp @unit.responses.unlimited_assets @unit.sourcemap +@unit.sync @unit.tealsign +@unit.timestamp @unit.transactions @unit.transactions.keyreg @unit.transactions.payment