From 350cfc659bada85d1342cf41e52f85a256680975 Mon Sep 17 00:00:00 2001 From: Felipe Guilherme Sabino <982190+sabino@users.noreply.github.com> Date: Sun, 18 May 2025 00:57:28 -0300 Subject: [PATCH] Restore Hy source and make Hy optional --- .github/workflows/test.yml | 24 ++++++++++++++ README.md | 11 +++++-- coincap_fdw/__init__.py | 6 ++++ coincap_fdw/api.py | 13 ++++++++ coincap_fdw/wrapper.hy | 18 ++++++++++ coincap_fdw/wrapper.py | 20 ++++++++++++ requirements.txt | 3 +- setup.py | 33 ++++++++++--------- src/__init__.py | 2 -- src/wrapper.hy | 32 ------------------ tests/test_wrapper.py | 67 ++++++++++++++++++++++++++++++++++++++ 11 files changed, 175 insertions(+), 54 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 coincap_fdw/__init__.py create mode 100644 coincap_fdw/api.py create mode 100644 coincap_fdw/wrapper.hy create mode 100644 coincap_fdw/wrapper.py delete mode 100644 src/__init__.py delete mode 100644 src/wrapper.hy create mode 100644 tests/test_wrapper.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f3b4620 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e . + - name: Run tests + run: python -m unittest discover -s tests -v + + + diff --git a/README.md b/README.md index b80a3be..64b7819 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,11 @@ CREATE EXTENSION multicorn; -- create a server that points at the wrapper class CREATE SERVER coincap FOREIGN DATA WRAPPER multicorn - OPTIONS (wrapper 'coincap_fdw.CoinCapForeignDataWrapper'); + OPTIONS (wrapper 'coincap_fdw.CoinCapForeignDataWrapper', + base_url 'https://api.coincap.io/v2', + endpoint 'assets'); +These options allow querying different CoinCap endpoints without changing the wrapper. +The defaults are base_url "https://api.coincap.io/v2" and endpoint "assets". -- define a foreign table using the server CREATE FOREIGN TABLE crypto_assets ( @@ -56,9 +60,10 @@ LIMIT 10; ``` coincap_fdw/ -├── src/ # Python/Hy source package +├── coincap_fdw/ # Python source package │ ├── __init__.py # Package initializer -│ └── wrapper.hy # Hy implementation of the FDW +│ ├── api.py # Helper for API requests +│ └── wrapper.hy # FDW implementation (Hy language) ├── requirements.txt # Runtime dependencies ├── setup.py # Packaging metadata └── README.md # Project documentation (this file) diff --git a/coincap_fdw/__init__.py b/coincap_fdw/__init__.py new file mode 100644 index 0000000..4bf0e8a --- /dev/null +++ b/coincap_fdw/__init__.py @@ -0,0 +1,6 @@ +try: + import hy # optional; allows importing .hy sources when available +except Exception: # pragma: no cover - hy not installed in minimal env + hy = None + +from .wrapper import CoinCapForeignDataWrapper diff --git a/coincap_fdw/api.py b/coincap_fdw/api.py new file mode 100644 index 0000000..43a5e07 --- /dev/null +++ b/coincap_fdw/api.py @@ -0,0 +1,13 @@ +import json +import requests + +DEFAULT_BASE_URL = "https://api.coincap.io/v2" + + +def fetch_endpoint(endpoint: str, base_url: str = DEFAULT_BASE_URL): + """Fetch JSON data from the given CoinCap API endpoint.""" + url = f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}" + resp = requests.request("GET", url) + resp.raise_for_status() + return json.loads(resp.content.decode("utf-8"))["data"] + diff --git a/coincap_fdw/wrapper.hy b/coincap_fdw/wrapper.hy new file mode 100644 index 0000000..d6b8513 --- /dev/null +++ b/coincap_fdw/wrapper.hy @@ -0,0 +1,18 @@ +(import [multicorn [ForeignDataWrapper]] + [coincap_fdw.api [fetch_endpoint DEFAULT_BASE_URL]]) + +(defn dict-filter [dict-obj selected-columns] + (setv lower-cols (lfor col selected-columns (.lower col))) + (dfor [k v] (.items dict-obj) :if (in lower-cols (.lower k)) [(.lower k) v])) + +(defclass CoinCapForeignDataWrapper [ForeignDataWrapper] + (defn --init-- [self options columns] + (-> (super CoinCapForeignDataWrapper self) (.--init-- options columns)) + (setv self.columns columns) + (setv self.base-url (get options "base_url" DEFAULT_BASE_URL)) + (setv self.endpoint (get options "endpoint" "assets"))) + + (defn execute [self quals columns] + (setv assets-list (fetch_endpoint self.endpoint self.base-url)) + (lfor asset assets-list (dict-filter asset self.columns))) + diff --git a/coincap_fdw/wrapper.py b/coincap_fdw/wrapper.py new file mode 100644 index 0000000..2a2c9e2 --- /dev/null +++ b/coincap_fdw/wrapper.py @@ -0,0 +1,20 @@ +from multicorn import ForeignDataWrapper +from .api import fetch_endpoint, DEFAULT_BASE_URL + + +def dict_filter(dict_obj, selected_columns): + lower_columns = [col.lower() for col in selected_columns] + return {k.lower(): v for k, v in dict_obj.items() if k.lower() in lower_columns} + + +class CoinCapForeignDataWrapper(ForeignDataWrapper): + def __init__(self, options, columns): + super().__init__(options, columns) + self.columns = columns + self.base_url = options.get("base_url", DEFAULT_BASE_URL) + self.endpoint = options.get("endpoint", "assets") + + def execute(self, quals, columns): + assets_list = fetch_endpoint(self.endpoint, self.base_url) + return [dict_filter(asset, self.columns) for asset in assets_list] + diff --git a/requirements.txt b/requirements.txt index 7f44ece..e8e35cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ +requests==2.25.1 + hy==0.20.0 -requests==2.25.1 \ No newline at end of file diff --git a/setup.py b/setup.py index 70f8c3e..94c8ddf 100644 --- a/setup.py +++ b/setup.py @@ -1,18 +1,19 @@ -import subprocess -from setuptools import setup, find_packages, Extension +from setuptools import setup setup( - name='coincap_fdw', - version='0.0.1', - author='sabino', - author_email='sabino@secret.fyi', - url='https://github.com/sabino/coincap_fdw', - license='WTFPL', - packages=['coincap_fdw'], - install_requires=[ - 'requests==2.25.1', - 'hy==0.20.0' - ], - package_data={'coincap_fdw': ['*.hy']}, - package_dir={'coincap_fdw': 'src'} -) \ No newline at end of file + name='coincap_fdw', + version='0.0.1', + author='sabino', + author_email='sabino@secret.fyi', + url='https://github.com/sabino/coincap_fdw', + license='WTFPL', + packages=['coincap_fdw'], + install_requires=[ + 'requests==2.25.1', + 'hy==0.20.0' + ], + package_data={'coincap_fdw': ['*.py', '*.hy']}, + package_dir={'coincap_fdw': 'coincap_fdw'} +) + + diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index 27a59d9..0000000 --- a/src/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -import hy -from .wrapper import CoinCapForeignDataWrapper \ No newline at end of file diff --git a/src/wrapper.hy b/src/wrapper.hy deleted file mode 100644 index bd83304..0000000 --- a/src/wrapper.hy +++ /dev/null @@ -1,32 +0,0 @@ -(import [multicorn [ForeignDataWrapper]] - [requests [request]] - json) - -(defn in-list? - [elements-list search-element] - (>= (.count elements-list search-element) 1)) - -(defn dict-filter - [dict-obj selected-columns] - (setv lower-columns (lfor col selected-columns (.lower col))) - (dfor [key value] - (.items dict-obj) :if (in-list? lower-columns (.lower key)) - [(.lower key) value])) - -(defclass CoinCapForeignDataWrapper - [ForeignDataWrapper] - - (defn --init-- - [self options columns] - (setv self.columns columns) - (-> (super CoinCapForeignDataWrapper self) - (.--init-- options columns))) - - (defn execute - [self options columns] - (setv assets-list (-> (request "GET" "https://api.coincap.io/v2/assets") - (. content) - (.decode "utf-8") - json.loads - (get "data"))) - (lfor asset assets-list (dict-filter asset self.columns)))) diff --git a/tests/test_wrapper.py b/tests/test_wrapper.py new file mode 100644 index 0000000..ccca825 --- /dev/null +++ b/tests/test_wrapper.py @@ -0,0 +1,67 @@ +import json +import unittest +from unittest.mock import patch +import sys +from types import SimpleNamespace +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'coincap_fdw')) + +class FakeForeignDataWrapper: + def __init__(self, *args, **kwargs): + pass + +sys.modules.setdefault('multicorn', SimpleNamespace(ForeignDataWrapper=FakeForeignDataWrapper)) +sys.modules.setdefault('requests', SimpleNamespace(request=lambda *a, **k: None)) + +from coincap_fdw.api import fetch_endpoint, DEFAULT_BASE_URL +from coincap_fdw.wrapper import CoinCapForeignDataWrapper + + +class DummyResponse: + def __init__(self, data): + self.content = json.dumps({'data': data}).encode('utf-8') + + def raise_for_status(self): + pass + + +def make_mock_request(expected_url, data): + def _request(method, url): + assert method == 'GET' + assert url == expected_url + return DummyResponse(data) + return _request + + +class TestAPI(unittest.TestCase): + def test_fetch_endpoint(self): + data = [{'id': 'btc'}] + url = f"{DEFAULT_BASE_URL}/assets" + with patch('requests.request', make_mock_request(url, data)): + self.assertEqual(fetch_endpoint('assets'), data) + + +class TestWrapper(unittest.TestCase): + def test_execute_defaults(self): + data = [{'id': 'btc'}] + url = f"{DEFAULT_BASE_URL}/assets" + with patch('requests.request', make_mock_request(url, data)): + wrapper = CoinCapForeignDataWrapper({}, {'id': {}}) + rows = list(wrapper.execute({}, {})) + self.assertEqual(rows, [{'id': 'btc'}]) + + def test_execute_custom(self): + data = [{'id': 'btc', 'name': 'Bitcoin'}] + url = 'https://custom/api/coins' + with patch('requests.request', make_mock_request(url, data)): + wrapper = CoinCapForeignDataWrapper({'base_url': 'https://custom/api', + 'endpoint': 'coins'}, + {'id': {}, 'name': {}}) + rows = list(wrapper.execute({}, {})) + self.assertEqual(rows, [{'id': 'btc', 'name': 'Bitcoin'}]) + + +if __name__ == '__main__': + unittest.main() +