mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-05-19 06:14:47 -07:00
global: fixes
This commit is contained in:
@@ -69,7 +69,7 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
|
||||
|
||||
writeln!(
|
||||
output,
|
||||
" * @param {{{{ signal?: AbortSignal, onUpdate?: (value: {}) => void }}}} [options]",
|
||||
" * @param {{{{ signal?: AbortSignal, onValue?: (value: {}) => void }}}} [options]",
|
||||
return_type
|
||||
)
|
||||
.unwrap();
|
||||
@@ -78,18 +78,18 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
|
||||
|
||||
let params = build_method_params(endpoint);
|
||||
let params_with_opts = if params.is_empty() {
|
||||
"{ signal, onUpdate } = {}".to_string()
|
||||
"{ signal, onValue } = {}".to_string()
|
||||
} else {
|
||||
format!("{}, {{ signal, onUpdate }} = {{}}", params)
|
||||
format!("{}, {{ signal, onValue }} = {{}}", params)
|
||||
};
|
||||
writeln!(output, " async {}({}) {{", method_name, params_with_opts).unwrap();
|
||||
|
||||
let path = build_path_template(&endpoint.path, &endpoint.path_params);
|
||||
|
||||
let fetch_call = if endpoint.returns_json() {
|
||||
"this.getJson(path, { signal, onUpdate })"
|
||||
"this.getJson(path, { signal, onValue })"
|
||||
} else {
|
||||
"this.getText(path, { signal, onUpdate })"
|
||||
"this.getText(path, { signal, onValue })"
|
||||
};
|
||||
|
||||
if endpoint.query_params.is_empty() {
|
||||
@@ -126,7 +126,7 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
|
||||
if endpoint.supports_csv {
|
||||
writeln!(
|
||||
output,
|
||||
" if (format === 'csv') return this.getText(path, {{ signal, onUpdate }});"
|
||||
" if (format === 'csv') return this.getText(path, {{ signal, onValue }});"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -16,12 +16,16 @@ pub fn generate_base_client(output: &mut String) {
|
||||
* @typedef {{Object}} BrkClientOptions
|
||||
* @property {{string}} baseUrl - Base URL for the API
|
||||
* @property {{number}} [timeout] - Request timeout in milliseconds
|
||||
* @property {{string|boolean}} [cache] - Enable browser cache with default name (true), custom name (string), or disable (false). No effect in Node.js. Default: true
|
||||
* @property {{string|boolean}} [browserCache] - Enable browser Cache API with default name (true), custom name (string), or disable (false). No effect in Node.js. Default: true
|
||||
* @property {{number|boolean}} [memCache] - In-memory parsed-response cache size (LRU). true/undefined → 1000, false/0 → disabled. Lets 304 responses skip the JSON parse entirely. Default: 1000
|
||||
*/
|
||||
|
||||
const _isBrowser = typeof window !== 'undefined' && 'caches' in window;
|
||||
const _runIdle = (/** @type {{VoidFunction}} */ fn) => (globalThis.requestIdleCallback ?? setTimeout)(fn);
|
||||
const _defaultCacheName = '__BRK_CLIENT__';
|
||||
const _defaultBrowserCacheName = '__BRK_CLIENT__';
|
||||
const _DEFAULT_MEM_CACHE_SIZE = 1000;
|
||||
|
||||
/** @template T @typedef {{{{ etag: string | null, value: T }}}} _MemEntry */
|
||||
/** @param {{*}} v */
|
||||
const _addCamelGetters = (v) => {{
|
||||
if (Array.isArray(v)) {{ v.forEach(_addCamelGetters); return v; }}
|
||||
@@ -38,12 +42,12 @@ const _addCamelGetters = (v) => {{
|
||||
}};
|
||||
|
||||
/**
|
||||
* @param {{string|boolean|undefined}} cache
|
||||
* @param {{string|boolean|undefined}} option
|
||||
* @returns {{Promise<Cache | null>}}
|
||||
*/
|
||||
const _openCache = (cache) => {{
|
||||
if (!_isBrowser || cache === false) return Promise.resolve(null);
|
||||
const name = typeof cache === 'string' ? cache : _defaultCacheName;
|
||||
const _openBrowserCache = (option) => {{
|
||||
if (!_isBrowser || option === false) return Promise.resolve(null);
|
||||
const name = typeof option === 'string' ? option : _defaultBrowserCacheName;
|
||||
return caches.open(name).catch(() => null);
|
||||
}};
|
||||
|
||||
@@ -233,7 +237,7 @@ function _wrapSeriesData(raw) {{
|
||||
* @property {{(n: number) => RangeBuilder<T>}} first - Get first n items
|
||||
* @property {{(n: number) => RangeBuilder<T>}} last - Get last n items
|
||||
* @property {{(n: number) => SkippedBuilder<T>}} skip - Skip first n items, chain with take()
|
||||
* @property {{(onUpdate?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch all data
|
||||
* @property {{(onValue?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch all data
|
||||
* @property {{() => Promise<string>}} fetchCsv - Fetch all data as CSV
|
||||
* @property {{() => Promise<number>}} len - Get total number of data points
|
||||
* @property {{() => Promise<Version>}} version - Get the current version of the series
|
||||
@@ -249,7 +253,7 @@ function _wrapSeriesData(raw) {{
|
||||
* @property {{(n: number) => DateRangeBuilder<T>}} first - Get first n items
|
||||
* @property {{(n: number) => DateRangeBuilder<T>}} last - Get last n items
|
||||
* @property {{(n: number) => DateSkippedBuilder<T>}} skip - Skip first n items, chain with take()
|
||||
* @property {{(onUpdate?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch all data
|
||||
* @property {{(onValue?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch all data
|
||||
* @property {{() => Promise<string>}} fetchCsv - Fetch all data as CSV
|
||||
* @property {{() => Promise<number>}} len - Get total number of data points
|
||||
* @property {{() => Promise<Version>}} version - Get the current version of the series
|
||||
@@ -260,39 +264,39 @@ function _wrapSeriesData(raw) {{
|
||||
/** @typedef {{SeriesEndpoint<any>}} AnySeriesEndpoint */
|
||||
|
||||
/** @template T @typedef {{Object}} SingleItemBuilder
|
||||
* @property {{(onUpdate?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch the item
|
||||
* @property {{(onValue?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch the item
|
||||
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
|
||||
* @property {{Thenable<T>}} then - Thenable
|
||||
*/
|
||||
|
||||
/** @template T @typedef {{Object}} DateSingleItemBuilder
|
||||
* @property {{(onUpdate?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch the item
|
||||
* @property {{(onValue?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch the item
|
||||
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
|
||||
* @property {{DateThenable<T>}} then - Thenable
|
||||
*/
|
||||
|
||||
/** @template T @typedef {{Object}} SkippedBuilder
|
||||
* @property {{(n: number) => RangeBuilder<T>}} take - Take n items after skipped position
|
||||
* @property {{(onUpdate?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch from skipped position to end
|
||||
* @property {{(onValue?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch from skipped position to end
|
||||
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
|
||||
* @property {{Thenable<T>}} then - Thenable
|
||||
*/
|
||||
|
||||
/** @template T @typedef {{Object}} DateSkippedBuilder
|
||||
* @property {{(n: number) => DateRangeBuilder<T>}} take - Take n items after skipped position
|
||||
* @property {{(onUpdate?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch from skipped position to end
|
||||
* @property {{(onValue?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch from skipped position to end
|
||||
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
|
||||
* @property {{DateThenable<T>}} then - Thenable
|
||||
*/
|
||||
|
||||
/** @template T @typedef {{Object}} RangeBuilder
|
||||
* @property {{(onUpdate?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch the range
|
||||
* @property {{(onValue?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch the range
|
||||
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
|
||||
* @property {{Thenable<T>}} then - Thenable
|
||||
*/
|
||||
|
||||
/** @template T @typedef {{Object}} DateRangeBuilder
|
||||
* @property {{(onUpdate?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch the range
|
||||
* @property {{(onValue?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch the range
|
||||
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
|
||||
* @property {{DateThenable<T>}} then - Thenable
|
||||
*/
|
||||
@@ -340,7 +344,7 @@ function _endpoint(client, name, index) {{
|
||||
* @returns {{DateRangeBuilder<T>}}
|
||||
*/
|
||||
const rangeBuilder = (start, end) => ({{
|
||||
fetch(onUpdate) {{ return client._fetchSeriesData(buildPath(start, end), onUpdate); }},
|
||||
fetch(onValue) {{ return client._fetchSeriesData(buildPath(start, end), onValue); }},
|
||||
fetchCsv() {{ return client.getText(buildPath(start, end, 'csv')); }},
|
||||
then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
|
||||
}});
|
||||
@@ -350,7 +354,7 @@ function _endpoint(client, name, index) {{
|
||||
* @returns {{DateSingleItemBuilder<T>}}
|
||||
*/
|
||||
const singleItemBuilder = (idx) => ({{
|
||||
fetch(onUpdate) {{ return client._fetchSeriesData(buildPath(idx, idx + 1), onUpdate); }},
|
||||
fetch(onValue) {{ return client._fetchSeriesData(buildPath(idx, idx + 1), onValue); }},
|
||||
fetchCsv() {{ return client.getText(buildPath(idx, idx + 1, 'csv')); }},
|
||||
then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
|
||||
}});
|
||||
@@ -361,7 +365,7 @@ function _endpoint(client, name, index) {{
|
||||
*/
|
||||
const skippedBuilder = (start) => ({{
|
||||
take(n) {{ return rangeBuilder(start, start + n); }},
|
||||
fetch(onUpdate) {{ return client._fetchSeriesData(buildPath(start, undefined), onUpdate); }},
|
||||
fetch(onValue) {{ return client._fetchSeriesData(buildPath(start, undefined), onValue); }},
|
||||
fetchCsv() {{ return client.getText(buildPath(start, undefined, 'csv')); }},
|
||||
then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
|
||||
}});
|
||||
@@ -377,7 +381,7 @@ function _endpoint(client, name, index) {{
|
||||
first(n) {{ return rangeBuilder(undefined, n); }},
|
||||
last(n) {{ return n === 0 ? rangeBuilder(undefined, 0) : rangeBuilder(-n, undefined); }},
|
||||
skip(n) {{ return skippedBuilder(n); }},
|
||||
fetch(onUpdate) {{ return client._fetchSeriesData(buildPath(), onUpdate); }},
|
||||
fetch(onValue) {{ return client._fetchSeriesData(buildPath(), onValue); }},
|
||||
fetchCsv() {{ return client.getText(buildPath(undefined, undefined, 'csv')); }},
|
||||
len() {{ return client.getSeriesLen(name, index); }},
|
||||
version() {{ return client.getSeriesVersion(name, index); }},
|
||||
@@ -401,10 +405,45 @@ class BrkClientBase {{
|
||||
this.baseUrl = rawUrl.endsWith('/') ? rawUrl.slice(0, -1) : rawUrl;
|
||||
this.timeout = isString ? 5000 : (options.timeout ?? 5000);
|
||||
/** @type {{Promise<Cache | null>}} */
|
||||
this._cachePromise = _openCache(isString ? undefined : options.cache);
|
||||
this._browserCachePromise = _openBrowserCache(isString ? undefined : options.browserCache);
|
||||
/** @type {{Cache | null}} */
|
||||
this._cache = null;
|
||||
this._cachePromise.then(c => this._cache = c);
|
||||
this._browserCache = null;
|
||||
this._browserCachePromise.then(c => this._browserCache = c);
|
||||
const memOpt = isString ? undefined : options.memCache;
|
||||
this._memCacheMax = memOpt === false || memOpt === 0
|
||||
? 0
|
||||
: (typeof memOpt === 'number' ? memOpt : _DEFAULT_MEM_CACHE_SIZE);
|
||||
/** @type {{Map<string, _MemEntry<unknown>>}} */
|
||||
this._memCache = new Map();
|
||||
}}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {{string}} key
|
||||
* @returns {{_MemEntry<T> | undefined}}
|
||||
*/
|
||||
_memGet(key) {{
|
||||
if (!this._memCacheMax) return undefined;
|
||||
const hit = this._memCache.get(key);
|
||||
if (!hit) return undefined;
|
||||
this._memCache.delete(key);
|
||||
this._memCache.set(key, hit);
|
||||
return /** @type {{_MemEntry<T>}} */ (hit);
|
||||
}}
|
||||
|
||||
/**
|
||||
* @param {{string}} key
|
||||
* @param {{string | null}} etag
|
||||
* @param {{unknown}} value
|
||||
*/
|
||||
_memSet(key, etag, value) {{
|
||||
if (!this._memCacheMax) return;
|
||||
if (this._memCache.has(key)) this._memCache.delete(key);
|
||||
else if (this._memCache.size >= this._memCacheMax) {{
|
||||
const oldest = this._memCache.keys().next().value;
|
||||
if (oldest !== undefined) this._memCache.delete(oldest);
|
||||
}}
|
||||
this._memCache.set(key, {{ etag, value }});
|
||||
}}
|
||||
|
||||
/**
|
||||
@@ -422,66 +461,86 @@ class BrkClientBase {{
|
||||
}}
|
||||
|
||||
/**
|
||||
* Make a GET request - races cache vs network, first to resolve calls onUpdate.
|
||||
* Shared implementation backing `getJson` and `getText`.
|
||||
* Make a GET request with layered caching.
|
||||
*
|
||||
* Contract:
|
||||
* - The returned Promise resolves with the **freshest** value (post-revalidation).
|
||||
* - `onValue` fires once with the freshest value, or twice if a stale snapshot
|
||||
* could be shown first (stale-while-revalidate). On a 304 there is no second fire.
|
||||
*
|
||||
* Layers:
|
||||
* - L1 (memCache): in-memory parsed values keyed by URL+ETag. Lets 304s skip the parse entirely.
|
||||
* - L2 (browserCache): Cache API, survives reload and feeds onValue fast on cold start.
|
||||
*
|
||||
* @template T
|
||||
* @param {{string}} path
|
||||
* @param {{(res: Response) => Promise<T>}} parse - Response body reader
|
||||
* @param {{{{ onUpdate?: (value: T) => void, signal?: AbortSignal }}}} [options]
|
||||
* @param {{{{ onValue?: (value: T) => void, signal?: AbortSignal }}}} [options]
|
||||
* @returns {{Promise<T>}}
|
||||
*/
|
||||
async _getCached(path, parse, {{ onUpdate, signal }} = {{}}) {{
|
||||
async _getCached(path, parse, {{ onValue, signal }} = {{}}) {{
|
||||
const url = `${{this.baseUrl}}${{path}}`;
|
||||
const cache = this._cache ?? await this._cachePromise;
|
||||
/** @type {{_MemEntry<T> | undefined}} */
|
||||
const memHit = this._memGet(url);
|
||||
const browserCache = this._browserCache ?? await this._browserCachePromise;
|
||||
|
||||
let resolved = false;
|
||||
/** @type {{Response | null}} */
|
||||
let cachedRes = null;
|
||||
|
||||
// Race cache vs network - first to resolve calls onUpdate
|
||||
const cachePromise = cache?.match(url).then(async (res) => {{
|
||||
cachedRes = res ?? null;
|
||||
if (!res) return null;
|
||||
const value = await parse(res);
|
||||
if (!resolved && onUpdate) {{
|
||||
resolved = true;
|
||||
onUpdate(value);
|
||||
}}
|
||||
return value;
|
||||
}});
|
||||
|
||||
const networkPromise = this.get(path, {{ signal }}).then(async (res) => {{
|
||||
const cloned = res.clone();
|
||||
const value = await parse(res);
|
||||
// Skip update if ETag matches and cache already delivered
|
||||
if (cachedRes?.headers.get('ETag') === res.headers.get('ETag')) {{
|
||||
if (!resolved && onUpdate) {{
|
||||
resolved = true;
|
||||
onUpdate(value);
|
||||
}}
|
||||
// L1 fast path: deliver from memCache, revalidate via network.
|
||||
// ETag match → zero parse, zero clone, zero cache write, no second onValue fire.
|
||||
if (memHit) {{
|
||||
if (onValue) onValue(memHit.value);
|
||||
try {{
|
||||
const res = await this.get(path, {{ signal }});
|
||||
const netEtag = res.headers.get('ETag');
|
||||
if (netEtag && netEtag === memHit.etag) return memHit.value;
|
||||
const cloned = browserCache ? res.clone() : null;
|
||||
const value = await parse(res);
|
||||
this._memSet(url, netEtag, value);
|
||||
if (onValue) onValue(value);
|
||||
if (cloned) _runIdle(() => browserCache.put(url, cloned));
|
||||
return value;
|
||||
}} catch {{
|
||||
return memHit.value;
|
||||
}}
|
||||
resolved = true;
|
||||
if (onUpdate) onUpdate(value);
|
||||
if (cache) _runIdle(() => cache.put(url, cloned));
|
||||
return value;
|
||||
}});
|
||||
}}
|
||||
|
||||
// L1 miss: race browserCache (stale snapshot) vs network (fresh).
|
||||
let networkSettled = false;
|
||||
const stalePromise = onValue && browserCache
|
||||
? browserCache.match(url).then(async (res) => {{
|
||||
if (!res || networkSettled) return null;
|
||||
const value = await parse(res);
|
||||
if (networkSettled) return value;
|
||||
this._memSet(url, res.headers.get('ETag'), value);
|
||||
onValue(value);
|
||||
return value;
|
||||
}}).catch(() => null)
|
||||
: null;
|
||||
|
||||
try {{
|
||||
return await networkPromise;
|
||||
const res = await this.get(path, {{ signal }});
|
||||
networkSettled = true;
|
||||
const netEtag = res.headers.get('ETag');
|
||||
// Stale won and populated memCache with matching ETag → reuse, skip parse + second onValue.
|
||||
const populated = /** @type {{_MemEntry<T> | undefined}} */ (this._memGet(url));
|
||||
if (populated && netEtag && netEtag === populated.etag) return populated.value;
|
||||
const cloned = browserCache ? res.clone() : null;
|
||||
const value = await parse(res);
|
||||
this._memSet(url, netEtag, value);
|
||||
if (onValue) onValue(value);
|
||||
if (cloned) _runIdle(() => browserCache.put(url, cloned));
|
||||
return value;
|
||||
}} catch (e) {{
|
||||
// Network failed - wait for cache
|
||||
const cachedValue = await cachePromise?.catch(() => null);
|
||||
if (cachedValue != null) return cachedValue;
|
||||
const stale = await stalePromise;
|
||||
if (stale != null) return stale;
|
||||
throw e;
|
||||
}}
|
||||
}}
|
||||
|
||||
/**
|
||||
* Make a GET request expecting a JSON response. Cached and supports `onUpdate`.
|
||||
* Make a GET request expecting a JSON response. Cached and supports `onValue`.
|
||||
* @template T
|
||||
* @param {{string}} path
|
||||
* @param {{{{ onUpdate?: (value: T) => void, signal?: AbortSignal }}}} [options]
|
||||
* @param {{{{ onValue?: (value: T) => void, signal?: AbortSignal }}}} [options]
|
||||
* @returns {{Promise<T>}}
|
||||
*/
|
||||
getJson(path, options) {{
|
||||
@@ -490,9 +549,9 @@ class BrkClientBase {{
|
||||
|
||||
/**
|
||||
* Make a GET request expecting a text response (text/plain, text/csv, ...).
|
||||
* Cached and supports `onUpdate`, same as `getJson`.
|
||||
* Cached and supports `onValue`, same as `getJson`.
|
||||
* @param {{string}} path
|
||||
* @param {{{{ onUpdate?: (value: string) => void, signal?: AbortSignal }}}} [options]
|
||||
* @param {{{{ onValue?: (value: string) => void, signal?: AbortSignal }}}} [options]
|
||||
* @returns {{Promise<string>}}
|
||||
*/
|
||||
getText(path, options) {{
|
||||
@@ -503,12 +562,12 @@ class BrkClientBase {{
|
||||
* Fetch series data and wrap with helper methods (internal)
|
||||
* @template T
|
||||
* @param {{string}} path
|
||||
* @param {{(value: DateSeriesData<T>) => void}} [onUpdate]
|
||||
* @param {{(value: DateSeriesData<T>) => void}} [onValue]
|
||||
* @returns {{Promise<DateSeriesData<T>>}}
|
||||
*/
|
||||
async _fetchSeriesData(path, onUpdate) {{
|
||||
const wrappedOnUpdate = onUpdate ? (/** @type {{SeriesData<T>}} */ raw) => onUpdate(_wrapSeriesData(raw)) : undefined;
|
||||
const raw = await this.getJson(path, {{ onUpdate: wrappedOnUpdate }});
|
||||
async _fetchSeriesData(path, onValue) {{
|
||||
const wrappedOnValue = onValue ? (/** @type {{SeriesData<T>}} */ raw) => onValue(_wrapSeriesData(raw)) : undefined;
|
||||
const raw = await this.getJson(path, {{ onValue: wrappedOnValue }});
|
||||
return _wrapSeriesData(raw);
|
||||
}}
|
||||
}}
|
||||
|
||||
@@ -325,13 +325,13 @@ AnyDateSeriesData = DateSeriesData[Any]
|
||||
|
||||
class _EndpointConfig:
|
||||
"""Shared endpoint configuration."""
|
||||
client: BrkClientBase
|
||||
client: BrkClient
|
||||
name: str
|
||||
index: Index
|
||||
start: Optional[int]
|
||||
end: Optional[int]
|
||||
|
||||
def __init__(self, client: BrkClientBase, name: str, index: Index,
|
||||
def __init__(self, client: BrkClient, name: str, index: Index,
|
||||
start: Optional[int] = None, end: Optional[int] = None):
|
||||
self.client = client
|
||||
self.name = name
|
||||
@@ -366,6 +366,12 @@ class _EndpointConfig:
|
||||
def get_csv(self) -> str:
|
||||
return self.client.get_text(self._build_path(format='csv'))
|
||||
|
||||
def get_len(self) -> int:
|
||||
return self.client.get_series_len(self.name, self.index)
|
||||
|
||||
def get_version(self) -> Version:
|
||||
return self.client.get_series_version(self.name, self.index)
|
||||
|
||||
|
||||
class RangeBuilder(Generic[T]):
|
||||
"""Builder with range specified."""
|
||||
@@ -449,7 +455,7 @@ class SeriesEndpoint(Generic[T]):
|
||||
data = endpoint.skip(100).take(10).fetch()
|
||||
"""
|
||||
|
||||
def __init__(self, client: BrkClientBase, name: str, index: Index):
|
||||
def __init__(self, client: BrkClient, name: str, index: Index):
|
||||
self._config = _EndpointConfig(client, name, index)
|
||||
|
||||
@overload
|
||||
@@ -483,6 +489,14 @@ class SeriesEndpoint(Generic[T]):
|
||||
"""Fetch all data as CSV."""
|
||||
return self._config.get_csv()
|
||||
|
||||
def len(self) -> int:
|
||||
"""Total number of data points for this series."""
|
||||
return self._config.get_len()
|
||||
|
||||
def version(self) -> Version:
|
||||
"""Current version of the series."""
|
||||
return self._config.get_version()
|
||||
|
||||
def path(self) -> str:
|
||||
"""Get the base endpoint path."""
|
||||
return self._config.path()
|
||||
@@ -500,7 +514,7 @@ class DateSeriesEndpoint(Generic[T]):
|
||||
data = endpoint[:10].fetch()
|
||||
"""
|
||||
|
||||
def __init__(self, client: BrkClientBase, name: str, index: Index):
|
||||
def __init__(self, client: BrkClient, name: str, index: Index):
|
||||
self._config = _EndpointConfig(client, name, index)
|
||||
|
||||
@overload
|
||||
@@ -546,6 +560,14 @@ class DateSeriesEndpoint(Generic[T]):
|
||||
"""Fetch all data as CSV."""
|
||||
return self._config.get_csv()
|
||||
|
||||
def len(self) -> int:
|
||||
"""Total number of data points for this series."""
|
||||
return self._config.get_len()
|
||||
|
||||
def version(self) -> Version:
|
||||
"""Current version of the series."""
|
||||
return self._config.get_version()
|
||||
|
||||
def path(self) -> str:
|
||||
"""Get the base endpoint path."""
|
||||
return self._config.path()
|
||||
@@ -604,10 +626,10 @@ pub fn generate_index_accessors(output: &mut String, patterns: &[IndexSetPattern
|
||||
// Generate helper functions
|
||||
writeln!(
|
||||
output,
|
||||
r#"def _ep(c: BrkClientBase, n: str, i: Index) -> SeriesEndpoint[Any]:
|
||||
r#"def _ep(c: BrkClient, n: str, i: Index) -> SeriesEndpoint[Any]:
|
||||
return SeriesEndpoint(c, n, i)
|
||||
|
||||
def _dep(c: BrkClientBase, n: str, i: Index) -> DateSeriesEndpoint[Any]:
|
||||
def _dep(c: BrkClient, n: str, i: Index) -> DateSeriesEndpoint[Any]:
|
||||
return DateSeriesEndpoint(c, n, i)
|
||||
"#
|
||||
)
|
||||
@@ -623,7 +645,7 @@ def _dep(c: BrkClientBase, n: str, i: Index) -> DateSeriesEndpoint[Any]:
|
||||
writeln!(output, "class {}(Generic[T]):", by_class_name).unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" def __init__(self, c: BrkClientBase, n: str): self._c, self._n = c, n"
|
||||
" def __init__(self, c: BrkClient, n: str): self._c, self._n = c, n"
|
||||
)
|
||||
.unwrap();
|
||||
for index in &pattern.indexes {
|
||||
@@ -648,7 +670,7 @@ def _dep(c: BrkClientBase, n: str, i: Index) -> DateSeriesEndpoint[Any]:
|
||||
writeln!(output, " by: {}[T]", by_class_name).unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" def __init__(self, c: BrkClientBase, n: str): self._n, self.by = n, {}(c, n)",
|
||||
" def __init__(self, c: BrkClient, n: str): self._n, self.by = n, {}(c, n)",
|
||||
by_class_name
|
||||
)
|
||||
.unwrap();
|
||||
@@ -705,13 +727,13 @@ pub fn generate_structural_patterns(
|
||||
if pattern.is_templated() {
|
||||
writeln!(
|
||||
output,
|
||||
" def __init__(self, client: BrkClientBase, acc: str, disc: str):"
|
||||
" def __init__(self, client: BrkClient, acc: str, disc: str):"
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" def __init__(self, client: BrkClientBase, acc: str):"
|
||||
" def __init__(self, client: BrkClient, acc: str):"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ fn generate_tree_class(
|
||||
writeln!(output, " ").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" def __init__(self, client: BrkClientBase, base_path: str = ''):"
|
||||
" def __init__(self, client: BrkClient, base_path: str = ''):"
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -187,6 +187,14 @@ impl EndpointConfig {{
|
||||
fn get_text(&self, format: Option<&str>) -> Result<String> {{
|
||||
self.client.get_text(&self.build_path(format))
|
||||
}}
|
||||
|
||||
fn get_len(&self) -> Result<i64> {{
|
||||
self.client.get_json(&format!("/api/series/{{}}/{{}}/len", self.name, self.index.name()))
|
||||
}}
|
||||
|
||||
fn get_version(&self) -> Result<Version> {{
|
||||
self.client.get_json(&format!("/api/series/{{}}/{{}}/version", self.name, self.index.name()))
|
||||
}}
|
||||
}}
|
||||
|
||||
/// Builder for series endpoint queries.
|
||||
@@ -280,6 +288,17 @@ impl<T: DeserializeOwned, D: DeserializeOwned> SeriesEndpoint<T, D> {{
|
||||
self.config.get_text(Some("csv"))
|
||||
}}
|
||||
|
||||
/// Total number of data points for this series.
|
||||
#[allow(clippy::len_without_is_empty)]
|
||||
pub fn len(&self) -> Result<i64> {{
|
||||
self.config.get_len()
|
||||
}}
|
||||
|
||||
/// Current version of the series.
|
||||
pub fn version(&self) -> Result<Version> {{
|
||||
self.config.get_version()
|
||||
}}
|
||||
|
||||
/// Get the base endpoint path.
|
||||
pub fn path(&self) -> String {{
|
||||
self.config.path()
|
||||
|
||||
@@ -19,10 +19,11 @@ BRK uses [sparse files](https://en.wikipedia.org/wiki/Sparse_file). Tools like `
|
||||
## Install
|
||||
|
||||
```bash
|
||||
rustup update
|
||||
RUSTFLAGS="-C target-cpu=native" cargo install --locked brk_cli
|
||||
rustup update && RUSTFLAGS="-C target-cpu=native" cargo install --locked brk_cli --version $(cargo search brk_cli | head -1 | awk -F'"' '{print $2}')
|
||||
```
|
||||
|
||||
Updates Rust, then builds `brk_cli` with optimizations tuned to your CPU. The `--version $(...)` subshell queries crates.io for the absolute latest published version, including pre-releases (rc/beta/alpha); without it, `cargo install` only picks the latest stable.
|
||||
|
||||
Portable build (without native CPU optimizations):
|
||||
|
||||
```bash
|
||||
|
||||
@@ -159,6 +159,14 @@ impl EndpointConfig {
|
||||
fn get_text(&self, format: Option<&str>) -> Result<String> {
|
||||
self.client.get_text(&self.build_path(format))
|
||||
}
|
||||
|
||||
fn get_len(&self) -> Result<i64> {
|
||||
self.client.get_json(&format!("/api/series/{}/{}/len", self.name, self.index.name()))
|
||||
}
|
||||
|
||||
fn get_version(&self) -> Result<Version> {
|
||||
self.client.get_json(&format!("/api/series/{}/{}/version", self.name, self.index.name()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for series endpoint queries.
|
||||
@@ -252,6 +260,17 @@ impl<T: DeserializeOwned, D: DeserializeOwned> SeriesEndpoint<T, D> {
|
||||
self.config.get_text(Some("csv"))
|
||||
}
|
||||
|
||||
/// Total number of data points for this series.
|
||||
#[allow(clippy::len_without_is_empty)]
|
||||
pub fn len(&self) -> Result<i64> {
|
||||
self.config.get_len()
|
||||
}
|
||||
|
||||
/// Current version of the series.
|
||||
pub fn version(&self) -> Result<Version> {
|
||||
self.config.get_version()
|
||||
}
|
||||
|
||||
/// Get the base endpoint path.
|
||||
pub fn path(&self) -> String {
|
||||
self.config.path()
|
||||
|
||||
@@ -42,8 +42,6 @@ impl TxRemoval {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `Replaced` if any of `tx`'s inputs is now claimed by a freshly
|
||||
/// added tx (BIP-125 inferred); otherwise `Vanished`.
|
||||
fn find_removal(tx: &Transaction, spent_by: &SpentBy) -> Self {
|
||||
tx.input
|
||||
.iter()
|
||||
|
||||
@@ -43,9 +43,6 @@ impl Linearizer {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Singleton clusters bypass SFL: there's only one ordering. Larger
|
||||
/// clusters are linearized into chunks, each chunk becoming a Package
|
||||
/// with its order index recorded for downstream stability.
|
||||
fn pack_cluster(cluster: &Cluster, cluster_id: u32) -> Vec<Package> {
|
||||
if cluster.nodes.len() == 1 {
|
||||
return vec![Package::singleton(cluster, cluster_id)];
|
||||
|
||||
@@ -45,8 +45,7 @@ impl EntryPool {
|
||||
}
|
||||
|
||||
/// Direct children of a transaction (txs whose `depends` includes
|
||||
/// `prefix`). Derived on demand via a linear scan, called only by
|
||||
/// the CPFP query endpoint, which is not on the hot path.
|
||||
/// `prefix`). Linear scan over all entries.
|
||||
pub fn children(&self, prefix: &TxidPrefix) -> SmallVec<[TxidPrefix; 2]> {
|
||||
self.entries
|
||||
.iter()
|
||||
|
||||
@@ -36,16 +36,12 @@ impl TxStore {
|
||||
self.promote_recent(new_recent);
|
||||
}
|
||||
|
||||
/// Append to the cap-bounded sample buffer if there's room. The
|
||||
/// pre-cap window becomes the next `recent()` value.
|
||||
fn sample_recent(buf: &mut Vec<MempoolRecentTx>, txid: &Txid, tx: &Transaction) {
|
||||
if buf.len() < RECENT_CAP {
|
||||
buf.push(MempoolRecentTx::from((txid, tx)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Record `txid` in the unresolved set if any input lacks a
|
||||
/// prevout. Cleared later by `apply_fills` once all inputs fill.
|
||||
fn track_unresolved(&mut self, txid: &Txid, tx: &Transaction) {
|
||||
if tx.input.iter().any(|i| i.prevout.is_none()) {
|
||||
self.unresolved.insert(txid.clone());
|
||||
@@ -96,9 +92,6 @@ impl TxStore {
|
||||
applied
|
||||
}
|
||||
|
||||
/// Apply each `(vin, prevout)` to its empty input slot. Skips vins
|
||||
/// that are out of range or already filled. Returns the prevouts
|
||||
/// that were actually written.
|
||||
fn write_prevouts(tx: &mut Transaction, fills: Vec<(Vin, TxOut)>) -> Vec<TxOut> {
|
||||
let mut applied = Vec::with_capacity(fills.len());
|
||||
for (vin, prevout) in fills {
|
||||
@@ -118,8 +111,6 @@ impl TxStore {
|
||||
tx.total_sigop_cost = tx.total_sigop_cost();
|
||||
}
|
||||
|
||||
/// Drop `txid` from the unresolved set if every input now has a
|
||||
/// prevout. Idempotent if the tx was removed between phases.
|
||||
fn refresh_unresolved(&mut self, txid: &Txid) {
|
||||
if self.txs.get(txid).is_some_and(Self::all_resolved) {
|
||||
self.unresolved.remove(txid);
|
||||
|
||||
@@ -85,9 +85,12 @@ impl Query {
|
||||
tx_count: addr_data.tx_count,
|
||||
realized_price,
|
||||
},
|
||||
mempool_stats: self
|
||||
.mempool()
|
||||
.and_then(|m| m.addrs().get(&bytes).map(|e| e.stats.clone())),
|
||||
mempool_stats: self.mempool().map(|m| {
|
||||
m.addrs()
|
||||
.get(&bytes)
|
||||
.map(|e| e.stats.clone())
|
||||
.unwrap_or_default()
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -279,6 +279,7 @@ impl Query {
|
||||
coinbase_signature,
|
||||
coinbase_signature_ascii,
|
||||
scriptsig_bytes,
|
||||
coinbase_total_size,
|
||||
) = match reader.reader_at(positions[i]) {
|
||||
Ok(mut blk) => {
|
||||
let mut header_buf = [0u8; HEADER_SIZE];
|
||||
@@ -291,6 +292,7 @@ impl Query {
|
||||
String::new(),
|
||||
String::new(),
|
||||
vec![],
|
||||
0,
|
||||
)
|
||||
} else {
|
||||
// Skip tx count varint
|
||||
@@ -299,7 +301,7 @@ impl Query {
|
||||
let coinbase = Self::parse_coinbase_from_read(blk);
|
||||
(
|
||||
header_buf, coinbase.0, coinbase.1, coinbase.2, coinbase.3, coinbase.4,
|
||||
coinbase.5,
|
||||
coinbase.5, coinbase.6,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -311,6 +313,7 @@ impl Query {
|
||||
String::new(),
|
||||
String::new(),
|
||||
vec![],
|
||||
0,
|
||||
),
|
||||
};
|
||||
let header = Self::decode_header(&raw_header)?;
|
||||
@@ -382,8 +385,11 @@ impl Query {
|
||||
coinbase_addresses,
|
||||
coinbase_signature,
|
||||
coinbase_signature_ascii,
|
||||
avg_tx_size: if tx_count > 0 {
|
||||
size as f64 / tx_count as f64
|
||||
avg_tx_size: if tx_count > 0 && coinbase_total_size > 0 {
|
||||
let non_coinbase_total = (size as usize)
|
||||
.saturating_sub(HEADER_SIZE + varint_len + coinbase_total_size);
|
||||
let raw = non_coinbase_total as f64 / tx_count as f64;
|
||||
(raw * 100.0).round() / 100.0
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
@@ -542,7 +548,15 @@ impl Query {
|
||||
|
||||
fn parse_coinbase_from_read(
|
||||
reader: impl Read,
|
||||
) -> (String, Option<String>, Vec<String>, String, String, Vec<u8>) {
|
||||
) -> (
|
||||
String,
|
||||
Option<String>,
|
||||
Vec<String>,
|
||||
String,
|
||||
String,
|
||||
Vec<u8>,
|
||||
usize,
|
||||
) {
|
||||
let empty = (
|
||||
String::new(),
|
||||
None,
|
||||
@@ -550,6 +564,7 @@ impl Query {
|
||||
String::new(),
|
||||
String::new(),
|
||||
vec![],
|
||||
0,
|
||||
);
|
||||
|
||||
let tx =
|
||||
@@ -558,6 +573,8 @@ impl Query {
|
||||
Err(_) => return empty,
|
||||
};
|
||||
|
||||
let coinbase_total_size = tx.total_size();
|
||||
|
||||
let scriptsig_bytes: Vec<u8> = tx
|
||||
.input
|
||||
.first()
|
||||
@@ -595,6 +612,7 @@ impl Query {
|
||||
coinbase_signature,
|
||||
coinbase_signature_ascii,
|
||||
scriptsig_bytes,
|
||||
coinbase_total_size,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ impl Query {
|
||||
let (first, tx_count) = self.block_tx_range(height)?;
|
||||
let start: usize = start_index.into();
|
||||
if start >= tx_count {
|
||||
return Ok(Vec::new());
|
||||
return Err(Error::OutOfRange(
|
||||
"start index past last transaction in block".into(),
|
||||
));
|
||||
}
|
||||
let count = BLOCK_TXS_PAGE_SIZE.min(tx_count - start);
|
||||
let indices: Vec<TxIndex> = (first + start..first + start + count)
|
||||
|
||||
@@ -62,11 +62,7 @@ pub enum Auth {
|
||||
CookieFile(PathBuf),
|
||||
}
|
||||
|
||||
///
|
||||
/// Bitcoin Core RPC Client
|
||||
///
|
||||
/// Thread safe and free to clone
|
||||
///
|
||||
/// Bitcoin Core RPC client. Thread-safe and cheap to clone.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Client(pub(crate) Arc<ClientInner>);
|
||||
|
||||
|
||||
@@ -106,7 +106,6 @@ impl Client {
|
||||
})
|
||||
}
|
||||
|
||||
/// Get block hash at a given height
|
||||
pub fn get_block_hash<H>(&self, height: H) -> Result<BlockHash>
|
||||
where
|
||||
H: Into<u64> + Copy,
|
||||
@@ -188,7 +187,6 @@ impl Client {
|
||||
Ok(FeeRate::from(r.mempool_min_fee * 100_000.0))
|
||||
}
|
||||
|
||||
/// Get txids of all transactions in a memory pool
|
||||
pub fn get_raw_mempool(&self) -> Result<Vec<Txid>> {
|
||||
let r: GetRawMempool = self.0.call_with_retry("getrawmempool", &[])?;
|
||||
r.0.iter()
|
||||
@@ -310,7 +308,19 @@ impl Client {
|
||||
pub fn send_raw_transaction(&self, hex: &str) -> Result<Txid> {
|
||||
let txid: bitcoin::Txid = self
|
||||
.0
|
||||
.call_once("sendrawtransaction", &[Value::String(hex.to_string())])?;
|
||||
.call_once("sendrawtransaction", &[Value::String(hex.to_string())])
|
||||
.map_err(|e| {
|
||||
// Bitcoin Core returns RPC error codes for client-side problems
|
||||
// (decode failed, verification failed, already in chain, etc.).
|
||||
// Surface these as 400 (Parse) so HTTP callers see a 4xx, matching
|
||||
// mempool.space's POST /api/tx behavior.
|
||||
if let Error::CorepcRPC(JsonRpcError::Rpc(rpc)) = &e
|
||||
&& matches!(rpc.code, -22 | -25 | -26 | -27)
|
||||
{
|
||||
return Error::Parse(rpc.message.clone());
|
||||
}
|
||||
e
|
||||
})?;
|
||||
Ok(Txid::from(txid))
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ impl TxidsParam {
|
||||
/// Rejects unknown keys to prevent cache-busting via injected query params.
|
||||
pub fn from_query(query: &str) -> Result<Self, String> {
|
||||
if query.is_empty() {
|
||||
return Ok(Self { txids: Vec::new() });
|
||||
return Err("missing required query parameter `txId[]`".into());
|
||||
}
|
||||
let mut txids = Vec::new();
|
||||
for pair in query.split('&') {
|
||||
@@ -49,8 +49,7 @@ mod tests {
|
||||
const T2: &str = "0000000000000000000000000000000000000000000000000000000000000002";
|
||||
|
||||
#[test]
|
||||
fn parses_empty_single_and_multi() {
|
||||
assert!(TxidsParam::from_query("").unwrap().txids.is_empty());
|
||||
fn parses_single_and_multi() {
|
||||
assert_eq!(TxidsParam::from_query(&format!("txId[]={T1}")).unwrap().txids.len(), 1);
|
||||
assert_eq!(
|
||||
TxidsParam::from_query(&format!("txId%5B%5D={T1}&txId[]={T2}"))
|
||||
@@ -62,7 +61,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_key_and_invalid_txid() {
|
||||
fn rejects_empty_unknown_key_and_invalid_txid() {
|
||||
assert!(TxidsParam::from_query("").is_err());
|
||||
assert!(TxidsParam::from_query("foo=bar").is_err());
|
||||
assert!(TxidsParam::from_query("txId[]=notahex").is_err());
|
||||
assert!(TxidsParam::from_query("noequals").is_err());
|
||||
|
||||
@@ -72,7 +72,10 @@ impl AddrValidation {
|
||||
let output_type = OutputType::from(&script);
|
||||
let script_hex = script.as_bytes().to_lower_hex_string();
|
||||
|
||||
let is_script = matches!(output_type, OutputType::P2SH | OutputType::P2TR);
|
||||
let is_script = matches!(
|
||||
output_type,
|
||||
OutputType::P2SH | OutputType::P2WSH | OutputType::P2TR
|
||||
);
|
||||
let is_witness = matches!(
|
||||
output_type,
|
||||
OutputType::P2WPKH | OutputType::P2WSH | OutputType::P2TR | OutputType::P2A
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -35,5 +35,10 @@ dev = [
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
# `check_endpoints.py` at the root of tests/mempool_compat needs the extra glob.
|
||||
python_files = ["test_*.py", "check_*.py"]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
ignore = ["E701"]
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""GET /api/address/{address}"""
|
||||
|
||||
import pytest
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
@pytest.fixture(params=[
|
||||
"12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S", # P2PKH — early block reward
|
||||
"3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r", # P2SH
|
||||
], ids=["p2pkh", "p2sh"])
|
||||
def static_addr(request):
|
||||
"""Well-known addresses that always exist."""
|
||||
return request.param
|
||||
|
||||
|
||||
def test_address_info_static(brk, mempool, static_addr):
|
||||
"""Address stats structure must match for well-known addresses."""
|
||||
path = f"/api/address/{static_addr}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
assert b["address"] == m["address"]
|
||||
|
||||
|
||||
def test_address_info_discovered(brk, mempool, live_addrs):
|
||||
"""Address stats structure must match for each discovered type."""
|
||||
for atype, addr in live_addrs:
|
||||
path = f"/api/address/{addr}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", f"{path} [{atype}]", b, m)
|
||||
assert_same_structure(b, m)
|
||||
assert b["address"] == m["address"]
|
||||
|
||||
|
||||
def test_address_chain_stats_close(brk, mempool, live_addrs):
|
||||
"""Chain stats values must be close for each discovered address."""
|
||||
for atype, addr in live_addrs:
|
||||
path = f"/api/address/{addr}"
|
||||
b = brk.get_json(path)["chain_stats"]
|
||||
m = mempool.get_json(path)["chain_stats"]
|
||||
show("GET", f"{path} [chain_stats, {atype}]", b, m)
|
||||
assert_same_structure(b, m)
|
||||
assert abs(b["tx_count"] - m["tx_count"]) <= 5, (
|
||||
f"{atype} tx_count: brk={b['tx_count']} vs mempool={m['tx_count']}"
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""GET /api/address/{address}/txs"""
|
||||
|
||||
import pytest
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
@pytest.fixture(params=[
|
||||
"12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S",
|
||||
"3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r",
|
||||
], ids=["p2pkh", "p2sh"])
|
||||
def static_addr(request):
|
||||
return request.param
|
||||
|
||||
|
||||
def test_address_txs_static(brk, mempool, static_addr):
|
||||
"""Confirmed+mempool tx list structure must match for well-known addresses."""
|
||||
path = f"/api/address/{static_addr}/txs"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)")
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
if b and m:
|
||||
assert_same_structure(b[0], m[0])
|
||||
|
||||
|
||||
def test_address_txs_discovered(brk, mempool, live_addrs):
|
||||
"""Confirmed+mempool tx list structure must match for each discovered type."""
|
||||
for atype, addr in live_addrs:
|
||||
path = f"/api/address/{addr}/txs"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", f"{path} [{atype}]", f"({len(b)} txs)", f"({len(m)} txs)")
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
if b and m:
|
||||
assert_same_structure(b[0], m[0])
|
||||
|
||||
|
||||
def test_address_txs_fields(brk, mempool, live):
|
||||
"""Every tx in the list must carry the core mempool.space fields."""
|
||||
path = f"/api/address/{live.sample_address}/txs"
|
||||
b = brk.get_json(path)
|
||||
show("GET", path, f"({len(b)} txs)", "—")
|
||||
if not b:
|
||||
pytest.skip("address has no txs in brk")
|
||||
required = {"txid", "version", "locktime", "vin", "vout", "size", "weight", "fee", "status"}
|
||||
for tx in b[:5]:
|
||||
missing = required - set(tx.keys())
|
||||
assert not missing, f"tx {tx.get('txid', '?')} missing fields: {missing}"
|
||||
@@ -0,0 +1,49 @@
|
||||
"""GET /api/address/{address}/txs/chain"""
|
||||
|
||||
import pytest
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
@pytest.fixture(params=[
|
||||
"12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S",
|
||||
"3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r",
|
||||
], ids=["p2pkh", "p2sh"])
|
||||
def static_addr(request):
|
||||
return request.param
|
||||
|
||||
|
||||
def test_address_txs_chain_static(brk, mempool, static_addr):
|
||||
"""Confirmed-only tx list structure must match for well-known addresses."""
|
||||
path = f"/api/address/{static_addr}/txs/chain"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)")
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
if b and m:
|
||||
assert_same_structure(b[0], m[0])
|
||||
|
||||
|
||||
def test_address_txs_chain_discovered(brk, mempool, live_addrs):
|
||||
"""Confirmed-only tx list structure must match for each discovered type."""
|
||||
for atype, addr in live_addrs:
|
||||
path = f"/api/address/{addr}/txs/chain"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", f"{path} [{atype}]", f"({len(b)} txs)", f"({len(m)} txs)")
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
if b and m:
|
||||
assert_same_structure(b[0], m[0])
|
||||
|
||||
|
||||
def test_address_txs_chain_all_confirmed(brk, live):
|
||||
"""Every tx returned by /txs/chain must have confirmed=True in its status."""
|
||||
path = f"/api/address/{live.sample_address}/txs/chain"
|
||||
b = brk.get_json(path)
|
||||
show("GET", path, f"({len(b)} txs)", "—")
|
||||
if not b:
|
||||
pytest.skip("address has no confirmed txs in brk")
|
||||
unconfirmed = [t for t in b if not t.get("status", {}).get("confirmed", False)]
|
||||
assert not unconfirmed, (
|
||||
f"{len(unconfirmed)} unconfirmed tx(s) returned by /txs/chain"
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""GET /api/address/{address}/txs/mempool"""
|
||||
|
||||
from _lib import show
|
||||
|
||||
|
||||
def test_address_txs_mempool_sample(brk, mempool, live):
|
||||
"""Mempool tx list must be an array (contents are volatile)."""
|
||||
path = f"/api/address/{live.sample_address}/txs/mempool"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)")
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
|
||||
|
||||
def test_address_txs_mempool_discovered(brk, mempool, live_addrs):
|
||||
"""Mempool tx list must be a (possibly empty) array for each discovered type."""
|
||||
for atype, addr in live_addrs:
|
||||
path = f"/api/address/{addr}/txs/mempool"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", f"{path} [{atype}]", f"({len(b)} txs)", f"({len(m)} txs)")
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
|
||||
|
||||
def test_address_txs_mempool_all_unconfirmed(brk, live):
|
||||
"""Every tx returned by /txs/mempool must have confirmed=False (if any)."""
|
||||
path = f"/api/address/{live.sample_address}/txs/mempool"
|
||||
b = brk.get_json(path)
|
||||
show("GET", path, f"({len(b)} txs)", "—")
|
||||
confirmed = [t for t in b if t.get("status", {}).get("confirmed", False)]
|
||||
assert not confirmed, (
|
||||
f"{len(confirmed)} confirmed tx(s) returned by /txs/mempool"
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""GET /api/address/{address}/utxo"""
|
||||
|
||||
import pytest
|
||||
|
||||
from _lib import assert_same_values, show
|
||||
|
||||
|
||||
@pytest.fixture(params=[
|
||||
"12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S",
|
||||
"3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r",
|
||||
], ids=["p2pkh", "p2sh"])
|
||||
def static_addr(request):
|
||||
return request.param
|
||||
|
||||
|
||||
def test_address_utxo_static(brk, mempool, static_addr):
|
||||
"""UTXO list must match — same txids, values, and statuses."""
|
||||
path = f"/api/address/{static_addr}/utxo"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} utxos)", f"({len(m)} utxos)")
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
key = lambda u: (u.get("txid", ""), u.get("vout", 0))
|
||||
b_sorted = sorted(b, key=key)
|
||||
m_sorted = sorted(m, key=key)
|
||||
assert_same_values(b_sorted, m_sorted)
|
||||
|
||||
|
||||
def test_address_utxo_discovered(brk, mempool, live_addrs):
|
||||
"""UTXO list must match for each discovered address type — same txids, values, and statuses."""
|
||||
for atype, addr in live_addrs:
|
||||
path = f"/api/address/{addr}/utxo"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", f"{path} [{atype}]", f"({len(b)} utxos)", f"({len(m)} utxos)")
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
key = lambda u: (u.get("txid", ""), u.get("vout", 0))
|
||||
assert_same_values(sorted(b, key=key), sorted(m, key=key))
|
||||
|
||||
|
||||
def test_address_utxo_fields(brk, live):
|
||||
"""Every utxo must carry the core mempool.space fields."""
|
||||
path = f"/api/address/{live.sample_address}/utxo"
|
||||
b = brk.get_json(path)
|
||||
show("GET", path, f"({len(b)} utxos)", "—")
|
||||
if not b:
|
||||
pytest.skip("address has no utxos in brk")
|
||||
required = {"txid", "vout", "value", "status"}
|
||||
for u in b[:5]:
|
||||
missing = required - set(u.keys())
|
||||
assert not missing, f"utxo {u.get('txid', '?')}:{u.get('vout', '?')} missing fields: {missing}"
|
||||
assert isinstance(u["value"], int) and u["value"] > 0
|
||||
@@ -0,0 +1,53 @@
|
||||
"""GET /api/v1/validate-address/{address}"""
|
||||
|
||||
import pytest
|
||||
|
||||
from _lib import assert_same_structure, assert_same_values, show
|
||||
|
||||
|
||||
def test_validate_address_discovered(brk, mempool, live_addrs):
|
||||
"""Validation of each discovered address type must match exactly."""
|
||||
for atype, addr in live_addrs:
|
||||
path = f"/api/v1/validate-address/{addr}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", f"{path} [{atype}]", b, m)
|
||||
assert_same_values(b, m)
|
||||
assert b["isvalid"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("addr,kind", [
|
||||
("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", "p2pkh-genesis"),
|
||||
("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy", "p2sh"),
|
||||
("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", "p2wpkh"),
|
||||
("bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0", "p2tr"),
|
||||
])
|
||||
def test_validate_address_static_valid(brk, mempool, addr, kind):
|
||||
"""Well-known addresses across all script types must validate identically."""
|
||||
path = f"/api/v1/validate-address/{addr}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", f"{path} [{kind}]", b, m)
|
||||
assert_same_values(b, m)
|
||||
assert b["isvalid"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("addr,kind", [
|
||||
("notanaddress123", "garbage"),
|
||||
("", "empty"),
|
||||
("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNb", "bad-checksum-p2pkh"),
|
||||
("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5", "bad-checksum-p2wpkh"),
|
||||
("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLz", "bad-checksum-p2sh"),
|
||||
])
|
||||
def test_validate_address_invalid(brk, mempool, addr, kind):
|
||||
"""Invalid addresses must produce the same rejection structure."""
|
||||
path = f"/api/v1/validate-address/{addr}"
|
||||
if kind == "empty":
|
||||
# An empty path segment routes to a different endpoint — skip.
|
||||
pytest.skip("empty address routes to a different endpoint")
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", f"{path} [{kind}]", b, m)
|
||||
assert b["isvalid"] is False
|
||||
assert m["isvalid"] is False
|
||||
assert_same_structure(b, m)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""GET /api/block/{hash}"""
|
||||
|
||||
from _lib import assert_same_values, show
|
||||
|
||||
|
||||
def test_block_by_hash(brk, mempool, block):
|
||||
"""Confirmed block info must be identical."""
|
||||
path = f"/api/block/{block.hash}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_values(b, m)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""GET /api/block/{hash}/header"""
|
||||
|
||||
from _lib import show
|
||||
|
||||
|
||||
def test_block_header(brk, mempool, block):
|
||||
"""80-byte hex block header must be identical."""
|
||||
path = f"/api/block/{block.hash}/header"
|
||||
b = brk.get_text(path)
|
||||
m = mempool.get_text(path)
|
||||
show("GET", path, b, m)
|
||||
assert len(b) == 160, f"Expected 160 hex chars (80 bytes), got {len(b)}"
|
||||
assert b == m
|
||||
@@ -0,0 +1,13 @@
|
||||
"""GET /api/block-height/{height}"""
|
||||
|
||||
from _lib import show
|
||||
|
||||
|
||||
def test_block_height_to_hash(brk, mempool, block):
|
||||
"""Block hash at a given height must match."""
|
||||
path = f"/api/block-height/{block.height}"
|
||||
b = brk.get_text(path)
|
||||
m = mempool.get_text(path)
|
||||
show("GET", path, b, m)
|
||||
assert b == m
|
||||
assert b == block.hash
|
||||
@@ -0,0 +1,13 @@
|
||||
"""GET /api/block/{hash}/raw"""
|
||||
|
||||
from _lib import show
|
||||
|
||||
|
||||
def test_block_raw(brk, mempool, block):
|
||||
"""Raw block bytes must be identical and start with the 80-byte header."""
|
||||
path = f"/api/block/{block.hash}/raw"
|
||||
b = brk.get_bytes(path)
|
||||
m = mempool.get_bytes(path)
|
||||
show("GET", path, f"<{len(b)} bytes>", f"<{len(m)} bytes>")
|
||||
assert b == m
|
||||
assert len(b) >= 80
|
||||
@@ -0,0 +1,12 @@
|
||||
"""GET /api/block/{hash}/status"""
|
||||
|
||||
from _lib import assert_same_values, show
|
||||
|
||||
|
||||
def test_block_status(brk, mempool, block):
|
||||
"""Block status must be identical for a confirmed block."""
|
||||
path = f"/api/block/{block.hash}/status"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_values(b, m)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""GET /api/block/{hash}/txid/{index}"""
|
||||
|
||||
import pytest
|
||||
|
||||
from _lib import show
|
||||
|
||||
|
||||
def test_block_txid_at_index_0(brk, mempool, block):
|
||||
"""Txid at position 0 (coinbase) must match."""
|
||||
path = f"/api/block/{block.hash}/txid/0"
|
||||
b = brk.get_text(path)
|
||||
m = mempool.get_text(path)
|
||||
show("GET", path, b, m)
|
||||
assert b == m
|
||||
|
||||
|
||||
def test_block_txid_at_index_1(brk, mempool, block):
|
||||
"""Txid at position 1 (first non-coinbase) must match."""
|
||||
txids = mempool.get_json(f"/api/block/{block.hash}/txids")
|
||||
if len(txids) <= 1:
|
||||
pytest.skip("block has only coinbase")
|
||||
path = f"/api/block/{block.hash}/txid/1"
|
||||
b = brk.get_text(path)
|
||||
m = mempool.get_text(path)
|
||||
show("GET", path, b, m)
|
||||
assert b == m
|
||||
|
||||
|
||||
def test_block_txid_at_last_index(brk, mempool, block):
|
||||
"""Txid at last position must match."""
|
||||
txids = mempool.get_json(f"/api/block/{block.hash}/txids")
|
||||
last = len(txids) - 1
|
||||
path = f"/api/block/{block.hash}/txid/{last}"
|
||||
b = brk.get_text(path)
|
||||
m = mempool.get_text(path)
|
||||
show("GET", path, b, m)
|
||||
assert b == m
|
||||
@@ -0,0 +1,12 @@
|
||||
"""GET /api/block/{hash}/txids"""
|
||||
|
||||
from _lib import show
|
||||
|
||||
|
||||
def test_block_txids(brk, mempool, block):
|
||||
"""Ordered txid list must be identical."""
|
||||
path = f"/api/block/{block.hash}/txids"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b[:3], m[:3])
|
||||
assert b == m
|
||||
@@ -0,0 +1,14 @@
|
||||
"""GET /api/block/{hash}/txs"""
|
||||
|
||||
from _lib import assert_same_values, show
|
||||
|
||||
|
||||
def test_block_txs_page0(brk, mempool, block):
|
||||
"""First page of block transactions must match."""
|
||||
path = f"/api/block/{block.hash}/txs"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)")
|
||||
assert len(b) == len(m), f"Page size: brk={len(b)} vs mempool={len(m)}"
|
||||
if b and m:
|
||||
assert_same_values(b[0], m[0], exclude={"sigops"})
|
||||
@@ -0,0 +1,68 @@
|
||||
"""GET /api/block/{hash}/txs/{start_index}"""
|
||||
|
||||
import pytest
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
def test_block_txs_start_index_25(brk, mempool, block):
|
||||
"""Paginated txs from index 25 must match (skip small blocks)."""
|
||||
txids = mempool.get_json(f"/api/block/{block.hash}/txids")
|
||||
if len(txids) <= 25:
|
||||
pytest.skip(f"block has only {len(txids)} txs")
|
||||
path = f"/api/block/{block.hash}/txs/25"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)")
|
||||
assert len(b) == len(m)
|
||||
if b and m:
|
||||
assert_same_structure(b[0], m[0])
|
||||
|
||||
|
||||
def test_block_txs_start_index_zero(brk, mempool, block):
|
||||
"""`/txs/0` must mirror `/txs` (the default page) in length and structure."""
|
||||
path0 = f"/api/block/{block.hash}/txs/0"
|
||||
pathx = f"/api/block/{block.hash}/txs"
|
||||
b0 = brk.get_json(path0)
|
||||
bx = brk.get_json(pathx)
|
||||
show("GET", path0, f"({len(b0)} txs)", f"vs /txs ({len(bx)} txs)")
|
||||
assert len(b0) == len(bx)
|
||||
if b0 and bx:
|
||||
assert b0[0]["txid"] == bx[0]["txid"]
|
||||
|
||||
|
||||
def test_block_txs_start_aligned_pagination(brk, mempool, block):
|
||||
"""Pages at 0, 25, 50 must each be aligned slices of the full txid list."""
|
||||
txids = mempool.get_json(f"/api/block/{block.hash}/txids")
|
||||
if len(txids) <= 50:
|
||||
pytest.skip(f"block has only {len(txids)} txs")
|
||||
# mempool.space orders txids tip-first inside the block payload, but
|
||||
# /txids returns them in block order (coinbase-first). Paged /txs follows
|
||||
# the same coinbase-first order — so page N starts at offset N.
|
||||
page0 = brk.get_json(f"/api/block/{block.hash}/txs/0")
|
||||
page25 = brk.get_json(f"/api/block/{block.hash}/txs/25")
|
||||
page50 = brk.get_json(f"/api/block/{block.hash}/txs/50")
|
||||
show("GET", f"/api/block/{block.hash}/txs/{{0,25,50}}",
|
||||
f"page0={len(page0)} page25={len(page25)} page50={len(page50)}", "—")
|
||||
# The paging origin is what mempool.space does; verify against the live
|
||||
# /txids list rather than re-deriving the order ourselves.
|
||||
assert page0 and page0[0]["txid"] == txids[0]
|
||||
assert page25 and page25[0]["txid"] == txids[25]
|
||||
assert page50 and page50[0]["txid"] == txids[50]
|
||||
|
||||
|
||||
def test_block_txs_start_past_end(brk, mempool, block):
|
||||
"""A start index past the last tx must produce the same response on both servers."""
|
||||
txids = mempool.get_json(f"/api/block/{block.hash}/txids")
|
||||
past = len(txids) + 1000
|
||||
path = f"/api/block/{block.hash}/txs/{past}"
|
||||
b_resp = brk.get_raw(path)
|
||||
m_resp = mempool.get_raw(path)
|
||||
show("GET", path, f"brk={b_resp.status_code}", f"mempool={m_resp.status_code}")
|
||||
assert b_resp.status_code == m_resp.status_code, (
|
||||
f"past-end status differs: brk={b_resp.status_code} vs mempool={m_resp.status_code}"
|
||||
)
|
||||
if b_resp.status_code == 200:
|
||||
assert b_resp.json() == m_resp.json(), (
|
||||
f"past-end body differs: brk={b_resp.json()} vs mempool={m_resp.json()}"
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""GET /api/v1/block/{hash}"""
|
||||
|
||||
from _lib import assert_same_structure, assert_same_values, show
|
||||
|
||||
|
||||
def test_block_v1_extras_all_values(brk, mempool, block):
|
||||
"""Every shared extras field must match — exposes computation differences."""
|
||||
path = f"/api/v1/block/{block.hash}"
|
||||
b = brk.get_json(path)["extras"]
|
||||
m = mempool.get_json(path)["extras"]
|
||||
show("GET", f"{path} [extras]", b, m, max_lines=50)
|
||||
assert_same_structure(b, m)
|
||||
assert_same_values(b, m)
|
||||
|
||||
|
||||
def test_block_v1_extras_pool(brk, mempool, block):
|
||||
"""Pool identification structure must match."""
|
||||
path = f"/api/v1/block/{block.hash}"
|
||||
bp = brk.get_json(path)["extras"]["pool"]
|
||||
mp = mempool.get_json(path)["extras"]["pool"]
|
||||
show("GET", f"{path} [extras.pool]", bp, mp)
|
||||
assert_same_structure(bp, mp)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""GET /api/blocks/{height}"""
|
||||
|
||||
from _lib import assert_same_values, show
|
||||
|
||||
|
||||
def test_blocks_from_height(brk, mempool, block):
|
||||
"""Confirmed blocks from a fixed height must match exactly."""
|
||||
path = f"/api/blocks/{block.height}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
|
||||
assert len(b) == len(m)
|
||||
if b and m:
|
||||
assert_same_values(b[0], m[0])
|
||||
@@ -0,0 +1,35 @@
|
||||
"""GET /api/blocks (most recent confirmed blocks, no height)"""
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
def test_blocks_recent_structure(brk, mempool):
|
||||
"""Recent blocks list must have the same element structure."""
|
||||
path = "/api/blocks"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show(
|
||||
"GET", path,
|
||||
f"({len(b)} blocks, {b[-1]['height']}-{b[0]['height']})" if b else "[]",
|
||||
f"({len(m)} blocks, {m[-1]['height']}-{m[0]['height']})" if m else "[]",
|
||||
)
|
||||
assert len(b) > 0
|
||||
assert_same_structure(b, m)
|
||||
|
||||
|
||||
def test_blocks_recent_ordering(brk):
|
||||
"""Returned blocks must be ordered tip-first by strictly decreasing height."""
|
||||
b = brk.get_json("/api/blocks")
|
||||
heights = [blk["height"] for blk in b]
|
||||
show("GET", "/api/blocks", f"heights={heights[:5]}...", "—")
|
||||
assert heights == sorted(heights, reverse=True), (
|
||||
f"blocks are not strictly tip-first: {heights}"
|
||||
)
|
||||
assert len(set(heights)) == len(heights), "duplicate heights in /api/blocks"
|
||||
|
||||
|
||||
def test_blocks_recent_count(brk):
|
||||
"""mempool.space returns up to 15 blocks; brk should match that contract."""
|
||||
b = brk.get_json("/api/blocks")
|
||||
show("GET", "/api/blocks", f"({len(b)} blocks)", "—")
|
||||
assert 1 <= len(b) <= 15, f"unexpected block count: {len(b)}"
|
||||
@@ -0,0 +1,37 @@
|
||||
"""GET /api/blocks/tip/hash"""
|
||||
|
||||
from _lib import show
|
||||
|
||||
|
||||
def test_blocks_tip_hash_format(brk, mempool):
|
||||
"""Tip hash must be a valid 64-char hex string on both servers."""
|
||||
path = "/api/blocks/tip/hash"
|
||||
b = brk.get_text(path)
|
||||
m = mempool.get_text(path)
|
||||
show("GET", path, b, m)
|
||||
assert len(b) == 64 and all(c in "0123456789abcdef" for c in b.lower())
|
||||
assert len(m) == 64 and all(c in "0123456789abcdef" for c in m.lower())
|
||||
|
||||
|
||||
def test_blocks_tip_hash_matches_height(brk):
|
||||
"""`tip/hash` must equal `block-height/{tip_height}`."""
|
||||
h = int(brk.get_text("/api/blocks/tip/height"))
|
||||
by_height = brk.get_text(f"/api/block-height/{h}")
|
||||
tip_hash = brk.get_text("/api/blocks/tip/hash")
|
||||
show("GET", "/api/blocks/tip/hash", tip_hash, by_height)
|
||||
# Allow a one-block race if a new block landed between the two fetches.
|
||||
if tip_hash != by_height:
|
||||
h2 = int(brk.get_text("/api/blocks/tip/height"))
|
||||
assert h2 != h or tip_hash == by_height, (
|
||||
f"tip/hash={tip_hash} but block-height/{h}={by_height}"
|
||||
)
|
||||
|
||||
|
||||
def test_blocks_tip_hash_matches_recent(brk):
|
||||
"""`tip/hash` must equal the first hash in `/api/blocks`."""
|
||||
tip_hash = brk.get_text("/api/blocks/tip/hash")
|
||||
blocks = brk.get_json("/api/blocks")
|
||||
show("GET", "/api/blocks/tip/hash", tip_hash, blocks[0]["id"])
|
||||
assert blocks and blocks[0]["id"] == tip_hash, (
|
||||
f"tip/hash={tip_hash} but /api/blocks[0].id={blocks[0].get('id')}"
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""GET /api/blocks/tip/height"""
|
||||
|
||||
from _lib import show
|
||||
|
||||
|
||||
def test_blocks_tip_height_close(brk, mempool):
|
||||
"""Tip heights must be within a few blocks of each other."""
|
||||
path = "/api/blocks/tip/height"
|
||||
b = int(brk.get_text(path))
|
||||
m = int(mempool.get_text(path))
|
||||
show("GET", path, b, m)
|
||||
assert abs(b - m) <= 3, f"Tip heights differ by {abs(b - m)}: brk={b}, mempool={m}"
|
||||
|
||||
|
||||
def test_blocks_tip_height_resolves_to_hash(brk):
|
||||
"""`tip/height` must resolve to a valid hash via `block-height/{tip}`."""
|
||||
h = int(brk.get_text("/api/blocks/tip/height"))
|
||||
bh = brk.get_text(f"/api/block-height/{h}")
|
||||
show("GET", "/api/blocks/tip/height", h, bh)
|
||||
assert len(bh) == 64 and all(c in "0123456789abcdef" for c in bh.lower()), (
|
||||
f"block-height/{h} returned non-hash: {bh!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_blocks_tip_height_matches_recent(brk):
|
||||
"""`tip/height` must equal the first element's height in `/api/blocks`."""
|
||||
h = int(brk.get_text("/api/blocks/tip/height"))
|
||||
blocks = brk.get_json("/api/blocks")
|
||||
show("GET", "/api/blocks/tip/height", h, blocks[0]["height"])
|
||||
assert blocks and blocks[0]["height"] == h, (
|
||||
f"tip/height={h} but /api/blocks[0].height={blocks[0]['height']}"
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""GET /api/v1/blocks/{height}"""
|
||||
|
||||
from _lib import assert_same_values, show
|
||||
|
||||
|
||||
def test_blocks_v1_from_height(brk, mempool, block):
|
||||
"""v1 blocks from a confirmed height — all values must match."""
|
||||
path = f"/api/v1/blocks/{block.height}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
|
||||
assert len(b) == len(m)
|
||||
if b and m:
|
||||
assert_same_values(b[0], m[0])
|
||||
@@ -0,0 +1,31 @@
|
||||
"""GET /api/v1/blocks (with extras, no height)"""
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
def test_blocks_v1_recent_structure(brk, mempool):
|
||||
"""Recent v1 blocks (with extras) must have the same structure."""
|
||||
path = "/api/v1/blocks"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
|
||||
assert len(b) > 0
|
||||
assert_same_structure(b, m)
|
||||
|
||||
|
||||
def test_blocks_v1_recent_ordering(brk):
|
||||
"""v1 blocks must also be tip-first."""
|
||||
b = brk.get_json("/api/v1/blocks")
|
||||
heights = [blk["height"] for blk in b]
|
||||
show("GET", "/api/v1/blocks", f"heights={heights[:5]}...", "—")
|
||||
assert heights == sorted(heights, reverse=True), (
|
||||
f"v1 blocks are not strictly tip-first: {heights}"
|
||||
)
|
||||
|
||||
|
||||
def test_blocks_v1_recent_has_extras(brk):
|
||||
"""Each v1 block must carry the extras envelope (v1 distinguishes itself from /api/blocks)."""
|
||||
b = brk.get_json("/api/v1/blocks")
|
||||
show("GET", "/api/v1/blocks", f"({len(b)} blocks)", "—")
|
||||
assert b
|
||||
assert "extras" in b[0], f"v1 blocks element missing 'extras': {list(b[0].keys())}"
|
||||
158
packages/brk_client/tests/mempool_compat/check_endpoints.py
Normal file
158
packages/brk_client/tests/mempool_compat/check_endpoints.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Global registry checks for the mempool.space compatibility suite.
|
||||
|
||||
These tests don't poke individual endpoints — they verify the *set* of
|
||||
endpoints brk exposes matches the registry in `_endpoints.py`. If
|
||||
mempool.space adds a new endpoint, classify it as covered or skipped here so
|
||||
this file fails loudly on the next CI run.
|
||||
|
||||
Checks:
|
||||
1. Every `covered` endpoint actually appears in brk's live `/openapi.json`.
|
||||
2. Every `covered` endpoint has a test file at its declared `test_file` path.
|
||||
3. Every `skipped` endpoint is NOT exposed by brk (proves the skip is real).
|
||||
4. Every brk path that *looks* like a mempool path is classified — no
|
||||
orphan routes that we silently added without registering.
|
||||
5. Brk extensions listed in `BRK_EXTENSIONS` actually exist on brk.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from _endpoints import (
|
||||
BRK_EXTENSIONS,
|
||||
MEMPOOL_ENDPOINTS,
|
||||
Endpoint,
|
||||
covered_endpoints,
|
||||
skipped_endpoints,
|
||||
)
|
||||
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
|
||||
|
||||
# ---- Brk-side discovery -------------------------------------------------
|
||||
|
||||
|
||||
_HTTP_METHODS = {"get", "post", "put", "delete", "patch", "head", "options"}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def brk_routes(brk) -> set[tuple[str, str]]:
|
||||
"""Every `(METHOD, /api/...)` pair brk reports in its OpenAPI spec."""
|
||||
spec = brk.get_json("/openapi.json")
|
||||
return {
|
||||
(method.upper(), path)
|
||||
for path, ops in spec["paths"].items()
|
||||
if path.startswith("/api")
|
||||
for method in ops.keys()
|
||||
if method.lower() in _HTTP_METHODS
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def brk_paths(brk_routes) -> set[str]:
|
||||
"""Just the path strings (collapsed across methods)."""
|
||||
return {path for _, path in brk_routes}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def brk_compat_paths(brk_paths) -> set[str]:
|
||||
"""Brk paths that are part of the mempool.space compat surface.
|
||||
|
||||
Strips out brk-only namespaces (series, metrics, urpd, vecs, server, etc.)
|
||||
so we're left with paths that belong in the registry.
|
||||
"""
|
||||
brk_only_prefixes = (
|
||||
"/api/series",
|
||||
"/api/metric",
|
||||
"/api/metrics",
|
||||
"/api/urpd",
|
||||
"/api/vecs",
|
||||
"/api/server",
|
||||
"/api.json",
|
||||
)
|
||||
return {p for p in brk_paths if not p.startswith(brk_only_prefixes)}
|
||||
|
||||
|
||||
# ---- Checks -------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint", covered_endpoints(), ids=lambda e: e.path)
|
||||
def test_covered_endpoint_exposed_by_brk(brk_routes, endpoint: Endpoint):
|
||||
"""Every covered endpoint must appear in brk's OpenAPI under the same method."""
|
||||
assert (endpoint.method, endpoint.path) in brk_routes, (
|
||||
f"{endpoint.method} {endpoint.path} is marked covered in _endpoints.py "
|
||||
f"but brk's /openapi.json doesn't expose it"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint", covered_endpoints(), ids=lambda e: e.path)
|
||||
def test_covered_endpoint_has_test_file(endpoint: Endpoint):
|
||||
"""Every covered endpoint must have a test file at its declared path."""
|
||||
assert endpoint.test_file is not None, (
|
||||
f"{endpoint.path} is covered but has no test_file"
|
||||
)
|
||||
file = HERE / endpoint.test_file
|
||||
assert file.is_file(), (
|
||||
f"{endpoint.path} declares test_file={endpoint.test_file!r}, "
|
||||
f"but {file} doesn't exist"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint", skipped_endpoints(), ids=lambda e: e.path)
|
||||
def test_skipped_endpoint_not_exposed_by_brk(brk_routes, endpoint: Endpoint):
|
||||
"""Every skipped endpoint must be absent from brk's OpenAPI for that method."""
|
||||
assert (endpoint.method, endpoint.path) not in brk_routes, (
|
||||
f"{endpoint.method} {endpoint.path} is marked skipped "
|
||||
f"({endpoint.skip_reason!r}) but brk now exposes it — please update "
|
||||
f"_endpoints.py to mark it covered and add a test"
|
||||
)
|
||||
|
||||
|
||||
def test_no_orphan_brk_routes(brk_compat_paths):
|
||||
"""Every brk compat path must be classified in the registry.
|
||||
|
||||
If this fails, brk has a route that looks like a mempool.space endpoint
|
||||
but isn't tracked. Either add it to MEMPOOL_ENDPOINTS (covered + a test)
|
||||
or to BRK_EXTENSIONS (brk-only with a one-line justification in source).
|
||||
"""
|
||||
registry_paths = {e.path for e in MEMPOOL_ENDPOINTS}
|
||||
extension_paths = set(BRK_EXTENSIONS)
|
||||
known = registry_paths | extension_paths
|
||||
orphans = brk_compat_paths - known
|
||||
assert not orphans, (
|
||||
f"Brk exposes {len(orphans)} unclassified mempool-style routes:\n "
|
||||
+ "\n ".join(sorted(orphans))
|
||||
+ "\nClassify each in mempool_compat/_endpoints.py."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", BRK_EXTENSIONS, ids=lambda p: p)
|
||||
def test_brk_extension_actually_exists(brk_paths, path: str):
|
||||
"""Each path in BRK_EXTENSIONS must exist in brk's OpenAPI.
|
||||
|
||||
Stale entries get caught here so the list stays accurate.
|
||||
"""
|
||||
assert path in brk_paths, (
|
||||
f"{path} is listed in BRK_EXTENSIONS but brk's /openapi.json doesn't "
|
||||
f"expose it — remove it from _endpoints.py"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_has_no_duplicates():
|
||||
"""Each (method, path) pair appears at most once in MEMPOOL_ENDPOINTS."""
|
||||
seen: set[tuple[str, str]] = set()
|
||||
dups: list[tuple[str, str]] = []
|
||||
for e in MEMPOOL_ENDPOINTS:
|
||||
key = (e.method, e.path)
|
||||
if key in seen:
|
||||
dups.append(key)
|
||||
seen.add(key)
|
||||
assert not dups, f"Duplicate registry entries: {dups}"
|
||||
|
||||
|
||||
def test_skipped_endpoints_have_reason():
|
||||
"""Every skipped endpoint must include a skip_reason."""
|
||||
bad = [e for e in skipped_endpoints() if not e.skip_reason]
|
||||
assert not bad, f"Skipped endpoints missing skip_reason: {[e.path for e in bad]}"
|
||||
216
packages/brk_client/tests/mempool_compat/conftest.py
Normal file
216
packages/brk_client/tests/mempool_compat/conftest.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Shared fixtures for mempool.space compatibility tests.
|
||||
|
||||
Helper functions live in `_lib.py`; this file holds only fixtures so pytest
|
||||
can discover them throughout the subtree. Each subtree test imports helpers
|
||||
with `from _lib import ...` — the conftest puts this directory on sys.path.
|
||||
|
||||
Usage:
|
||||
cd packages/brk_client
|
||||
uv run pytest tests/mempool_compat -sv # all
|
||||
uv run pytest tests/mempool_compat/blocks -sv # one category
|
||||
uv run pytest tests/mempool_compat/blocks/test_block.py -sv # one endpoint
|
||||
BRK_URL=http://host:port uv run pytest tests/mempool_compat -sv # custom server
|
||||
|
||||
Environment variables:
|
||||
BRK_URL brk server base URL (default: http://localhost:3110)
|
||||
MEMPOOL_URL mempool.space base URL (default: https://mempool.space)
|
||||
RATE_LIMIT seconds between mempool.space requests (default: 0.5)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
# Make `_lib` and `_endpoints` importable from any nested test file.
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
BRK_BASE = os.environ.get("BRK_URL", "http://localhost:3110")
|
||||
MEMPOOL_BASE = os.environ.get("MEMPOOL_URL", "https://mempool.space")
|
||||
RATE_LIMIT = float(os.environ.get("RATE_LIMIT", "0.5"))
|
||||
|
||||
|
||||
class ApiClient:
|
||||
"""HTTP client for a single API server with optional rate limiting."""
|
||||
|
||||
def __init__(self, base_url: str, name: str, rate_limit: float = 0.0):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.name = name
|
||||
self.rate_limit = rate_limit
|
||||
self._last_request = 0.0
|
||||
self.session = requests.Session()
|
||||
self.session.headers["User-Agent"] = "brk-compat-test/1.0"
|
||||
|
||||
def _wait(self):
|
||||
if self.rate_limit > 0:
|
||||
elapsed = time.monotonic() - self._last_request
|
||||
if elapsed < self.rate_limit:
|
||||
time.sleep(self.rate_limit - elapsed)
|
||||
self._last_request = time.monotonic()
|
||||
|
||||
def get(self, path: str, params=None, timeout: int = 30) -> requests.Response:
|
||||
self._wait()
|
||||
url = f"{self.base_url}{path}"
|
||||
for _ in range(3):
|
||||
resp = self.session.get(url, params=params, timeout=timeout)
|
||||
if resp.status_code == 429:
|
||||
wait = int(resp.headers.get("Retry-After", 5))
|
||||
time.sleep(wait)
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
|
||||
def get_raw(self, path: str, params=None, timeout: int = 30) -> requests.Response:
|
||||
"""Like `get` but does not raise on non-2xx — returns the raw response."""
|
||||
self._wait()
|
||||
url = f"{self.base_url}{path}"
|
||||
return self.session.get(url, params=params, timeout=timeout)
|
||||
|
||||
def get_json(self, path: str, params=None, timeout: int = 30) -> Any:
|
||||
return self.get(path, params=params, timeout=timeout).json()
|
||||
|
||||
def get_text(self, path: str, params=None, timeout: int = 30) -> str:
|
||||
return self.get(path, params=params, timeout=timeout).text
|
||||
|
||||
def get_bytes(self, path: str, params=None, timeout: int = 30) -> bytes:
|
||||
return self.get(path, params=params, timeout=timeout).content
|
||||
|
||||
|
||||
# Absolute heights for well-known eras + relative depths for recent blocks.
|
||||
# Covers: genesis-era, early, mid, post-halving, taproot-era, recent, near-tip.
|
||||
FIXED_HEIGHTS = [100, 100_000, 400_000, 630_000, 800_000]
|
||||
RELATIVE_DEPTHS = [1000, 100, 10]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlockData:
|
||||
"""A discovered block with associated txids."""
|
||||
|
||||
height: int
|
||||
hash: str
|
||||
txid: str
|
||||
coinbase_txid: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiveData:
|
||||
"""Live blockchain data discovered at session start."""
|
||||
|
||||
tip_height: int
|
||||
tip_hash: str
|
||||
blocks: list # list[BlockData] — multiple depths for parametrized tests
|
||||
addresses: dict # dict[str, str] — keyed by scriptpubkey_type
|
||||
stable_height: int
|
||||
stable_hash: str
|
||||
stable_block: dict
|
||||
sample_txid: str
|
||||
coinbase_txid: str
|
||||
sample_address: str
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def brk():
|
||||
return ApiClient(BRK_BASE, "brk")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def mempool():
|
||||
return ApiClient(MEMPOOL_BASE, "mempool.space", rate_limit=RATE_LIMIT)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def check_servers(brk, mempool):
|
||||
"""Fail fast if either server is unreachable."""
|
||||
try:
|
||||
brk.get("/api/blocks/tip/height")
|
||||
except Exception as e:
|
||||
pytest.exit(f"brk server not reachable at {brk.base_url}: {e}")
|
||||
try:
|
||||
mempool.get("/api/blocks/tip/height")
|
||||
except Exception as e:
|
||||
pytest.exit(f"mempool.space not reachable at {mempool.base_url}: {e}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def live(mempool) -> LiveData:
|
||||
"""Discover live blockchain data once per session.
|
||||
|
||||
Picks blocks at multiple depths and extracts addresses of different
|
||||
scriptpubkey types so parametrized tests cover varied real data.
|
||||
"""
|
||||
tip_height = int(mempool.get_text("/api/blocks/tip/height"))
|
||||
tip_hash = mempool.get_text("/api/blocks/tip/hash")
|
||||
|
||||
heights = FIXED_HEIGHTS + [tip_height - d for d in RELATIVE_DEPTHS]
|
||||
heights.sort()
|
||||
|
||||
blocks: list[BlockData] = []
|
||||
addresses: dict[str, str] = {}
|
||||
|
||||
for h in heights:
|
||||
bh = mempool.get_text(f"/api/block-height/{h}")
|
||||
txids = mempool.get_json(f"/api/block/{bh}/txids")
|
||||
coinbase = txids[0]
|
||||
sample = txids[min(1, len(txids) - 1)]
|
||||
blocks.append(BlockData(height=h, hash=bh, txid=sample, coinbase_txid=coinbase))
|
||||
|
||||
if len(addresses) < 8:
|
||||
tx = mempool.get_json(f"/api/tx/{sample}")
|
||||
for vout in tx.get("vout", []):
|
||||
atype = vout.get("scriptpubkey_type")
|
||||
addr = vout.get("scriptpubkey_address")
|
||||
if addr and atype and atype not in addresses:
|
||||
addresses[atype] = addr
|
||||
|
||||
stable = blocks[0]
|
||||
stable_block = mempool.get_json(f"/api/block/{stable.hash}")
|
||||
sample_address = next(iter(addresses.values()), "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
|
||||
|
||||
data = LiveData(
|
||||
tip_height=tip_height,
|
||||
tip_hash=tip_hash,
|
||||
blocks=blocks,
|
||||
addresses=addresses,
|
||||
stable_height=stable.height,
|
||||
stable_hash=stable.hash,
|
||||
stable_block=stable_block,
|
||||
sample_txid=stable.txid,
|
||||
coinbase_txid=stable.coinbase_txid,
|
||||
sample_address=sample_address,
|
||||
)
|
||||
|
||||
print(f"\n{'=' * 70}")
|
||||
print(f" LIVE TEST DATA (from {MEMPOOL_BASE})")
|
||||
print(f"{'=' * 70}")
|
||||
print(f" tip {data.tip_height} {data.tip_hash[:20]}...")
|
||||
for i, b in enumerate(blocks):
|
||||
print(f" block[{i}] {b.height} {b.hash[:20]}... tx={b.txid[:16]}...")
|
||||
for atype, addr in addresses.items():
|
||||
print(f" addr {atype:12s} {addr}")
|
||||
print(f"{'=' * 70}\n")
|
||||
return data
|
||||
|
||||
|
||||
@pytest.fixture(params=range(8), ids=[
|
||||
"h100", "h100k", "h400k", "h630k", "h800k", "recent1k", "recent100", "recent10",
|
||||
])
|
||||
def block(request, live):
|
||||
"""One BlockData per id — skip if not discovered for this session."""
|
||||
i = request.param
|
||||
if i >= len(live.blocks):
|
||||
pytest.skip("block not discovered")
|
||||
return live.blocks[i]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def live_addrs(live):
|
||||
"""All dynamically discovered addresses, keyed by scriptpubkey_type."""
|
||||
return list(live.addresses.items())
|
||||
@@ -0,0 +1,27 @@
|
||||
"""GET /api/v1/fees/mempool-blocks"""
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
def test_fees_mempool_blocks(brk, mempool):
|
||||
"""Projected mempool blocks must have the same element structure."""
|
||||
path = "/api/v1/fees/mempool-blocks"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
assert len(b) > 0
|
||||
if b and m:
|
||||
assert_same_structure(b[0], m[0])
|
||||
|
||||
|
||||
def test_fees_mempool_blocks_fee_range(brk, mempool):
|
||||
"""Each projected block must have a 7-element feeRange."""
|
||||
path = "/api/v1/fees/mempool-blocks"
|
||||
for label, client in [("brk", brk), ("mempool", mempool)]:
|
||||
blocks = client.get_json(path)
|
||||
for i, block in enumerate(blocks[:3]):
|
||||
assert "feeRange" in block, f"{label} block {i} missing feeRange"
|
||||
assert len(block["feeRange"]) == 7, (
|
||||
f"{label} block {i} feeRange has {len(block['feeRange'])} items, expected 7"
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""GET /api/v1/fees/precise"""
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
EXPECTED_FEE_KEYS = [
|
||||
"fastestFee", "halfHourFee", "hourFee", "economyFee", "minimumFee",
|
||||
]
|
||||
|
||||
|
||||
def test_fees_precise_structure(brk, mempool):
|
||||
"""Precise fees must have the same structure as recommended."""
|
||||
path = "/api/v1/fees/precise"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
for key in EXPECTED_FEE_KEYS:
|
||||
assert key in b
|
||||
|
||||
|
||||
def test_fees_precise_ordering(brk, mempool):
|
||||
"""Precise fee tiers must be ordered: fastest >= halfHour >= hour >= economy >= minimum."""
|
||||
path = "/api/v1/fees/precise"
|
||||
for label, client in [("brk", brk), ("mempool", mempool)]:
|
||||
d = client.get_json(path)
|
||||
assert d["fastestFee"] >= d["halfHourFee"] >= d["hourFee"], (
|
||||
f"{label}: precise fee ordering violated {d}"
|
||||
)
|
||||
assert d["hourFee"] >= d["economyFee"] >= d["minimumFee"], (
|
||||
f"{label}: precise fee ordering violated {d}"
|
||||
)
|
||||
|
||||
|
||||
def test_fees_precise_numeric(brk):
|
||||
"""Each tier in /precise must be a non-negative number."""
|
||||
d = brk.get_json("/api/v1/fees/precise")
|
||||
show("GET", "/api/v1/fees/precise", d, "—")
|
||||
for key in EXPECTED_FEE_KEYS:
|
||||
v = d[key]
|
||||
assert isinstance(v, (int, float)), f"{key} not numeric: {type(v).__name__}"
|
||||
assert v >= 0, f"{key} is negative: {v}"
|
||||
@@ -0,0 +1,33 @@
|
||||
"""GET /api/v1/fees/recommended"""
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
EXPECTED_FEE_KEYS = [
|
||||
"fastestFee", "halfHourFee", "hourFee", "economyFee", "minimumFee",
|
||||
]
|
||||
|
||||
|
||||
def test_fees_recommended(brk, mempool):
|
||||
"""Recommended fees must have the same keys and numeric types."""
|
||||
path = "/api/v1/fees/recommended"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
for key in EXPECTED_FEE_KEYS:
|
||||
assert key in b, f"brk missing '{key}'"
|
||||
assert isinstance(b[key], (int, float)), f"'{key}' is not numeric: {type(b[key])}"
|
||||
|
||||
|
||||
def test_fees_recommended_ordering(brk, mempool):
|
||||
"""Fee tiers must be ordered: fastest >= halfHour >= hour >= economy >= minimum."""
|
||||
path = "/api/v1/fees/recommended"
|
||||
for label, client in [("brk", brk), ("mempool", mempool)]:
|
||||
d = client.get_json(path)
|
||||
assert d["fastestFee"] >= d["halfHourFee"] >= d["hourFee"], (
|
||||
f"{label}: fee ordering violated {d}"
|
||||
)
|
||||
assert d["hourFee"] >= d["economyFee"] >= d["minimumFee"], (
|
||||
f"{label}: fee ordering violated {d}"
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""GET /api/v1/difficulty-adjustment"""
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
DIFFICULTY_KEYS = [
|
||||
"progressPercent", "difficultyChange", "estimatedRetargetDate",
|
||||
"remainingBlocks", "remainingTime", "previousRetarget",
|
||||
"previousTime", "nextRetargetHeight", "timeAvg",
|
||||
"adjustedTimeAvg", "timeOffset", "expectedBlocks",
|
||||
]
|
||||
|
||||
|
||||
def test_difficulty_adjustment(brk, mempool):
|
||||
"""Difficulty adjustment must have the same structure."""
|
||||
path = "/api/v1/difficulty-adjustment"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
for key in DIFFICULTY_KEYS:
|
||||
assert key in b, f"brk missing '{key}'"
|
||||
|
||||
|
||||
def test_difficulty_adjustment_values_sane(brk, mempool):
|
||||
"""Progress must be 0-100 %, remaining blocks must be 0-2016."""
|
||||
path = "/api/v1/difficulty-adjustment"
|
||||
for label, client in [("brk", brk), ("mempool", mempool)]:
|
||||
d = client.get_json(path)
|
||||
assert 0 <= d["progressPercent"] <= 100, (
|
||||
f"{label} progressPercent out of range: {d['progressPercent']}"
|
||||
)
|
||||
assert 0 <= d["remainingBlocks"] <= 2016, (
|
||||
f"{label} remainingBlocks out of range: {d['remainingBlocks']}"
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""GET /api/v1/historical-price (with and without timestamp)"""
|
||||
|
||||
import pytest
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
# Well-known timestamps from different eras
|
||||
HISTORICAL_TIMESTAMPS = [
|
||||
1231006505, # genesis block (2009-01-03)
|
||||
1354116278, # block 210000 — first halving (2012-11-28)
|
||||
1468082773, # block 420000 — second halving (2016-07-09)
|
||||
1588788036, # block 630000 — third halving (2020-05-11)
|
||||
1713571767, # block 840000 — fourth halving (2024-04-20)
|
||||
]
|
||||
|
||||
|
||||
def test_historical_price(brk, mempool):
|
||||
"""Historical price must have the same structure."""
|
||||
path = "/api/v1/historical-price"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m, max_lines=15)
|
||||
assert_same_structure(b, m)
|
||||
assert "prices" in b
|
||||
assert isinstance(b["prices"], list)
|
||||
|
||||
|
||||
def test_historical_price_at_block_timestamps(brk, mempool, live):
|
||||
"""Historical price at each discovered block's timestamp must match structure."""
|
||||
for block in live.blocks:
|
||||
info = brk.get_json(f"/api/block/{block.hash}")
|
||||
ts = info["timestamp"]
|
||||
path = f"/api/v1/historical-price?timestamp={ts}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
assert "prices" in b
|
||||
assert len(b["prices"]) > 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ts", HISTORICAL_TIMESTAMPS, ids=[
|
||||
"genesis", "halving1", "halving2", "halving3", "halving4",
|
||||
])
|
||||
def test_historical_price_at_era(brk, mempool, ts):
|
||||
"""Historical price at well-known timestamps must match structure."""
|
||||
path = f"/api/v1/historical-price?timestamp={ts}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
assert "prices" in b
|
||||
assert len(b["prices"]) > 0
|
||||
@@ -0,0 +1,22 @@
|
||||
"""GET /api/v1/prices"""
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
def test_prices(brk, mempool):
|
||||
"""Current price must have the same structure."""
|
||||
path = "/api/v1/prices"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
assert "USD" in b
|
||||
assert "time" in b
|
||||
|
||||
|
||||
def test_prices_positive(brk, mempool):
|
||||
"""USD price must be a positive number on both servers."""
|
||||
path = "/api/v1/prices"
|
||||
for label, client in [("brk", brk), ("mempool", mempool)]:
|
||||
d = client.get_json(path)
|
||||
assert d["USD"] > 0, f"{label} USD price is not positive: {d['USD']}"
|
||||
@@ -0,0 +1,23 @@
|
||||
"""GET /api/mempool"""
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
def test_mempool_info(brk, mempool):
|
||||
"""Mempool stats must have the same keys and types."""
|
||||
path = "/api/mempool"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m, max_lines=15)
|
||||
assert_same_structure(b, m)
|
||||
assert isinstance(b["count"], int)
|
||||
assert isinstance(b["vsize"], int)
|
||||
|
||||
|
||||
def test_mempool_info_positive(brk, mempool):
|
||||
"""Both servers must report a non-empty mempool."""
|
||||
path = "/api/mempool"
|
||||
for label, client in [("brk", brk), ("mempool", mempool)]:
|
||||
d = client.get_json(path)
|
||||
assert d["count"] > 0, f"{label} mempool count is 0"
|
||||
assert d["vsize"] > 0, f"{label} mempool vsize is 0"
|
||||
@@ -0,0 +1,25 @@
|
||||
"""GET /api/mempool/recent"""
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
def test_mempool_recent(brk, mempool):
|
||||
"""Recent mempool txs must have the same element structure."""
|
||||
path = "/api/mempool/recent"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
assert len(b) > 0
|
||||
if b and m:
|
||||
assert_same_structure(b[0], m[0])
|
||||
|
||||
|
||||
def test_mempool_recent_fields(brk, mempool):
|
||||
"""Each recent tx must have txid, fee, vsize, value."""
|
||||
path = "/api/mempool/recent"
|
||||
for label, client in [("brk", brk), ("mempool", mempool)]:
|
||||
txs = client.get_json(path)
|
||||
for tx in txs[:3]:
|
||||
for key in ["txid", "fee", "vsize", "value"]:
|
||||
assert key in tx, f"{label} recent tx missing '{key}': {tx}"
|
||||
@@ -0,0 +1,46 @@
|
||||
"""GET /api/mempool/txids"""
|
||||
|
||||
from _lib import show
|
||||
|
||||
|
||||
HEX = set("0123456789abcdef")
|
||||
|
||||
|
||||
def test_mempool_txids_basic(brk, mempool):
|
||||
"""Txid list must be a non-empty array of strings on both servers."""
|
||||
path = "/api/mempool/txids"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} txids)", f"({len(m)} txids)")
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
assert len(b) > 0, "brk mempool has no txids"
|
||||
assert isinstance(b[0], str) and len(b[0]) == 64
|
||||
|
||||
|
||||
def test_mempool_txids_format(brk):
|
||||
"""Every txid in brk's mempool list must be a 64-char lowercase hex string."""
|
||||
b = brk.get_json("/api/mempool/txids")
|
||||
show("GET", "/api/mempool/txids", f"({len(b)} txids)", "—")
|
||||
bad = [t for t in b if not (isinstance(t, str) and len(t) == 64 and set(t.lower()) <= HEX)]
|
||||
assert not bad, f"{len(bad)} malformed txid(s), e.g. {bad[0] if bad else None!r}"
|
||||
|
||||
|
||||
def test_mempool_txids_unique(brk):
|
||||
"""Brk's mempool txid list must not contain duplicates."""
|
||||
b = brk.get_json("/api/mempool/txids")
|
||||
show("GET", "/api/mempool/txids", f"({len(b)} txids)", "—")
|
||||
assert len(b) == len(set(b)), (
|
||||
f"duplicate txids: {len(b) - len(set(b))} duplicates out of {len(b)}"
|
||||
)
|
||||
|
||||
|
||||
def test_mempool_txids_count_matches_summary(brk):
|
||||
"""`/api/mempool/txids` length must match `/api/mempool`'s `count` field."""
|
||||
txids = brk.get_json("/api/mempool/txids")
|
||||
summary = brk.get_json("/api/mempool")
|
||||
show("GET", "/api/mempool/txids", f"len={len(txids)}", f"count={summary.get('count')}")
|
||||
# Allow a small drift (1-2) since the mempool is updated asynchronously
|
||||
# between the two fetches.
|
||||
assert abs(len(txids) - summary["count"]) <= 5, (
|
||||
f"txids={len(txids)} vs /api/mempool.count={summary['count']}"
|
||||
)
|
||||
17
packages/brk_client/tests/mempool_compat/mining/conftest.py
Normal file
17
packages/brk_client/tests/mempool_compat/mining/conftest.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Mining-specific fixtures shared by every mining test in this folder."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pool_slugs(mempool):
|
||||
"""Top 3 active pool slugs from the last week."""
|
||||
data = mempool.get_json("/api/v1/mining/pools/1w")
|
||||
pools = data.get("pools", []) if isinstance(data, dict) else []
|
||||
slugs = [p["slug"] for p in pools if p.get("blockCount", 0) > 0][:3]
|
||||
return slugs or ["foundryusa"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pool_slug(pool_slugs):
|
||||
return pool_slugs[0]
|
||||
@@ -0,0 +1,15 @@
|
||||
"""GET /api/v1/mining/blocks/fee-rates/{time_period}"""
|
||||
|
||||
import pytest
|
||||
|
||||
from _lib import assert_same_structure, show, summary
|
||||
|
||||
|
||||
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y"])
|
||||
def test_mining_blocks_fee_rates(brk, mempool, period):
|
||||
"""Block fee-rate percentiles must have the same element structure."""
|
||||
path = f"/api/v1/mining/blocks/fee-rates/{period}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, summary(b), summary(m))
|
||||
assert_same_structure(b, m)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""GET /api/v1/mining/blocks/fees/{time_period}"""
|
||||
|
||||
import pytest
|
||||
|
||||
from _lib import assert_same_structure, show, summary
|
||||
|
||||
|
||||
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y"])
|
||||
def test_mining_blocks_fees(brk, mempool, period):
|
||||
"""Average block fees must have the same element structure."""
|
||||
path = f"/api/v1/mining/blocks/fees/{period}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, summary(b), summary(m))
|
||||
assert_same_structure(b, m)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""GET /api/v1/mining/blocks/rewards/{time_period}"""
|
||||
|
||||
import pytest
|
||||
|
||||
from _lib import assert_same_structure, show, summary
|
||||
|
||||
|
||||
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y"])
|
||||
def test_mining_blocks_rewards(brk, mempool, period):
|
||||
"""Average block rewards must have the same element structure."""
|
||||
path = f"/api/v1/mining/blocks/rewards/{period}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, summary(b), summary(m))
|
||||
assert_same_structure(b, m)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""GET /api/v1/mining/blocks/sizes-weights/{time_period}"""
|
||||
|
||||
import pytest
|
||||
|
||||
from _lib import assert_same_structure, show, summary
|
||||
|
||||
|
||||
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y"])
|
||||
def test_mining_blocks_sizes_weights(brk, mempool, period):
|
||||
"""Block sizes and weights must have the same structure."""
|
||||
path = f"/api/v1/mining/blocks/sizes-weights/{period}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, summary(b), summary(m))
|
||||
assert_same_structure(b, m)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""GET /api/v1/mining/blocks/timestamp/{timestamp}"""
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
def test_mining_blocks_timestamp(brk, mempool, live):
|
||||
"""Block lookup by timestamp must have the same structure for various eras."""
|
||||
for block in live.blocks:
|
||||
info = brk.get_json(f"/api/block/{block.hash}")
|
||||
ts = info["timestamp"]
|
||||
path = f"/api/v1/mining/blocks/timestamp/{ts}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""GET /api/v1/mining/difficulty-adjustments/{time_period}"""
|
||||
|
||||
import pytest
|
||||
|
||||
from _lib import assert_same_structure, show, summary
|
||||
|
||||
|
||||
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y"])
|
||||
def test_mining_difficulty_adjustments(brk, mempool, period):
|
||||
"""Historical difficulty adjustments must have the same structure."""
|
||||
path = f"/api/v1/mining/difficulty-adjustments/{period}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, summary(b), summary(m))
|
||||
assert_same_structure(b, m)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""GET /api/v1/mining/hashrate/{time_period}"""
|
||||
|
||||
import pytest
|
||||
|
||||
from _lib import assert_same_structure, show, summary
|
||||
|
||||
|
||||
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y"])
|
||||
def test_mining_hashrate(brk, mempool, period):
|
||||
"""Network hashrate + difficulty must have the same structure."""
|
||||
path = f"/api/v1/mining/hashrate/{period}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, summary(b), summary(m))
|
||||
assert_same_structure(b, m)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""GET /api/v1/mining/hashrate/pools/{time_period}"""
|
||||
|
||||
import pytest
|
||||
|
||||
from _lib import assert_same_structure, show, summary
|
||||
|
||||
|
||||
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "1y"])
|
||||
def test_mining_hashrate_pools(brk, mempool, period):
|
||||
"""Per-pool hashrate must have the same structure."""
|
||||
path = f"/api/v1/mining/hashrate/pools/{period}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, summary(b), summary(m))
|
||||
assert_same_structure(b, m)
|
||||
13
packages/brk_client/tests/mempool_compat/mining/test_pool.py
Normal file
13
packages/brk_client/tests/mempool_compat/mining/test_pool.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""GET /api/v1/mining/pool/{slug}"""
|
||||
|
||||
from _lib import assert_same_structure, show, summary
|
||||
|
||||
|
||||
def test_mining_pool_detail(brk, mempool, pool_slugs):
|
||||
"""Pool detail must have the same structure for top pools."""
|
||||
for slug in pool_slugs:
|
||||
path = f"/api/v1/mining/pool/{slug}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, summary(b), summary(m))
|
||||
assert_same_structure(b, m)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""GET /api/v1/mining/pool/{slug}/blocks"""
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
def test_mining_pool_blocks(brk, mempool, pool_slugs):
|
||||
"""Recent blocks by pool must have the same element structure."""
|
||||
for slug in pool_slugs:
|
||||
path = f"/api/v1/mining/pool/{slug}/blocks"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
if b and m:
|
||||
assert_same_structure(b[0], m[0])
|
||||
@@ -0,0 +1,15 @@
|
||||
"""GET /api/v1/mining/pool/{slug}/blocks/{height}"""
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
def test_mining_pool_blocks_at_height(brk, mempool, pool_slug, live):
|
||||
"""Pool blocks before various heights must have the same element structure."""
|
||||
for block in live.blocks[::2]: # every other block, to keep run-time bounded
|
||||
path = f"/api/v1/mining/pool/{pool_slug}/blocks/{block.height}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
if b and m:
|
||||
assert_same_structure(b[0], m[0])
|
||||
@@ -0,0 +1,13 @@
|
||||
"""GET /api/v1/mining/pool/{slug}/hashrate"""
|
||||
|
||||
from _lib import assert_same_structure, show, summary
|
||||
|
||||
|
||||
def test_mining_pool_hashrate(brk, mempool, pool_slugs):
|
||||
"""Pool hashrate history must have the same structure for top pools."""
|
||||
for slug in pool_slugs:
|
||||
path = f"/api/v1/mining/pool/{slug}/hashrate"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, summary(b), summary(m))
|
||||
assert_same_structure(b, m)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""GET /api/v1/mining/pools"""
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
def test_mining_pools_list_structure(brk, mempool):
|
||||
"""Pool list must have the same element structure."""
|
||||
path = "/api/v1/mining/pools"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show(
|
||||
"GET", path,
|
||||
b[:3] if isinstance(b, list) else b,
|
||||
m[:3] if isinstance(m, list) else m,
|
||||
)
|
||||
assert_same_structure(b, m)
|
||||
|
||||
|
||||
def _pools(data):
|
||||
"""`pools` may live at the root or inside an envelope across versions."""
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return data.get("pools", []) if isinstance(data, dict) else []
|
||||
|
||||
|
||||
def test_mining_pools_list_fields(brk):
|
||||
"""Each pool entry must carry slug and name (period-less endpoint omits stats)."""
|
||||
b = _pools(brk.get_json("/api/v1/mining/pools"))
|
||||
show("GET", "/api/v1/mining/pools", f"({len(b)} pools)", "—")
|
||||
assert b, "no pools in brk's response"
|
||||
required = {"slug", "name"}
|
||||
for p in b[:5]:
|
||||
missing = required - set(p.keys())
|
||||
assert not missing, f"pool {p.get('slug', '?')} missing fields: {missing}"
|
||||
assert isinstance(p["name"], str) and p["name"]
|
||||
|
||||
|
||||
def test_mining_pools_slugs_unique(brk):
|
||||
"""Pool slugs must be unique across the response."""
|
||||
b = _pools(brk.get_json("/api/v1/mining/pools"))
|
||||
slugs = [p["slug"] for p in b]
|
||||
show("GET", "/api/v1/mining/pools", f"({len(slugs)} slugs)", "—")
|
||||
assert len(slugs) == len(set(slugs)), (
|
||||
f"duplicate slugs: {len(slugs) - len(set(slugs))}"
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""GET /api/v1/mining/pools/{time_period}"""
|
||||
|
||||
import pytest
|
||||
|
||||
from _lib import assert_same_structure, show, summary
|
||||
|
||||
|
||||
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"])
|
||||
def test_mining_pools_by_period(brk, mempool, period):
|
||||
"""Pool stats for a time period must have the same structure."""
|
||||
path = f"/api/v1/mining/pools/{period}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, summary(b), summary(m))
|
||||
assert_same_structure(b, m)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""GET /api/v1/mining/reward-stats/{block_count}"""
|
||||
|
||||
import pytest
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
@pytest.mark.parametrize("block_count", [10, 100, 500])
|
||||
def test_mining_reward_stats(brk, mempool, block_count):
|
||||
"""Reward stats must have the same structure."""
|
||||
path = f"/api/v1/mining/reward-stats/{block_count}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""GET /api/v1/cpfp/{txid}"""
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
def test_cpfp(brk, mempool, block):
|
||||
"""CPFP info structure must match for a confirmed tx."""
|
||||
path = f"/api/v1/cpfp/{block.txid}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""POST /api/tx (broadcast)
|
||||
|
||||
We can't actually broadcast a real transaction in a test, so we send a
|
||||
clearly malformed payload and verify both servers reject it with 4xx. The
|
||||
goal is to confirm the endpoint exists and behaves like a transaction
|
||||
broadcaster — not to push live transactions.
|
||||
"""
|
||||
|
||||
from _lib import show
|
||||
|
||||
|
||||
def test_post_tx_invalid_hex(brk, mempool):
|
||||
"""Both servers must reject an obviously invalid hex payload with 4xx."""
|
||||
path = "/api/tx"
|
||||
bad_hex = "deadbeef" # too short to be a valid serialized transaction
|
||||
|
||||
b = brk.session.post(f"{brk.base_url}{path}", data=bad_hex, timeout=15)
|
||||
mempool._wait()
|
||||
m = mempool.session.post(f"{mempool.base_url}{path}", data=bad_hex, timeout=15)
|
||||
show("POST", path, f"brk={b.status_code}", f"mempool={m.status_code}")
|
||||
|
||||
assert 400 <= b.status_code < 500, (
|
||||
f"brk POST /api/tx with garbage should 4xx, got {b.status_code}: {b.text!r}"
|
||||
)
|
||||
assert 400 <= m.status_code < 500, (
|
||||
f"mempool POST /api/tx with garbage should 4xx, got {m.status_code}: {m.text!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_post_tx_empty_body(brk, mempool):
|
||||
"""Both servers must reject an empty body with 4xx."""
|
||||
path = "/api/tx"
|
||||
|
||||
b = brk.session.post(f"{brk.base_url}{path}", data="", timeout=15)
|
||||
mempool._wait()
|
||||
m = mempool.session.post(f"{mempool.base_url}{path}", data="", timeout=15)
|
||||
show("POST", path, f"brk={b.status_code}", f"mempool={m.status_code}")
|
||||
|
||||
assert 400 <= b.status_code < 500
|
||||
assert 400 <= m.status_code < 500
|
||||
@@ -0,0 +1,56 @@
|
||||
"""GET /api/v1/transaction-times?txId[]=..."""
|
||||
|
||||
from _lib import show
|
||||
|
||||
|
||||
def test_transaction_times_few(brk, mempool, live):
|
||||
"""First-seen timestamps must match for a few txids."""
|
||||
txids = [b.txid for b in live.blocks[:3]]
|
||||
params = [("txId[]", t) for t in txids]
|
||||
path = "/api/v1/transaction-times"
|
||||
b = brk.get_json(path, params=params)
|
||||
m = mempool.get_json(path, params=params)
|
||||
show("GET", f"{path}?txId[]={{{len(txids)} txids}}", b, m)
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
assert len(b) == len(m) == len(txids)
|
||||
assert b == m, f"timestamps differ: brk={b} vs mempool={m}"
|
||||
|
||||
|
||||
def test_transaction_times_many(brk, mempool, live):
|
||||
"""A larger batch (covering all sample blocks + coinbases) must match exactly."""
|
||||
txids = [b.txid for b in live.blocks] + [b.coinbase_txid for b in live.blocks]
|
||||
params = [("txId[]", t) for t in txids]
|
||||
path = "/api/v1/transaction-times"
|
||||
b = brk.get_json(path, params=params)
|
||||
m = mempool.get_json(path, params=params)
|
||||
show("GET", f"{path}?txId[]={{{len(txids)} txids}}", f"({len(b)})", f"({len(m)})")
|
||||
assert len(b) == len(m) == len(txids)
|
||||
assert b == m, f"timestamps differ: brk={b} vs mempool={m}"
|
||||
|
||||
|
||||
def test_transaction_times_single(brk, mempool, live):
|
||||
"""A single-element batch must return a 1-element list with the same value."""
|
||||
txid = live.sample_txid
|
||||
params = [("txId[]", txid)]
|
||||
path = "/api/v1/transaction-times"
|
||||
b = brk.get_json(path, params=params)
|
||||
m = mempool.get_json(path, params=params)
|
||||
show("GET", f"{path}?txId[]={txid[:16]}...", b, m)
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
assert len(b) == len(m) == 1
|
||||
assert b == m, f"single timestamp differs: brk={b} vs mempool={m}"
|
||||
|
||||
|
||||
def test_transaction_times_empty(brk, mempool):
|
||||
"""An empty batch must be rejected (any non-2xx) on both servers.
|
||||
|
||||
mempool.space returns 500 — technically a server-side bug (it should be a
|
||||
4xx since the request itself is malformed) — so we don't insist on exact
|
||||
status parity, only that neither server silently treats it as valid input.
|
||||
"""
|
||||
path = "/api/v1/transaction-times"
|
||||
b_resp = brk.get_raw(path)
|
||||
m_resp = mempool.get_raw(path)
|
||||
show("GET", path, f"brk={b_resp.status_code}", f"mempool={m_resp.status_code}")
|
||||
assert not b_resp.ok, f"brk accepted empty batch with {b_resp.status_code}: {b_resp.text!r}"
|
||||
assert not m_resp.ok, f"mempool accepted empty batch with {m_resp.status_code}"
|
||||
@@ -0,0 +1,21 @@
|
||||
"""GET /api/tx/{txid}"""
|
||||
|
||||
from _lib import assert_same_values, show
|
||||
|
||||
|
||||
def test_tx_by_id(brk, mempool, block):
|
||||
"""Full transaction data must match for a confirmed tx."""
|
||||
path = f"/api/tx/{block.txid}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_values(b, m, exclude={"sigops"})
|
||||
|
||||
|
||||
def test_tx_coinbase(brk, mempool, block):
|
||||
"""Coinbase transaction must match."""
|
||||
path = f"/api/tx/{block.coinbase_txid}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_values(b, m, exclude={"sigops"})
|
||||
@@ -0,0 +1,12 @@
|
||||
"""GET /api/tx/{txid}/hex"""
|
||||
|
||||
from _lib import show
|
||||
|
||||
|
||||
def test_tx_hex(brk, mempool, block):
|
||||
"""Raw transaction hex must be identical."""
|
||||
path = f"/api/tx/{block.txid}/hex"
|
||||
b = brk.get_text(path)
|
||||
m = mempool.get_text(path)
|
||||
show("GET", path, b[:80] + "...", m[:80] + "...")
|
||||
assert b == m
|
||||
@@ -0,0 +1,12 @@
|
||||
"""GET /api/tx/{txid}/merkle-proof"""
|
||||
|
||||
from _lib import assert_same_values, show
|
||||
|
||||
|
||||
def test_tx_merkle_proof(brk, mempool, block):
|
||||
"""Merkle inclusion proof must match."""
|
||||
path = f"/api/tx/{block.txid}/merkle-proof"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_values(b, m)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""GET /api/tx/{txid}/merkleblock-proof"""
|
||||
|
||||
from _lib import show
|
||||
|
||||
|
||||
def test_tx_merkleblock_proof(brk, mempool, block):
|
||||
"""BIP37 merkleblock proof hex must be identical."""
|
||||
path = f"/api/tx/{block.txid}/merkleblock-proof"
|
||||
b = brk.get_text(path)
|
||||
m = mempool.get_text(path)
|
||||
show("GET", path, b[:80] + "...", m[:80] + "...")
|
||||
assert b == m
|
||||
@@ -0,0 +1,38 @@
|
||||
"""GET /api/tx/{txid}/outspend/{vout}"""
|
||||
|
||||
from _lib import assert_same_values, show
|
||||
|
||||
|
||||
def test_tx_outspend_first(brk, mempool, block):
|
||||
"""Spending status of vout 0 must match exactly."""
|
||||
path = f"/api/tx/{block.txid}/outspend/0"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_values(b, m)
|
||||
|
||||
|
||||
def test_tx_outspend_last(brk, mempool, block):
|
||||
"""Spending status of the last vout must also match exactly."""
|
||||
tx = mempool.get_json(f"/api/tx/{block.txid}")
|
||||
last_vout = len(tx["vout"]) - 1
|
||||
path = f"/api/tx/{block.txid}/outspend/{last_vout}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_values(b, m)
|
||||
|
||||
|
||||
def test_tx_outspend_out_of_range(brk, mempool, block):
|
||||
"""A vout index past the last output must produce the same response on both servers.
|
||||
|
||||
Both servers return `{"spent": false}` rather than 4xx — they don't bound-check
|
||||
the vout index. The compat property is that they agree.
|
||||
"""
|
||||
tx = mempool.get_json(f"/api/tx/{block.txid}")
|
||||
bad_vout = len(tx["vout"]) + 100
|
||||
path = f"/api/tx/{block.txid}/outspend/{bad_vout}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert b == m, f"out-of-range outspend disagrees: brk={b} vs mempool={m}"
|
||||
@@ -0,0 +1,12 @@
|
||||
"""GET /api/tx/{txid}/outspends"""
|
||||
|
||||
from _lib import assert_same_values, show
|
||||
|
||||
|
||||
def test_tx_outspends(brk, mempool, block):
|
||||
"""Spending status of all outputs must match exactly."""
|
||||
path = f"/api/tx/{block.txid}/outspends"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_values(b, m)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""GET /api/tx/{txid}/raw"""
|
||||
|
||||
from _lib import show
|
||||
|
||||
|
||||
def test_tx_raw(brk, mempool, block):
|
||||
"""Raw transaction bytes must be identical."""
|
||||
path = f"/api/tx/{block.txid}/raw"
|
||||
b = brk.get_bytes(path)
|
||||
m = mempool.get_bytes(path)
|
||||
show("GET", path, f"<{len(b)} bytes>", f"<{len(m)} bytes>")
|
||||
assert b == m
|
||||
@@ -0,0 +1,16 @@
|
||||
"""GET /api/v1/tx/{txid}/rbf
|
||||
|
||||
For confirmed transactions both servers return an empty/null replacement
|
||||
set; the structure is what's load-bearing here.
|
||||
"""
|
||||
|
||||
from _lib import assert_same_structure, show
|
||||
|
||||
|
||||
def test_tx_rbf_for_confirmed(brk, mempool, block):
|
||||
"""RBF replacement timeline structure must match for a confirmed tx."""
|
||||
path = f"/api/v1/tx/{block.txid}/rbf"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""GET /api/tx/{txid}/status"""
|
||||
|
||||
from _lib import assert_same_values, show
|
||||
|
||||
|
||||
def test_tx_status(brk, mempool, block):
|
||||
"""Confirmation status must match for a confirmed tx."""
|
||||
path = f"/api/tx/{block.txid}/status"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_values(b, m)
|
||||
@@ -57,3 +57,17 @@ def test_fetch_typed_series():
|
||||
print(e)
|
||||
f = client.series.prices.ohlc.usd.by.day1().tail(10).fetch()
|
||||
print(f)
|
||||
|
||||
|
||||
def test_endpoint_len():
|
||||
client = BrkClient("http://localhost:3110")
|
||||
n = client.series.prices.split.close.usd.by.day1().len()
|
||||
assert isinstance(n, int)
|
||||
assert n > 0
|
||||
|
||||
|
||||
def test_endpoint_version():
|
||||
client = BrkClient("http://localhost:3110")
|
||||
v = client.series.prices.split.close.usd.by.day1().version()
|
||||
assert isinstance(v, int)
|
||||
assert v >= 1
|
||||
|
||||
@@ -18,7 +18,6 @@ def day1_metric():
|
||||
version=1,
|
||||
index="day1",
|
||||
type="n",
|
||||
total=100,
|
||||
start=0,
|
||||
end=5,
|
||||
stamp="2024-01-01T00:00:00Z",
|
||||
@@ -33,7 +32,6 @@ def height_metric():
|
||||
version=1,
|
||||
index="height",
|
||||
type="n",
|
||||
total=1000,
|
||||
start=800000,
|
||||
end=800005,
|
||||
stamp="2024-01-01T00:00:00Z",
|
||||
@@ -48,7 +46,6 @@ def month1_metric():
|
||||
version=1,
|
||||
index="month1",
|
||||
type="n",
|
||||
total=200,
|
||||
start=0,
|
||||
end=3,
|
||||
stamp="2024-01-01T00:00:00Z",
|
||||
@@ -63,7 +60,6 @@ def hour1_metric():
|
||||
version=1,
|
||||
index="hour1",
|
||||
type="n",
|
||||
total=200000,
|
||||
start=0,
|
||||
end=3,
|
||||
stamp="2024-01-01T00:00:00Z",
|
||||
@@ -78,7 +74,6 @@ def week1_metric():
|
||||
version=1,
|
||||
index="week1",
|
||||
type="n",
|
||||
total=800,
|
||||
start=0,
|
||||
end=3,
|
||||
stamp="2024-01-01T00:00:00Z",
|
||||
@@ -93,7 +88,6 @@ def year1_metric():
|
||||
version=1,
|
||||
index="year1",
|
||||
type="n",
|
||||
total=20,
|
||||
start=0,
|
||||
end=3,
|
||||
stamp="2024-01-01T00:00:00Z",
|
||||
@@ -108,7 +102,6 @@ def day3_metric():
|
||||
version=1,
|
||||
index="day3",
|
||||
type="n",
|
||||
total=2000,
|
||||
start=0,
|
||||
end=3,
|
||||
stamp="2024-01-01T00:00:00Z",
|
||||
@@ -123,7 +116,6 @@ def empty_metric():
|
||||
version=1,
|
||||
index="day1",
|
||||
type="n",
|
||||
total=100,
|
||||
start=5,
|
||||
end=5,
|
||||
stamp="2024-01-01T00:00:00Z",
|
||||
|
||||
2
packages/brk_client/uv.lock
generated
2
packages/brk_client/uv.lock
generated
@@ -50,7 +50,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "brk-client"
|
||||
version = "0.3.0b6"
|
||||
version = "0.3.0b7"
|
||||
source = { editable = "." }
|
||||
|
||||
[package.dev-dependencies]
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
"""
|
||||
Mempool.space API compatibility tests.
|
||||
|
||||
Compares every brk mempool_space endpoint against the real mempool.space API
|
||||
using live blockchain data — nothing is hardcoded or deterministic.
|
||||
|
||||
Usage:
|
||||
cd scripts/mempool_compat
|
||||
uv run pytest -sv # all tests, verbose
|
||||
uv run pytest -sv test_blocks.py # one category
|
||||
uv run pytest -sv -k "test_block_header" # one test
|
||||
BRK_URL=http://host:port uv run pytest -sv # custom brk server
|
||||
|
||||
Environment variables:
|
||||
BRK_URL brk server base URL (default: http://localhost:3000)
|
||||
MEMPOOL_URL mempool.space base URL (default: https://mempool.space)
|
||||
RATE_LIMIT seconds between mempool.space requests (default: 0.5)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional, Set
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
BRK_BASE = os.environ.get("BRK_URL", "http://localhost:3000")
|
||||
MEMPOOL_BASE = os.environ.get("MEMPOOL_URL", "https://mempool.space")
|
||||
RATE_LIMIT = float(os.environ.get("RATE_LIMIT", "0.5"))
|
||||
|
||||
|
||||
# ── API client ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ApiClient:
|
||||
"""HTTP client for a single API server with optional rate limiting."""
|
||||
|
||||
def __init__(self, base_url: str, name: str, rate_limit: float = 0.0):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.name = name
|
||||
self.rate_limit = rate_limit
|
||||
self._last_request = 0.0
|
||||
self.session = requests.Session()
|
||||
self.session.headers["User-Agent"] = "brk-compat-test/1.0"
|
||||
|
||||
def _wait(self):
|
||||
if self.rate_limit > 0:
|
||||
elapsed = time.monotonic() - self._last_request
|
||||
if elapsed < self.rate_limit:
|
||||
time.sleep(self.rate_limit - elapsed)
|
||||
self._last_request = time.monotonic()
|
||||
|
||||
def get(self, path: str, params=None, timeout: int = 30) -> requests.Response:
|
||||
self._wait()
|
||||
url = f"{self.base_url}{path}"
|
||||
for attempt in range(3):
|
||||
resp = self.session.get(url, params=params, timeout=timeout)
|
||||
if resp.status_code == 429:
|
||||
wait = int(resp.headers.get("Retry-After", 5))
|
||||
time.sleep(wait)
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
|
||||
def get_json(self, path: str, params=None, timeout: int = 30) -> Any:
|
||||
return self.get(path, params=params, timeout=timeout).json()
|
||||
|
||||
def get_text(self, path: str, params=None, timeout: int = 30) -> str:
|
||||
return self.get(path, params=params, timeout=timeout).text
|
||||
|
||||
def get_bytes(self, path: str, params=None, timeout: int = 30) -> bytes:
|
||||
return self.get(path, params=params, timeout=timeout).content
|
||||
|
||||
|
||||
# ── Live data ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# Absolute heights for well-known eras + relative depths for recent blocks.
|
||||
# Covers: genesis-era, early, mid, post-halving, taproot-era, recent, near-tip.
|
||||
FIXED_HEIGHTS = [100, 100_000, 400_000, 630_000, 800_000]
|
||||
RELATIVE_DEPTHS = [1000, 100, 10]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlockData:
|
||||
"""A discovered block with associated txids."""
|
||||
|
||||
height: int
|
||||
hash: str
|
||||
txid: str
|
||||
coinbase_txid: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiveData:
|
||||
"""Live blockchain data discovered at session start."""
|
||||
|
||||
tip_height: int
|
||||
tip_hash: str
|
||||
# Multiple blocks at various depths for parametrized tests
|
||||
blocks: list # list[BlockData]
|
||||
# Addresses keyed by scriptpubkey_type
|
||||
addresses: dict # dict[str, str]
|
||||
# Convenience aliases (first block)
|
||||
stable_height: int
|
||||
stable_hash: str
|
||||
stable_block: dict
|
||||
sample_txid: str
|
||||
coinbase_txid: str
|
||||
sample_address: str
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def brk():
|
||||
return ApiClient(BRK_BASE, "brk")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def mempool():
|
||||
return ApiClient(MEMPOOL_BASE, "mempool.space", rate_limit=RATE_LIMIT)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def check_servers(brk, mempool):
|
||||
"""Fail fast if either server is unreachable."""
|
||||
try:
|
||||
brk.get("/api/blocks/tip/height")
|
||||
except Exception as e:
|
||||
pytest.exit(f"brk server not reachable at {brk.base_url}: {e}")
|
||||
try:
|
||||
mempool.get("/api/blocks/tip/height")
|
||||
except Exception as e:
|
||||
pytest.exit(f"mempool.space not reachable at {mempool.base_url}: {e}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def live(mempool) -> LiveData:
|
||||
"""Discover live blockchain data for all tests.
|
||||
|
||||
Fetches blocks at several depths and extracts txids + addresses of
|
||||
different types so parametrized tests hit varied real data.
|
||||
"""
|
||||
tip_height = int(mempool.get_text("/api/blocks/tip/height"))
|
||||
tip_hash = mempool.get_text("/api/blocks/tip/hash")
|
||||
|
||||
heights = FIXED_HEIGHTS + [tip_height - d for d in RELATIVE_DEPTHS]
|
||||
heights.sort()
|
||||
|
||||
blocks: list[BlockData] = []
|
||||
addresses: dict[str, str] = {}
|
||||
|
||||
for h in heights:
|
||||
bh = mempool.get_text(f"/api/block-height/{h}")
|
||||
txids = mempool.get_json(f"/api/block/{bh}/txids")
|
||||
coinbase = txids[0]
|
||||
sample = txids[min(1, len(txids) - 1)]
|
||||
blocks.append(BlockData(height=h, hash=bh, txid=sample, coinbase_txid=coinbase))
|
||||
|
||||
# Collect addresses of different types from non-coinbase outputs
|
||||
if len(addresses) < 8:
|
||||
tx = mempool.get_json(f"/api/tx/{sample}")
|
||||
for vout in tx.get("vout", []):
|
||||
atype = vout.get("scriptpubkey_type")
|
||||
addr = vout.get("scriptpubkey_address")
|
||||
if addr and atype and atype not in addresses:
|
||||
addresses[atype] = addr
|
||||
|
||||
stable = blocks[0]
|
||||
stable_block = mempool.get_json(f"/api/block/{stable.hash}")
|
||||
sample_address = next(iter(addresses.values()), "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
|
||||
|
||||
data = LiveData(
|
||||
tip_height=tip_height,
|
||||
tip_hash=tip_hash,
|
||||
blocks=blocks,
|
||||
addresses=addresses,
|
||||
stable_height=stable.height,
|
||||
stable_hash=stable.hash,
|
||||
stable_block=stable_block,
|
||||
sample_txid=stable.txid,
|
||||
coinbase_txid=stable.coinbase_txid,
|
||||
sample_address=sample_address,
|
||||
)
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print(f" LIVE TEST DATA (from {MEMPOOL_BASE})")
|
||||
print(f"{'='*70}")
|
||||
print(f" tip {data.tip_height} {data.tip_hash[:20]}…")
|
||||
for i, b in enumerate(blocks):
|
||||
print(f" block[{i}] {b.height} {b.hash[:20]}… tx={b.txid[:16]}…")
|
||||
for atype, addr in addresses.items():
|
||||
print(f" addr {atype:12s} {addr}")
|
||||
print(f"{'='*70}\n")
|
||||
return data
|
||||
|
||||
|
||||
# ── Display helpers ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def show(method: str, path: str, brk_data: Any, mem_data: Any, max_lines: int = 20):
|
||||
"""Print both responses so the runner can see what was fetched."""
|
||||
print(f"\n{'─'*70}")
|
||||
print(f" {method} {path}")
|
||||
print(f"{'─'*70}")
|
||||
for label, data in [("mempool.space", mem_data), ("brk", brk_data)]:
|
||||
print(f"\n [{label}]")
|
||||
if isinstance(data, (dict, list)):
|
||||
text = json.dumps(data, indent=2)
|
||||
elif isinstance(data, bytes):
|
||||
text = f"<{len(data)} bytes>"
|
||||
else:
|
||||
text = str(data)
|
||||
lines = text.split("\n")
|
||||
for line in lines[:max_lines]:
|
||||
print(f" {line}")
|
||||
if len(lines) > max_lines:
|
||||
print(f" … ({len(lines) - max_lines} more lines)")
|
||||
|
||||
|
||||
# ── Comparison helpers ────────────────────────────────────────────────
|
||||
|
||||
# Keys that brk is intentionally not implementing (mempool.space-specific features).
|
||||
# Everything else that mempool.space returns MUST be present in brk.
|
||||
ALLOWED_MISSING = {
|
||||
"matchRate", "expectedFees", "expectedWeight",
|
||||
# brk only tracks USD — non-USD currencies and exchange rates are intentionally absent
|
||||
"EUR", "GBP", "CAD", "CHF", "AUD", "JPY",
|
||||
"USDEUR", "USDGBP", "USDCAD", "USDCHF", "USDAUD", "USDJPY",
|
||||
# brk doesn't compute block health scores
|
||||
"avgBlockHealth",
|
||||
# brk doesn't compute block similarity/template matching
|
||||
"similarity",
|
||||
# brk doesn't compute fee delta or match rate per pool
|
||||
"avgFeeDelta", "avgMatchRate",
|
||||
}
|
||||
|
||||
# Coinbase transactions use vout=65535 (u16::MAX) in brk vs 4294967295 (u32::MAX)
|
||||
# in mempool.space. This is an intentional representation difference.
|
||||
COINBASE_VOUT_BRK = 65535
|
||||
COINBASE_VOUT_MEMPOOL = 4294967295
|
||||
|
||||
|
||||
def assert_same_structure(brk_data: Any, mem_data: Any, path: str = "root"):
|
||||
"""brk must have every key mempool.space has (extra brk keys are fine).
|
||||
|
||||
Recurses into nested dicts; for arrays, compares the first element.
|
||||
int/float are treated as equivalent; None is compatible with anything.
|
||||
"""
|
||||
if isinstance(mem_data, dict):
|
||||
assert isinstance(brk_data, dict), (
|
||||
f"Expected dict at {path}, got {type(brk_data).__name__}"
|
||||
)
|
||||
brk_keys = set(brk_data.keys())
|
||||
mem_keys = set(mem_data.keys())
|
||||
missing = mem_keys - brk_keys - ALLOWED_MISSING
|
||||
assert not missing, f"brk missing keys at {path}: {missing}"
|
||||
for key in brk_keys & mem_keys:
|
||||
assert_same_structure(brk_data[key], mem_data[key], f"{path}.{key}")
|
||||
elif isinstance(mem_data, list):
|
||||
assert isinstance(brk_data, list), (
|
||||
f"Expected list at {path}, got {type(brk_data).__name__}"
|
||||
)
|
||||
if mem_data and brk_data:
|
||||
assert_same_structure(brk_data[0], mem_data[0], f"{path}[0]")
|
||||
else:
|
||||
if mem_data is None or brk_data is None:
|
||||
return
|
||||
bt = type(brk_data).__name__
|
||||
mt = type(mem_data).__name__
|
||||
if {bt, mt} <= {"int", "float"}:
|
||||
return
|
||||
# int/str are compatible when the string is a numeric literal
|
||||
# (mempool.space serializes large numbers as strings)
|
||||
if {bt, mt} == {"int", "str"}:
|
||||
return
|
||||
assert bt == mt, (
|
||||
f"Type mismatch at {path}: brk={bt}({brk_data!r}) "
|
||||
f"vs mempool={mt}({mem_data!r})"
|
||||
)
|
||||
|
||||
|
||||
def assert_same_values(
|
||||
brk_data: Any,
|
||||
mem_data: Any,
|
||||
path: str = "root",
|
||||
exclude: Optional[Set[str]] = None,
|
||||
):
|
||||
"""Both responses must have identical values.
|
||||
|
||||
Floats are compared with relative tolerance 1e-4.
|
||||
Pass ``exclude`` to skip keys that are expected to differ.
|
||||
"""
|
||||
exclude = exclude or set()
|
||||
|
||||
if isinstance(mem_data, dict):
|
||||
assert isinstance(brk_data, dict), (
|
||||
f"Expected dict at {path}, got {type(brk_data).__name__}"
|
||||
)
|
||||
# brk must have every mempool key; extra brk keys are fine
|
||||
mem_keys = set(mem_data.keys())
|
||||
for key in mem_keys - exclude - ALLOWED_MISSING:
|
||||
assert key in brk_data, f"brk missing '{key}' at {path}"
|
||||
assert_same_values(brk_data[key], mem_data[key], f"{path}.{key}", exclude)
|
||||
elif isinstance(mem_data, list):
|
||||
assert isinstance(brk_data, list), (
|
||||
f"Expected list at {path}, got {type(brk_data).__name__}"
|
||||
)
|
||||
assert len(brk_data) == len(mem_data), (
|
||||
f"Length mismatch at {path}: brk={len(brk_data)} vs mempool={len(mem_data)}"
|
||||
)
|
||||
for i, (b, m) in enumerate(zip(brk_data, mem_data)):
|
||||
assert_same_values(b, m, f"{path}[{i}]", exclude)
|
||||
elif mem_data is None:
|
||||
# mempool returns null, brk computes a value — that's fine
|
||||
return
|
||||
elif isinstance(mem_data, float) or isinstance(brk_data, float):
|
||||
if brk_data is None:
|
||||
return
|
||||
assert float(brk_data) == pytest.approx(
|
||||
float(mem_data), rel=1e-4, abs=1e-6
|
||||
), f"Float mismatch at {path}: brk={brk_data} vs mempool={mem_data}"
|
||||
else:
|
||||
# Coinbase vout: brk uses u16::MAX, mempool uses u32::MAX — both valid
|
||||
if (
|
||||
brk_data == COINBASE_VOUT_BRK
|
||||
and mem_data == COINBASE_VOUT_MEMPOOL
|
||||
):
|
||||
return
|
||||
assert brk_data == mem_data, (
|
||||
f"Value mismatch at {path}: brk={brk_data!r} vs mempool={mem_data!r}"
|
||||
)
|
||||
@@ -1,11 +0,0 @@
|
||||
[project]
|
||||
name = "mempool-compat"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"pytest>=7.0",
|
||||
"requests>=2.28",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["."]
|
||||
@@ -1,159 +0,0 @@
|
||||
"""
|
||||
Address endpoint compatibility tests — parametrized across address types.
|
||||
|
||||
Endpoints covered:
|
||||
GET /api/address/{address}
|
||||
GET /api/address/{address}/txs
|
||||
GET /api/address/{address}/txs/chain
|
||||
GET /api/address/{address}/txs/mempool
|
||||
GET /api/address/{address}/utxo
|
||||
GET /api/v1/validate-address/{address}
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import show, assert_same_structure, assert_same_values
|
||||
|
||||
|
||||
@pytest.fixture(params=[
|
||||
"12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S", # P2PKH — early block reward
|
||||
"3D2oetdNuZUqQHPJmcMDDHYoqkyNVsFk9r", # P2SH
|
||||
], ids=["p2pkh", "p2sh"])
|
||||
def static_addr(request):
|
||||
"""Well-known addresses that always exist."""
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def live_addrs(live):
|
||||
"""All dynamically discovered address types."""
|
||||
return list(live.addresses.items())
|
||||
|
||||
|
||||
# ── /api/address/{address} ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_address_info_static(brk, mempool, static_addr):
|
||||
"""Address stats structure must match for well-known addresses."""
|
||||
path = f"/api/address/{static_addr}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
assert b["address"] == m["address"]
|
||||
|
||||
|
||||
def test_address_info_discovered(brk, mempool, live_addrs):
|
||||
"""Address stats structure must match for each discovered type."""
|
||||
for atype, addr in live_addrs:
|
||||
path = f"/api/address/{addr}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", f"{path} [{atype}]", b, m)
|
||||
assert_same_structure(b, m)
|
||||
assert b["address"] == m["address"]
|
||||
|
||||
|
||||
def test_address_chain_stats_close(brk, mempool, live_addrs):
|
||||
"""Chain stats values must be close for each discovered address."""
|
||||
for atype, addr in live_addrs:
|
||||
path = f"/api/address/{addr}"
|
||||
b = brk.get_json(path)["chain_stats"]
|
||||
m = mempool.get_json(path)["chain_stats"]
|
||||
show("GET", f"{path} [chain_stats, {atype}]", b, m)
|
||||
assert_same_structure(b, m)
|
||||
assert abs(b["tx_count"] - m["tx_count"]) <= 5, (
|
||||
f"{atype} tx_count: brk={b['tx_count']} vs mempool={m['tx_count']}"
|
||||
)
|
||||
|
||||
|
||||
# ── /api/address/{address}/txs ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_address_txs(brk, mempool, static_addr):
|
||||
"""Address transaction list structure must match."""
|
||||
path = f"/api/address/{static_addr}/txs"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)")
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
if b and m:
|
||||
assert_same_structure(b[0], m[0])
|
||||
|
||||
|
||||
# ── /api/address/{address}/txs/chain ─────────────────────────────────
|
||||
|
||||
|
||||
def test_address_txs_chain(brk, mempool, static_addr):
|
||||
"""Confirmed-only tx list structure must match."""
|
||||
path = f"/api/address/{static_addr}/txs/chain"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)")
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
if b and m:
|
||||
assert_same_structure(b[0], m[0])
|
||||
|
||||
|
||||
# ── /api/address/{address}/txs/mempool ────────────────────────────────
|
||||
|
||||
|
||||
def test_address_txs_mempool(brk, mempool, live):
|
||||
"""Mempool tx list must be an array (contents are volatile)."""
|
||||
path = f"/api/address/{live.sample_address}/txs/mempool"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)")
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
|
||||
|
||||
# ── /api/address/{address}/utxo ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_address_utxo(brk, mempool, static_addr):
|
||||
"""UTXO list must match — same txids, values, and statuses."""
|
||||
path = f"/api/address/{static_addr}/utxo"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} utxos)", f"({len(m)} utxos)")
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
# Sort by txid+vout for stable comparison
|
||||
key = lambda u: (u.get("txid", ""), u.get("vout", 0))
|
||||
b_sorted = sorted(b, key=key)
|
||||
m_sorted = sorted(m, key=key)
|
||||
assert_same_values(b_sorted, m_sorted)
|
||||
|
||||
|
||||
# ── /api/v1/validate-address/{address} ───────────────────────────────
|
||||
|
||||
|
||||
def test_validate_address_discovered(brk, mempool, live_addrs):
|
||||
"""Validation of each discovered address type must match exactly."""
|
||||
for atype, addr in live_addrs:
|
||||
path = f"/api/v1/validate-address/{addr}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", f"{path} [{atype}]", b, m)
|
||||
assert_same_values(b, m)
|
||||
assert b["isvalid"] is True
|
||||
|
||||
|
||||
def test_validate_address_p2pkh(brk, mempool):
|
||||
"""Satoshi's P2PKH address must validate identically."""
|
||||
path = "/api/v1/validate-address/1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_values(b, m)
|
||||
assert b["isvalid"] is True
|
||||
|
||||
|
||||
def test_validate_address_invalid(brk, mempool):
|
||||
"""Invalid address must produce the same rejection structure."""
|
||||
path = "/api/v1/validate-address/notanaddress123"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert b["isvalid"] is False
|
||||
assert m["isvalid"] is False
|
||||
assert_same_structure(b, m)
|
||||
@@ -1,265 +0,0 @@
|
||||
"""
|
||||
Block endpoint compatibility tests — parametrized across blockchain eras.
|
||||
|
||||
Endpoints covered:
|
||||
GET /api/block/{hash}
|
||||
GET /api/v1/block/{hash} (with extras)
|
||||
GET /api/block/{hash}/header text/plain
|
||||
GET /api/block/{hash}/status
|
||||
GET /api/block/{hash}/txids
|
||||
GET /api/block/{hash}/txs
|
||||
GET /api/block/{hash}/txs/{start}
|
||||
GET /api/block/{hash}/txid/{index} text/plain
|
||||
GET /api/block-height/{height} text/plain
|
||||
GET /api/blocks
|
||||
GET /api/blocks/{height}
|
||||
GET /api/v1/blocks
|
||||
GET /api/v1/blocks/{height}
|
||||
GET /api/blocks/tip/height text/plain
|
||||
GET /api/blocks/tip/hash text/plain
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import show, assert_same_structure, assert_same_values
|
||||
|
||||
|
||||
def _block_ids(live):
|
||||
return [f"h{b.height}" for b in live.blocks]
|
||||
|
||||
|
||||
def _bi(request, live):
|
||||
"""Resolve parametrized block index, skip if out of range."""
|
||||
i = request.param
|
||||
if i >= len(live.blocks):
|
||||
pytest.skip("block not discovered")
|
||||
return live.blocks[i]
|
||||
|
||||
|
||||
@pytest.fixture(params=range(8), ids=[
|
||||
"h100", "h100k", "h400k", "h630k", "h800k", "recent1k", "recent100", "recent10",
|
||||
])
|
||||
def block(request, live):
|
||||
i = request.param
|
||||
if i >= len(live.blocks):
|
||||
pytest.skip("block not discovered")
|
||||
return live.blocks[i]
|
||||
|
||||
|
||||
# ── /api/block/{hash} ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_block_by_hash(brk, mempool, block):
|
||||
"""Confirmed block info must be identical."""
|
||||
path = f"/api/block/{block.hash}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_values(b, m)
|
||||
|
||||
|
||||
# ── /api/v1/block/{hash} ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_block_v1_extras_all_values(brk, mempool, block):
|
||||
"""Every shared extras field must match — exposes computation differences."""
|
||||
path = f"/api/v1/block/{block.hash}"
|
||||
b = brk.get_json(path)["extras"]
|
||||
m = mempool.get_json(path)["extras"]
|
||||
show("GET", f"{path} [extras]", b, m, max_lines=50)
|
||||
assert_same_structure(b, m)
|
||||
assert_same_values(b, m)
|
||||
|
||||
|
||||
def test_block_v1_extras_pool(brk, mempool, block):
|
||||
"""Pool identification structure must match."""
|
||||
path = f"/api/v1/block/{block.hash}"
|
||||
bp = brk.get_json(path)["extras"]["pool"]
|
||||
mp = mempool.get_json(path)["extras"]["pool"]
|
||||
show("GET", f"{path} [extras.pool]", bp, mp)
|
||||
assert_same_structure(bp, mp)
|
||||
|
||||
|
||||
# ── /api/block/{hash}/header ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_block_header(brk, mempool, block):
|
||||
"""80-byte hex block header must be identical."""
|
||||
path = f"/api/block/{block.hash}/header"
|
||||
b = brk.get_text(path)
|
||||
m = mempool.get_text(path)
|
||||
show("GET", path, b, m)
|
||||
assert len(b) == 160, f"Expected 160 hex chars (80 bytes), got {len(b)}"
|
||||
assert b == m
|
||||
|
||||
|
||||
# ── /api/block/{hash}/status ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_block_status(brk, mempool, block):
|
||||
"""Block status must be identical for a confirmed block."""
|
||||
path = f"/api/block/{block.hash}/status"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_values(b, m)
|
||||
|
||||
|
||||
# ── /api/block/{hash}/txids ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_block_txids(brk, mempool, block):
|
||||
"""Ordered txid list must be identical."""
|
||||
path = f"/api/block/{block.hash}/txids"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b[:3], m[:3])
|
||||
assert b == m
|
||||
|
||||
|
||||
# ── /api/block/{hash}/txs ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_block_txs_page0(brk, mempool, block):
|
||||
"""First page of block transactions must match."""
|
||||
path = f"/api/block/{block.hash}/txs"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)")
|
||||
assert len(b) == len(m), f"Page size: brk={len(b)} vs mempool={len(m)}"
|
||||
if b and m:
|
||||
assert_same_values(b[0], m[0], exclude={"sigops"})
|
||||
|
||||
|
||||
def test_block_txs_start_index(brk, mempool, block):
|
||||
"""Paginated txs from index 25 must match (skip small blocks)."""
|
||||
# Blocks with <26 txs don't have a second page
|
||||
txids = mempool.get_json(f"/api/block/{block.hash}/txids")
|
||||
if len(txids) <= 25:
|
||||
pytest.skip(f"block has only {len(txids)} txs")
|
||||
path = f"/api/block/{block.hash}/txs/25"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} txs)", f"({len(m)} txs)")
|
||||
assert len(b) == len(m)
|
||||
if b and m:
|
||||
assert_same_structure(b[0], m[0])
|
||||
|
||||
|
||||
# ── /api/block/{hash}/txid/{index} ───────────────────────────────────
|
||||
|
||||
|
||||
def test_block_txid_at_index_0(brk, mempool, block):
|
||||
"""Txid at position 0 (coinbase) must match."""
|
||||
path = f"/api/block/{block.hash}/txid/0"
|
||||
b = brk.get_text(path)
|
||||
m = mempool.get_text(path)
|
||||
show("GET", path, b, m)
|
||||
assert b == m
|
||||
|
||||
|
||||
def test_block_txid_at_index_1(brk, mempool, block):
|
||||
"""Txid at position 1 (first non-coinbase) must match."""
|
||||
txids = mempool.get_json(f"/api/block/{block.hash}/txids")
|
||||
if len(txids) <= 1:
|
||||
pytest.skip("block has only coinbase")
|
||||
path = f"/api/block/{block.hash}/txid/1"
|
||||
b = brk.get_text(path)
|
||||
m = mempool.get_text(path)
|
||||
show("GET", path, b, m)
|
||||
assert b == m
|
||||
|
||||
|
||||
def test_block_txid_at_last_index(brk, mempool, block):
|
||||
"""Txid at last position must match."""
|
||||
txids = mempool.get_json(f"/api/block/{block.hash}/txids")
|
||||
last = len(txids) - 1
|
||||
path = f"/api/block/{block.hash}/txid/{last}"
|
||||
b = brk.get_text(path)
|
||||
m = mempool.get_text(path)
|
||||
show("GET", path, b, m)
|
||||
assert b == m
|
||||
|
||||
|
||||
# ── /api/block-height/{height} ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_block_height_to_hash(brk, mempool, block):
|
||||
"""Block hash at a given height must match."""
|
||||
path = f"/api/block-height/{block.height}"
|
||||
b = brk.get_text(path)
|
||||
m = mempool.get_text(path)
|
||||
show("GET", path, b, m)
|
||||
assert b == m
|
||||
assert b == block.hash
|
||||
|
||||
|
||||
# ── /api/blocks/{height} ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_blocks_from_height(brk, mempool, block):
|
||||
"""Confirmed blocks from a fixed height must match exactly."""
|
||||
path = f"/api/blocks/{block.height}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
|
||||
assert len(b) == len(m)
|
||||
if b and m:
|
||||
assert_same_values(b[0], m[0])
|
||||
|
||||
|
||||
def test_blocks_v1_from_height(brk, mempool, block):
|
||||
"""v1 blocks from a confirmed height — all values must match."""
|
||||
path = f"/api/v1/blocks/{block.height}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
|
||||
assert len(b) == len(m)
|
||||
if b and m:
|
||||
assert_same_values(b[0], m[0])
|
||||
|
||||
|
||||
# ── non-parametrized (no block param) ────────────────────────────────
|
||||
|
||||
|
||||
def test_blocks_recent(brk, mempool):
|
||||
"""Recent blocks list must have the same structure."""
|
||||
path = "/api/blocks"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show(
|
||||
"GET", path,
|
||||
f"({len(b)} blocks, {b[-1]['height']}–{b[0]['height']})" if b else "[]",
|
||||
f"({len(m)} blocks, {m[-1]['height']}–{m[0]['height']})" if m else "[]",
|
||||
)
|
||||
assert len(b) > 0
|
||||
assert_same_structure(b, m)
|
||||
|
||||
|
||||
def test_blocks_v1_recent(brk, mempool):
|
||||
"""Recent v1 blocks (with extras) must have the same structure."""
|
||||
path = "/api/v1/blocks"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
|
||||
assert len(b) > 0
|
||||
assert_same_structure(b, m)
|
||||
|
||||
|
||||
def test_blocks_tip_height(brk, mempool):
|
||||
"""Tip heights must be within a few blocks of each other."""
|
||||
path = "/api/blocks/tip/height"
|
||||
b = int(brk.get_text(path))
|
||||
m = int(mempool.get_text(path))
|
||||
show("GET", path, b, m)
|
||||
assert abs(b - m) <= 3, f"Tip heights differ by {abs(b - m)}: brk={b}, mempool={m}"
|
||||
|
||||
|
||||
def test_blocks_tip_hash(brk, mempool):
|
||||
"""Tip hash must be a valid 64-char hex string."""
|
||||
path = "/api/blocks/tip/hash"
|
||||
b = brk.get_text(path)
|
||||
m = mempool.get_text(path)
|
||||
show("GET", path, b, m)
|
||||
assert len(b) == 64
|
||||
assert len(m) == 64
|
||||
@@ -1,84 +0,0 @@
|
||||
"""
|
||||
Fee endpoint compatibility tests.
|
||||
|
||||
Endpoints covered:
|
||||
GET /api/v1/fees/recommended
|
||||
GET /api/v1/fees/precise
|
||||
GET /api/v1/fees/mempool-blocks
|
||||
"""
|
||||
|
||||
from conftest import show, assert_same_structure
|
||||
|
||||
|
||||
EXPECTED_FEE_KEYS = [
|
||||
"fastestFee", "halfHourFee", "hourFee", "economyFee", "minimumFee",
|
||||
]
|
||||
|
||||
|
||||
# ── /api/v1/fees/recommended ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_fees_recommended(brk, mempool):
|
||||
"""Recommended fees must have the same keys and numeric types."""
|
||||
path = "/api/v1/fees/recommended"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
for key in EXPECTED_FEE_KEYS:
|
||||
assert key in b, f"brk missing '{key}'"
|
||||
assert isinstance(b[key], (int, float)), f"'{key}' is not numeric: {type(b[key])}"
|
||||
|
||||
|
||||
def test_fees_recommended_ordering(brk, mempool):
|
||||
"""Fee tiers must be ordered: fastest >= halfHour >= hour >= economy >= minimum."""
|
||||
path = "/api/v1/fees/recommended"
|
||||
for label, client in [("brk", brk), ("mempool", mempool)]:
|
||||
d = client.get_json(path)
|
||||
assert d["fastestFee"] >= d["halfHourFee"] >= d["hourFee"], (
|
||||
f"{label}: fee ordering violated {d}"
|
||||
)
|
||||
assert d["hourFee"] >= d["economyFee"] >= d["minimumFee"], (
|
||||
f"{label}: fee ordering violated {d}"
|
||||
)
|
||||
|
||||
|
||||
# ── /api/v1/fees/precise ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_fees_precise(brk, mempool):
|
||||
"""Precise fees must have the same structure as recommended."""
|
||||
path = "/api/v1/fees/precise"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
for key in EXPECTED_FEE_KEYS:
|
||||
assert key in b
|
||||
|
||||
|
||||
# ── /api/v1/fees/mempool-blocks ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_fees_mempool_blocks(brk, mempool):
|
||||
"""Projected mempool blocks must have the same element structure."""
|
||||
path = "/api/v1/fees/mempool-blocks"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
assert len(b) > 0
|
||||
if b and m:
|
||||
assert_same_structure(b[0], m[0])
|
||||
|
||||
|
||||
def test_fees_mempool_blocks_fee_range(brk, mempool):
|
||||
"""Each projected block must have a 7-element feeRange."""
|
||||
path = "/api/v1/fees/mempool-blocks"
|
||||
for label, client in [("brk", brk), ("mempool", mempool)]:
|
||||
blocks = client.get_json(path)
|
||||
for i, block in enumerate(blocks[:3]):
|
||||
assert "feeRange" in block, f"{label} block {i} missing feeRange"
|
||||
assert len(block["feeRange"]) == 7, (
|
||||
f"{label} block {i} feeRange has {len(block['feeRange'])} items, expected 7"
|
||||
)
|
||||
@@ -1,122 +0,0 @@
|
||||
"""
|
||||
General endpoint compatibility tests.
|
||||
|
||||
Endpoints covered:
|
||||
GET /api/v1/difficulty-adjustment
|
||||
GET /api/v1/prices
|
||||
GET /api/v1/historical-price
|
||||
GET /api/v1/historical-price?timestamp=…
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import show, assert_same_structure
|
||||
|
||||
|
||||
DIFFICULTY_KEYS = [
|
||||
"progressPercent", "difficultyChange", "estimatedRetargetDate",
|
||||
"remainingBlocks", "remainingTime", "previousRetarget",
|
||||
"previousTime", "nextRetargetHeight", "timeAvg",
|
||||
"adjustedTimeAvg", "timeOffset", "expectedBlocks",
|
||||
]
|
||||
|
||||
|
||||
# ── /api/v1/difficulty-adjustment ────────────────────────────────────
|
||||
|
||||
|
||||
def test_difficulty_adjustment(brk, mempool):
|
||||
"""Difficulty adjustment must have the same structure."""
|
||||
path = "/api/v1/difficulty-adjustment"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
for key in DIFFICULTY_KEYS:
|
||||
assert key in b, f"brk missing '{key}'"
|
||||
|
||||
|
||||
def test_difficulty_adjustment_values_sane(brk, mempool):
|
||||
"""Progress must be 0–100 %, remaining blocks must be 0–2016."""
|
||||
path = "/api/v1/difficulty-adjustment"
|
||||
for label, client in [("brk", brk), ("mempool", mempool)]:
|
||||
d = client.get_json(path)
|
||||
assert 0 <= d["progressPercent"] <= 100, (
|
||||
f"{label} progressPercent out of range: {d['progressPercent']}"
|
||||
)
|
||||
assert 0 <= d["remainingBlocks"] <= 2016, (
|
||||
f"{label} remainingBlocks out of range: {d['remainingBlocks']}"
|
||||
)
|
||||
|
||||
|
||||
# ── /api/v1/prices ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_prices(brk, mempool):
|
||||
"""Current price must have the same structure."""
|
||||
path = "/api/v1/prices"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
assert "USD" in b
|
||||
assert "time" in b
|
||||
|
||||
|
||||
def test_prices_positive(brk, mempool):
|
||||
"""USD price must be a positive number on both servers."""
|
||||
path = "/api/v1/prices"
|
||||
for label, client in [("brk", brk), ("mempool", mempool)]:
|
||||
d = client.get_json(path)
|
||||
assert d["USD"] > 0, f"{label} USD price is not positive: {d['USD']}"
|
||||
|
||||
|
||||
# ── /api/v1/historical-price ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_historical_price(brk, mempool):
|
||||
"""Historical price must have the same structure."""
|
||||
path = "/api/v1/historical-price"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m, max_lines=15)
|
||||
assert_same_structure(b, m)
|
||||
assert "prices" in b
|
||||
assert isinstance(b["prices"], list)
|
||||
|
||||
|
||||
def test_historical_price_at_block_timestamps(brk, mempool, live):
|
||||
"""Historical price at each discovered block's timestamp must match structure."""
|
||||
for block in live.blocks:
|
||||
info = brk.get_json(f"/api/block/{block.hash}")
|
||||
ts = info["timestamp"]
|
||||
path = f"/api/v1/historical-price?timestamp={ts}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
assert "prices" in b
|
||||
assert len(b["prices"]) > 0
|
||||
|
||||
|
||||
# Well-known timestamps from different eras
|
||||
HISTORICAL_TIMESTAMPS = [
|
||||
1231006505, # genesis block (2009-01-03)
|
||||
1354116278, # block 210000 — first halving (2012-11-28)
|
||||
1468082773, # block 420000 — second halving (2016-07-09)
|
||||
1588788036, # block 630000 — third halving (2020-05-11)
|
||||
1713571767, # block 840000 — fourth halving (2024-04-20)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ts", HISTORICAL_TIMESTAMPS, ids=[
|
||||
"genesis", "halving1", "halving2", "halving3", "halving4",
|
||||
])
|
||||
def test_historical_price_at_era(brk, mempool, ts):
|
||||
"""Historical price at well-known timestamps must match structure."""
|
||||
path = f"/api/v1/historical-price?timestamp={ts}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
assert "prices" in b
|
||||
assert len(b["prices"]) > 0
|
||||
@@ -1,72 +0,0 @@
|
||||
"""
|
||||
Mempool endpoint compatibility tests.
|
||||
|
||||
Endpoints covered:
|
||||
GET /api/mempool
|
||||
GET /api/mempool/txids
|
||||
GET /api/mempool/recent
|
||||
"""
|
||||
|
||||
from conftest import show, assert_same_structure
|
||||
|
||||
|
||||
# ── /api/mempool ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_mempool_info(brk, mempool):
|
||||
"""Mempool stats must have the same keys and types."""
|
||||
path = "/api/mempool"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m, max_lines=15)
|
||||
assert_same_structure(b, m)
|
||||
assert isinstance(b["count"], int)
|
||||
assert isinstance(b["vsize"], int)
|
||||
|
||||
|
||||
def test_mempool_info_positive(brk, mempool):
|
||||
"""Both servers must report a non-empty mempool."""
|
||||
path = "/api/mempool"
|
||||
for label, client in [("brk", brk), ("mempool", mempool)]:
|
||||
d = client.get_json(path)
|
||||
assert d["count"] > 0, f"{label} mempool count is 0"
|
||||
assert d["vsize"] > 0, f"{label} mempool vsize is 0"
|
||||
|
||||
|
||||
# ── /api/mempool/txids ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_mempool_txids(brk, mempool):
|
||||
"""Txid list must be a non-empty array of strings."""
|
||||
path = "/api/mempool/txids"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} txids)", f"({len(m)} txids)")
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
assert len(b) > 0, "brk mempool has no txids"
|
||||
assert isinstance(b[0], str) and len(b[0]) == 64
|
||||
|
||||
|
||||
# ── /api/mempool/recent ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_mempool_recent(brk, mempool):
|
||||
"""Recent mempool txs must have the same element structure."""
|
||||
path = "/api/mempool/recent"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
assert len(b) > 0
|
||||
if b and m:
|
||||
assert_same_structure(b[0], m[0])
|
||||
|
||||
|
||||
def test_mempool_recent_fields(brk, mempool):
|
||||
"""Each recent tx must have txid, fee, vsize, value."""
|
||||
path = "/api/mempool/recent"
|
||||
for label, client in [("brk", brk), ("mempool", mempool)]:
|
||||
txs = client.get_json(path)
|
||||
for tx in txs[:3]:
|
||||
for key in ["txid", "fee", "vsize", "value"]:
|
||||
assert key in tx, f"{label} recent tx missing '{key}': {tx}"
|
||||
@@ -1,239 +0,0 @@
|
||||
"""
|
||||
Mining endpoint compatibility tests.
|
||||
|
||||
Endpoints covered:
|
||||
GET /api/v1/mining/pools
|
||||
GET /api/v1/mining/pools/{period}
|
||||
GET /api/v1/mining/pool/{slug}
|
||||
GET /api/v1/mining/pool/{slug}/hashrate
|
||||
GET /api/v1/mining/pool/{slug}/blocks
|
||||
GET /api/v1/mining/pool/{slug}/blocks/{height}
|
||||
GET /api/v1/mining/hashrate/{period}
|
||||
GET /api/v1/mining/hashrate/pools/{period}
|
||||
GET /api/v1/mining/difficulty-adjustments/{period}
|
||||
GET /api/v1/mining/reward-stats/{block_count}
|
||||
GET /api/v1/mining/blocks/fees/{period}
|
||||
GET /api/v1/mining/blocks/rewards/{period}
|
||||
GET /api/v1/mining/blocks/fee-rates/{period}
|
||||
GET /api/v1/mining/blocks/sizes-weights/{period}
|
||||
GET /api/v1/mining/blocks/timestamp/{timestamp}
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import show, assert_same_structure
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pool_slugs(mempool):
|
||||
"""Discover the top 3 active pool slugs from the last week."""
|
||||
data = mempool.get_json("/api/v1/mining/pools/1w")
|
||||
pools = data.get("pools", []) if isinstance(data, dict) else []
|
||||
slugs = [p["slug"] for p in pools if p.get("blockCount", 0) > 0][:3]
|
||||
return slugs or ["foundryusa"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pool_slug(pool_slugs):
|
||||
return pool_slugs[0]
|
||||
|
||||
|
||||
# ── /api/v1/mining/pools ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_mining_pools_list(brk, mempool):
|
||||
"""Pool list must have the same element structure."""
|
||||
path = "/api/v1/mining/pools"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b[:3] if isinstance(b, list) else b, m[:3] if isinstance(m, list) else m)
|
||||
assert_same_structure(b, m)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"])
|
||||
def test_mining_pools_by_period(brk, mempool, period):
|
||||
"""Pool stats for a time period must have the same structure."""
|
||||
path = f"/api/v1/mining/pools/{period}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, _summary(b), _summary(m))
|
||||
assert_same_structure(b, m)
|
||||
|
||||
|
||||
# ── /api/v1/mining/pool/{slug} ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_mining_pool_detail(brk, mempool, pool_slugs):
|
||||
"""Pool detail must have the same structure for top pools."""
|
||||
for slug in pool_slugs:
|
||||
path = f"/api/v1/mining/pool/{slug}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, _summary(b), _summary(m))
|
||||
assert_same_structure(b, m)
|
||||
|
||||
|
||||
def test_mining_pool_hashrate(brk, mempool, pool_slugs):
|
||||
"""Pool hashrate history must have the same structure for top pools."""
|
||||
for slug in pool_slugs:
|
||||
path = f"/api/v1/mining/pool/{slug}/hashrate"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, _summary(b), _summary(m))
|
||||
assert_same_structure(b, m)
|
||||
|
||||
|
||||
def test_mining_pool_blocks(brk, mempool, pool_slugs):
|
||||
"""Recent blocks by pool must have the same element structure."""
|
||||
for slug in pool_slugs:
|
||||
path = f"/api/v1/mining/pool/{slug}/blocks"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
if b and m:
|
||||
assert_same_structure(b[0], m[0])
|
||||
|
||||
|
||||
def test_mining_pool_blocks_at_height(brk, mempool, pool_slug, live):
|
||||
"""Pool blocks before various heights must have the same element structure."""
|
||||
for block in live.blocks[::2]: # every other block
|
||||
path = f"/api/v1/mining/pool/{pool_slug}/blocks/{block.height}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, f"({len(b)} blocks)", f"({len(m)} blocks)")
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
if b and m:
|
||||
assert_same_structure(b[0], m[0])
|
||||
|
||||
|
||||
# ── /api/v1/mining/hashrate ──────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y"])
|
||||
def test_mining_hashrate(brk, mempool, period):
|
||||
"""Network hashrate + difficulty must have the same structure."""
|
||||
path = f"/api/v1/mining/hashrate/{period}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, _summary(b), _summary(m))
|
||||
assert_same_structure(b, m)
|
||||
|
||||
|
||||
# ── /api/v1/mining/hashrate/pools ────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "1y"])
|
||||
def test_mining_hashrate_pools(brk, mempool, period):
|
||||
"""Per-pool hashrate must have the same structure."""
|
||||
path = f"/api/v1/mining/hashrate/pools/{period}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, _summary(b), _summary(m))
|
||||
assert_same_structure(b, m)
|
||||
|
||||
|
||||
# ── /api/v1/mining/difficulty-adjustments ─────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y"])
|
||||
def test_mining_difficulty_adjustments(brk, mempool, period):
|
||||
"""Historical difficulty adjustments must have the same structure."""
|
||||
path = f"/api/v1/mining/difficulty-adjustments/{period}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, _summary(b), _summary(m))
|
||||
assert_same_structure(b, m)
|
||||
|
||||
|
||||
# ── /api/v1/mining/reward-stats ──────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("block_count", [10, 100, 500])
|
||||
def test_mining_reward_stats(brk, mempool, block_count):
|
||||
"""Reward stats must have the same structure."""
|
||||
path = f"/api/v1/mining/reward-stats/{block_count}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
|
||||
|
||||
# ── /api/v1/mining/blocks/fees ───────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y"])
|
||||
def test_mining_blocks_fees(brk, mempool, period):
|
||||
"""Average block fees must have the same element structure."""
|
||||
path = f"/api/v1/mining/blocks/fees/{period}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, _summary(b), _summary(m))
|
||||
assert_same_structure(b, m)
|
||||
|
||||
|
||||
# ── /api/v1/mining/blocks/rewards ────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y"])
|
||||
def test_mining_blocks_rewards(brk, mempool, period):
|
||||
"""Average block rewards must have the same element structure."""
|
||||
path = f"/api/v1/mining/blocks/rewards/{period}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, _summary(b), _summary(m))
|
||||
assert_same_structure(b, m)
|
||||
|
||||
|
||||
# ── /api/v1/mining/blocks/fee-rates ──────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y"])
|
||||
def test_mining_blocks_fee_rates(brk, mempool, period):
|
||||
"""Block fee-rate percentiles must have the same element structure."""
|
||||
path = f"/api/v1/mining/blocks/fee-rates/{period}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, _summary(b), _summary(m))
|
||||
assert_same_structure(b, m)
|
||||
|
||||
|
||||
# ── /api/v1/mining/blocks/sizes-weights ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("period", ["24h", "3d", "1w", "1m", "3m", "6m", "1y"])
|
||||
def test_mining_blocks_sizes_weights(brk, mempool, period):
|
||||
"""Block sizes and weights must have the same structure."""
|
||||
path = f"/api/v1/mining/blocks/sizes-weights/{period}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, _summary(b), _summary(m))
|
||||
assert_same_structure(b, m)
|
||||
|
||||
|
||||
# ── /api/v1/mining/blocks/timestamp ──────────────────────────────────
|
||||
|
||||
|
||||
def test_mining_blocks_timestamp(brk, mempool, live):
|
||||
"""Block lookup by timestamp must have the same structure for various eras."""
|
||||
for block in live.blocks:
|
||||
# Get the block timestamp from brk
|
||||
info = brk.get_json(f"/api/block/{block.hash}")
|
||||
ts = info["timestamp"]
|
||||
path = f"/api/v1/mining/blocks/timestamp/{ts}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _summary(data):
|
||||
"""Short description of a response for the show() call."""
|
||||
if isinstance(data, list):
|
||||
return f"({len(data)} items)"
|
||||
if isinstance(data, dict):
|
||||
return f"(keys: {list(data.keys())})"
|
||||
return str(data)
|
||||
@@ -1,161 +0,0 @@
|
||||
"""
|
||||
Transaction endpoint compatibility tests — parametrized across blockchain eras.
|
||||
|
||||
Endpoints covered:
|
||||
GET /api/tx/{txid}
|
||||
GET /api/tx/{txid}/hex text/plain
|
||||
GET /api/tx/{txid}/raw application/octet-stream
|
||||
GET /api/tx/{txid}/status
|
||||
GET /api/tx/{txid}/merkle-proof
|
||||
GET /api/tx/{txid}/merkleblock-proof text/plain
|
||||
GET /api/tx/{txid}/outspend/{vout}
|
||||
GET /api/tx/{txid}/outspends
|
||||
GET /api/v1/cpfp/{txid}
|
||||
GET /api/v1/transaction-times
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import show, assert_same_structure, assert_same_values
|
||||
|
||||
|
||||
@pytest.fixture(params=range(8), ids=[
|
||||
"h100", "h100k", "h400k", "h630k", "h800k", "recent1k", "recent100", "recent10",
|
||||
])
|
||||
def block(request, live):
|
||||
i = request.param
|
||||
if i >= len(live.blocks):
|
||||
pytest.skip("block not discovered")
|
||||
return live.blocks[i]
|
||||
|
||||
|
||||
# ── /api/tx/{txid} ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_tx_by_id(brk, mempool, block):
|
||||
"""Full transaction data must match for a confirmed tx."""
|
||||
path = f"/api/tx/{block.txid}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_values(b, m, exclude={"sigops"})
|
||||
|
||||
|
||||
def test_tx_coinbase(brk, mempool, block):
|
||||
"""Coinbase transaction must match."""
|
||||
path = f"/api/tx/{block.coinbase_txid}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_values(b, m, exclude={"sigops"})
|
||||
|
||||
|
||||
# ── /api/tx/{txid}/hex ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_tx_hex(brk, mempool, block):
|
||||
"""Raw transaction hex must be identical."""
|
||||
path = f"/api/tx/{block.txid}/hex"
|
||||
b = brk.get_text(path)
|
||||
m = mempool.get_text(path)
|
||||
show("GET", path, b[:80] + "…", m[:80] + "…")
|
||||
assert b == m
|
||||
|
||||
|
||||
# ── /api/tx/{txid}/raw ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_tx_raw(brk, mempool, block):
|
||||
"""Raw transaction bytes must be identical."""
|
||||
path = f"/api/tx/{block.txid}/raw"
|
||||
b = brk.get_bytes(path)
|
||||
m = mempool.get_bytes(path)
|
||||
show("GET", path, f"<{len(b)} bytes>", f"<{len(m)} bytes>")
|
||||
assert b == m
|
||||
|
||||
|
||||
# ── /api/tx/{txid}/status ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_tx_status(brk, mempool, block):
|
||||
"""Confirmation status must match for a confirmed tx."""
|
||||
path = f"/api/tx/{block.txid}/status"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_values(b, m)
|
||||
|
||||
|
||||
# ── /api/tx/{txid}/merkle-proof ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_tx_merkle_proof(brk, mempool, block):
|
||||
"""Merkle inclusion proof must match."""
|
||||
path = f"/api/tx/{block.txid}/merkle-proof"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_values(b, m)
|
||||
|
||||
|
||||
# ── /api/tx/{txid}/merkleblock-proof ─────────────────────────────────
|
||||
|
||||
|
||||
def test_tx_merkleblock_proof(brk, mempool, block):
|
||||
"""BIP37 merkleblock proof hex must be identical."""
|
||||
path = f"/api/tx/{block.txid}/merkleblock-proof"
|
||||
b = brk.get_text(path)
|
||||
m = mempool.get_text(path)
|
||||
show("GET", path, b[:80] + "…", m[:80] + "…")
|
||||
assert b == m
|
||||
|
||||
|
||||
# ── /api/tx/{txid}/outspend/{vout} ───────────────────────────────────
|
||||
|
||||
|
||||
def test_tx_outspend(brk, mempool, block):
|
||||
"""Spending status of output 0 must match exactly."""
|
||||
path = f"/api/tx/{block.txid}/outspend/0"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_values(b, m)
|
||||
|
||||
|
||||
# ── /api/tx/{txid}/outspends ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_tx_outspends(brk, mempool, block):
|
||||
"""Spending status of all outputs must match exactly."""
|
||||
path = f"/api/tx/{block.txid}/outspends"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_values(b, m)
|
||||
|
||||
|
||||
# ── /api/v1/cpfp/{txid} ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cpfp(brk, mempool, block):
|
||||
"""CPFP info structure must match for a confirmed tx."""
|
||||
path = f"/api/v1/cpfp/{block.txid}"
|
||||
b = brk.get_json(path)
|
||||
m = mempool.get_json(path)
|
||||
show("GET", path, b, m)
|
||||
assert_same_structure(b, m)
|
||||
|
||||
|
||||
# ── /api/v1/transaction-times ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_transaction_times(brk, mempool, live):
|
||||
"""First-seen timestamps array must have the same length."""
|
||||
txids = [b.txid for b in live.blocks[:3]]
|
||||
params = [("txId[]", t) for t in txids]
|
||||
path = "/api/v1/transaction-times"
|
||||
b = brk.get_json(path, params=params)
|
||||
m = mempool.get_json(path, params=params)
|
||||
show("GET", f"{path}?txId[]={{{len(txids)} txids}}", b, m)
|
||||
assert isinstance(b, list) and isinstance(m, list)
|
||||
assert len(b) == len(m) == len(txids)
|
||||
385
scripts/mempool_compat/uv.lock
generated
385
scripts/mempool_compat/uv.lock
generated
@@ -1,385 +0,0 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.9"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.10'",
|
||||
"python_full_version < '3.10'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.2.25"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/1b/ef725f8eb19b5a261b30f78efa9252ef9d017985cb499102f6f49834cd12/charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217", size = 299121, upload-time = "2026-04-02T09:28:14.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/22/2f12878fbc680fbbb52386cd39a379801f62eaca74fc8b323381325f0f04/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5", size = 200612, upload-time = "2026-04-02T09:28:16.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/b6/10c84e789126ca97d4a7228863a30481e786980a8b8cfcbf4f30658ca63c/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9", size = 221041, upload-time = "2026-04-02T09:28:17.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/7b/c414866a138400b2e81973d006da7f694cfeaf895ef07d2cba9a8743841a/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a", size = 216323, upload-time = "2026-04-02T09:28:18.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/92/bdcf94997e06b223d826df3abed45a5ad6e17f609b7df9d25cd23b5bde30/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc", size = 208419, upload-time = "2026-04-02T09:28:20.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/64/3f9142293c88b1b10e199649ed1330f070c2a68e305335a5819fa7f25fa7/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00", size = 195016, upload-time = "2026-04-02T09:28:21.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/d1/d8a6b7dd5c5636b76ce0d080bc57d8e56c7bbd6bc2ac941529a35e41d84a/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776", size = 206115, upload-time = "2026-04-02T09:28:23.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/8c/60ebe912379627d023eb96995b40bc50308729f210f43d66109ca0a7bbd2/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319", size = 204022, upload-time = "2026-04-02T09:28:24.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/2a/41816ceda78a551cbfdfbeab6f3891152b0e3f758ce6580c2c18c829f774/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24", size = 195914, upload-time = "2026-04-02T09:28:26.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/9b/7c7f4b7f11525fcbdfba752455314ac60646bae91cdd671d531c1f7a97c6/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42", size = 222159, upload-time = "2026-04-02T09:28:27.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/57/301682e7469bdbfa2ce219a804f0668b2266ab8520570d85d3b3ef483ea3/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4", size = 206154, upload-time = "2026-04-02T09:28:28.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/ec/90339ff5cdc598b265748c1f231c7d7fbd9123a92cee10f757e0b1448de4/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67", size = 217423, upload-time = "2026-04-02T09:28:30.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/e7/a7a6147f8e3375676309cf584b25c72a3bab784ea4085b0011fa07b23aeb/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274", size = 210604, upload-time = "2026-04-02T09:28:31.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/62/d9340c7a79c393e57807d7fb6c57e82060687891f81b74d3201958b919c1/charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366", size = 144631, upload-time = "2026-04-02T09:28:33.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/e7/92901117e2ddc8facfe8235a3ecd4eb482185b2ad5d5b6606b37c1afea06/charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444", size = 154710, upload-time = "2026-04-02T09:28:34.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/4f/e1fb138201ad9a32499dd9a98aa4a5a5441fbf7f56b52b619a54b7ee8777/charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c", size = 143716, upload-time = "2026-04-02T09:28:35.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.10'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mempool-compat"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "pytest", version = "9.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||
{ name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "requests", version = "2.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "pytest", specifier = ">=7.0" },
|
||||
{ name = "requests", specifier = ">=2.28" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.10'" },
|
||||
{ name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "packaging", marker = "python_full_version < '3.10'" },
|
||||
{ name = "pluggy", marker = "python_full_version < '3.10'" },
|
||||
{ name = "pygments", marker = "python_full_version < '3.10'" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.10'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
|
||||
{ name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||
{ name = "packaging", marker = "python_full_version >= '3.10'" },
|
||||
{ name = "pluggy", marker = "python_full_version >= '3.10'" },
|
||||
{ name = "pygments", marker = "python_full_version >= '3.10'" },
|
||||
{ name = "tomli", marker = "python_full_version == '3.10.*'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "certifi", marker = "python_full_version < '3.10'" },
|
||||
{ name = "charset-normalizer", marker = "python_full_version < '3.10'" },
|
||||
{ name = "idna", marker = "python_full_version < '3.10'" },
|
||||
{ name = "urllib3", marker = "python_full_version < '3.10'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.33.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "certifi", marker = "python_full_version >= '3.10'" },
|
||||
{ name = "charset-normalizer", marker = "python_full_version >= '3.10'" },
|
||||
{ name = "idna", marker = "python_full_version >= '3.10'" },
|
||||
{ name = "urllib3", marker = "python_full_version >= '3.10'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
@@ -7,7 +7,6 @@
|
||||
* @import { Options } from './options/full.js'
|
||||
*
|
||||
* @import { PersistedValue } from './utils/persisted.js'
|
||||
* @import { MapCache } from './utils/cache.js'
|
||||
*
|
||||
* @import { SingleValueData, CandlestickData, Series, AnySeries, ISeries, HistogramData, LineData, BaselineData, LineSeriesPartialOptions, BaselineSeriesPartialOptions, HistogramSeriesPartialOptions, CandlestickSeriesPartialOptions, Chart, Legend } from "./utils/chart/index.js"
|
||||
*
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { brk } from "../utils/client.js";
|
||||
import { createMapCache } from "../utils/cache.js";
|
||||
import { latestPrice } from "../utils/price.js";
|
||||
import { createRow, formatBtc, renderTx, showPanel, hidePanel, TX_PAGE_SIZE } from "./render.js";
|
||||
|
||||
@@ -8,9 +7,6 @@ import { createRow, formatBtc, renderTx, showPanel, hidePanel, TX_PAGE_SIZE } fr
|
||||
/** @type {HTMLDivElement} */ let txSection;
|
||||
/** @type {string} */ let currentAddr = "";
|
||||
|
||||
const statsCache = createMapCache(50);
|
||||
const txCache = createMapCache(200);
|
||||
|
||||
const ROW_LABELS = [
|
||||
"Address",
|
||||
"Confirmed Balance",
|
||||
@@ -63,9 +59,7 @@ export async function update(address, signal) {
|
||||
while (txSection.children.length > 1) txSection.lastChild?.remove();
|
||||
|
||||
try {
|
||||
const stats = await statsCache.fetch(address, () =>
|
||||
brk.getAddress(address, { signal }),
|
||||
);
|
||||
const stats = await brk.getAddress(address, { signal });
|
||||
if (signal.aborted || currentAddr !== address) return;
|
||||
|
||||
const chain = stats.chainStats;
|
||||
@@ -118,11 +112,8 @@ export async function update(address, signal) {
|
||||
async function loadMore() {
|
||||
if (currentAddr !== address) return;
|
||||
loading = true;
|
||||
const key = `${address}:${pageIndex}`;
|
||||
try {
|
||||
const txs = await txCache.fetch(key, () =>
|
||||
brk.getAddressTxs(address, afterTxid, { signal }),
|
||||
);
|
||||
const txs = await brk.getAddressTxs(address, afterTxid, { signal });
|
||||
if (currentAddr !== address) return;
|
||||
for (const tx of txs) txSection.append(renderTx(tx));
|
||||
pageIndex++;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { brk } from "../utils/client.js";
|
||||
import { createMapCache } from "../utils/cache.js";
|
||||
import { createPersistedValue } from "../utils/persisted.js";
|
||||
import { createRow, formatFeeRate, formatHeightPrefix, renderTx, showPanel, hidePanel, TX_PAGE_SIZE } from "./render.js";
|
||||
|
||||
@@ -68,7 +67,6 @@ const ROW_DEFS = [
|
||||
let txTotalPages = 0;
|
||||
let txLoading = false;
|
||||
let txLoaded = false;
|
||||
const txPageCache = createMapCache(200);
|
||||
|
||||
const txPageParam = createPersistedValue({
|
||||
defaultValue: 0,
|
||||
@@ -200,11 +198,8 @@ async function loadTxPage(page, pushUrl = true) {
|
||||
txLoaded = true;
|
||||
if (pushUrl) txPageParam.setImmediate(page);
|
||||
updateTxNavs(page);
|
||||
const key = `${block.id}:${page}`;
|
||||
try {
|
||||
const txs = await txPageCache.fetch(key, () =>
|
||||
brk.getBlockTxsFromIndex(block.id, page * TX_PAGE_SIZE),
|
||||
);
|
||||
const txs = await brk.getBlockTxsFromIndex(block.id, page * TX_PAGE_SIZE);
|
||||
txList.innerHTML = "";
|
||||
const ascii = block.extras.coinbaseSignatureAscii;
|
||||
for (const tx of txs) txList.append(renderTx(tx, ascii));
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { explorerElement } from "../utils/elements.js";
|
||||
import { brk } from "../utils/client.js";
|
||||
import { createMapCache } from "../utils/cache.js";
|
||||
import {
|
||||
initChain,
|
||||
goToCube,
|
||||
@@ -30,7 +29,6 @@ import {
|
||||
|
||||
/** @type {number | undefined} */ let pollInterval;
|
||||
let navController = new AbortController();
|
||||
const txCache = createMapCache(50);
|
||||
let lastLoadedUrl = "";
|
||||
|
||||
function navigate() {
|
||||
@@ -134,7 +132,7 @@ async function load() {
|
||||
if (kind === "tx" && value) {
|
||||
const txid = await resolveTxid(value, { signal });
|
||||
if (signal.aborted) return;
|
||||
const tx = await txCache.fetch(txid, () => brk.getTx(txid, { signal }));
|
||||
const tx = await brk.getTx(txid, { signal });
|
||||
if (signal.aborted) return;
|
||||
await goToCube(tx.status?.blockHash ?? tx.status?.blockHeight ?? null, { silent: true });
|
||||
updateTx(tx);
|
||||
@@ -178,7 +176,7 @@ async function navigateToTx(txidOrIndex) {
|
||||
try {
|
||||
const txid = await resolveTxid(txidOrIndex, { signal });
|
||||
if (signal.aborted) return;
|
||||
const tx = await txCache.fetch(txid, () => brk.getTx(txid, { signal }));
|
||||
const tx = await brk.getTx(txid, { signal });
|
||||
if (signal.aborted) return;
|
||||
await goToCube(tx.status?.blockHash ?? tx.status?.blockHeight ?? null, { silent: true });
|
||||
updateTx(tx);
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* @template V
|
||||
* @param {number} [maxSize]
|
||||
*/
|
||||
export function createMapCache(maxSize = 100) {
|
||||
/** @type {Map<string, V>} */
|
||||
const map = new Map();
|
||||
|
||||
/** @param {string} key @param {V} value */
|
||||
const set = (key, value) => {
|
||||
if (map.size >= maxSize && !map.has(key)) {
|
||||
const first = map.keys().next().value;
|
||||
if (first !== undefined) map.delete(first);
|
||||
}
|
||||
map.set(key, value);
|
||||
};
|
||||
|
||||
return {
|
||||
/** @param {string} key @returns {V | undefined} */
|
||||
get: (key) => map.get(key),
|
||||
/** @param {string} key @returns {boolean} */
|
||||
has: (key) => map.has(key),
|
||||
set,
|
||||
/** @param {string} key @param {() => Promise<V>} fetcher @returns {Promise<V>} */
|
||||
async fetch(key, fetcher) {
|
||||
const hit = map.get(key);
|
||||
if (hit !== undefined) return hit;
|
||||
const value = await fetcher();
|
||||
set(key, value);
|
||||
return value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @template V
|
||||
* @typedef {ReturnType<typeof createMapCache<V>>} MapCache
|
||||
*/
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user