From 7e109bd2ec01b821da502ea12fbba16716ad0902 Mon Sep 17 00:00:00 2001 From: half-ogre Date: Tue, 20 Jan 2026 10:17:09 -0800 Subject: [PATCH 01/13] first steps catching this up to the Node SDK --- .github/workflows/tests.yaml | 40 +++ README.md | 83 +++++- hyphen/__init__.py | 46 ++- hyphen/feature_toggle.py | 270 +++++++++++++++--- hyphen/link.py | 127 ++++---- hyphen/net_info.py | 25 +- hyphen/types.py | 362 +++++++++++++++++++++++ mise.toml | 2 + pyproject.toml | 3 +- tests/test_feature_toggle.py | 540 ++++++++++++++++++++++++++++++----- tests/test_link.py | 109 +++++-- tests/test_net_info.py | 70 ++++- tests/test_types.py | 321 +++++++++++++++++++++ 13 files changed, 1787 insertions(+), 211 deletions(-) create mode 100644 .github/workflows/tests.yaml create mode 100644 hyphen/types.py create mode 100644 mise.toml create mode 100644 tests/test_types.py diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..db8db07 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,40 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Run linting + run: ruff check hyphen tests + + - name: Run type checking + run: mypy hyphen + + - name: Run tests with coverage + run: pytest --cov=hyphen --cov-report=xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + fail_ci_if_error: false diff --git a/README.md b/README.md index ddefc69..a4e81f7 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,54 @@ export HYPHEN_ORGANIZATION_ID="your_organization_id" ## Feature Toggles -Manage feature flags for your application. +Manage feature flags for your application with targeting support. * [Website](https://hyphen.ai) * [Guides](https://docs.hyphen.ai) -### Get a Single Toggle +### Basic Usage + +```python +from hyphen import FeatureToggle, ToggleContext + +toggle = FeatureToggle( + application_id='your_application_id', + api_key='your_api_key', + environment='production', # Optional, defaults to HYPHEN_ENVIRONMENT or "production" +) + +# Get a boolean toggle with default value +enabled = toggle.get_boolean('my-feature', default=False) +print('Feature enabled:', enabled) +``` + +### Targeting Context + +Use targeting context to evaluate toggles based on user attributes: + +```python +from hyphen import FeatureToggle, ToggleContext + +# Set a default context for all evaluations +toggle = FeatureToggle( + application_id='your_application_id', + api_key='your_api_key', + default_context=ToggleContext( + targeting_key='user_123', + user={'id': 'user_123', 'email': 'user@example.com'}, + ) +) + +# Or pass context per request +context = ToggleContext( + targeting_key='user_456', + ip_address='192.168.1.1', + custom_attributes={'plan': 'premium', 'beta_tester': True} +) +enabled = toggle.get_boolean('premium-feature', default=False, context=context) +``` + +### Type-Safe Toggle Methods ```python from hyphen import FeatureToggle @@ -43,8 +85,17 @@ toggle = FeatureToggle( api_key='your_api_key', ) -value = toggle.get_toggle('hyphen-sdk-boolean') -print('Toggle value:', value) +# Boolean toggles +enabled = toggle.get_boolean('feature-flag', default=False) + +# String toggles +theme = toggle.get_string('ui-theme', default='light') + +# Numeric toggles +max_items = toggle.get_number('max-items', default=10) + +# JSON object toggles +config = toggle.get_object('feature-config', default={'enabled': False}) ``` ### Get Multiple Toggles @@ -57,8 +108,26 @@ toggle = FeatureToggle( api_key='your_api_key', ) -toggles = toggle.get_toggles(['hyphen-sdk-boolean', 'hyphen-sdk-number', 'hyphen-sdk-string']) -print('Toggles:', toggles) +toggles = toggle.get_toggles(['feature-a', 'feature-b', 'feature-c']) +print('Toggles:', toggles) # {'feature-a': True, 'feature-b': 42, 'feature-c': 'enabled'} +``` + +### Error Handling + +```python +from hyphen import FeatureToggle + +def handle_toggle_error(error): + print(f'Toggle evaluation failed: {error}') + +toggle = FeatureToggle( + application_id='your_application_id', + api_key='your_api_key', + on_error=handle_toggle_error, # Errors call this instead of raising +) + +# Returns default value on error instead of raising +enabled = toggle.get_boolean('my-feature', default=False) ``` Toggles support multiple data types: @@ -349,4 +418,4 @@ We welcome contributions! Please follow these steps: This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. -Copyright © 2024 Hyphen, Inc. All rights reserved. +Copyright © 2025 Hyphen, Inc. All rights reserved. diff --git a/hyphen/__init__.py b/hyphen/__init__.py index daf5942..87008d9 100644 --- a/hyphen/__init__.py +++ b/hyphen/__init__.py @@ -1,8 +1,50 @@ """Hyphen Python SDK - Feature toggles, IP geolocation, and link shortening.""" from hyphen.feature_toggle import FeatureToggle -from hyphen.link import Link, QrSize +from hyphen.link import Link from hyphen.net_info import NetInfo +from hyphen.types import ( + CreateQrCodeOptions, + CreateShortCodeOptions, + Evaluation, + EvaluationResponse, + IpInfo, + IpInfoError, + IpLocation, + QrCode, + QrCodesResponse, + QrSize, + ShortCode, + ShortCodesResponse, + ToggleContext, + ToggleType, + UpdateShortCodeOptions, + UserContext, +) __version__ = "0.1.0" -__all__ = ["FeatureToggle", "NetInfo", "Link", "QrSize"] +__all__ = [ + # Services + "FeatureToggle", + "Link", + "NetInfo", + # Toggle types + "Evaluation", + "EvaluationResponse", + "ToggleContext", + "ToggleType", + "UserContext", + # Link types + "CreateQrCodeOptions", + "CreateShortCodeOptions", + "QrCode", + "QrCodesResponse", + "QrSize", + "ShortCode", + "ShortCodesResponse", + "UpdateShortCodeOptions", + # NetInfo types + "IpInfo", + "IpInfoError", + "IpLocation", +] diff --git a/hyphen/feature_toggle.py b/hyphen/feature_toggle.py index ec3c499..fc2bdf5 100644 --- a/hyphen/feature_toggle.py +++ b/hyphen/feature_toggle.py @@ -1,19 +1,35 @@ """Feature Toggle management for Hyphen SDK.""" import os -from typing import Any, Dict, List, Optional, Union +from typing import Any, Callable, Dict, List, Optional, Union from hyphen.base_client import BaseClient +from hyphen.types import Evaluation, EvaluationResponse, ToggleContext class FeatureToggle: - """Client for managing feature toggles in Hyphen.""" + """Client for managing feature toggles in Hyphen. + + Supports targeting context for personalized feature flag evaluation. + + Example: + >>> from hyphen import FeatureToggle, ToggleContext + >>> toggle = FeatureToggle( + ... application_id="your_app_id", + ... api_key="your_api_key", + ... default_context=ToggleContext(targeting_key="user_123") + ... ) + >>> enabled = toggle.get_boolean("my-feature", default=False) + """ def __init__( self, application_id: Optional[str] = None, + environment: Optional[str] = None, api_key: Optional[str] = None, base_url: str = "https://api.hyphen.ai", + default_context: Optional[ToggleContext] = None, + on_error: Optional[Callable[[Exception], None]] = None, ): """ Initialize the FeatureToggle client. @@ -21,11 +37,16 @@ def __init__( Args: application_id: Application ID. If not provided, will check HYPHEN_APPLICATION_ID env var. + environment: Environment name (e.g., "production", "staging"). + If not provided, will check HYPHEN_ENVIRONMENT env var, + defaulting to "production". api_key: API key for authentication. If not provided, will check HYPHEN_API_KEY or HYPHEN_PUBLIC_API_KEY env var. base_url: Base URL for the Hyphen API. + default_context: Default targeting context for all evaluations. + on_error: Callback function for error handling. If provided, + errors will be passed to this callback instead of being raised. """ - # Try to get API key from different sources resolved_api_key = ( api_key or os.environ.get("HYPHEN_API_KEY") @@ -39,52 +60,237 @@ def __init__( "HYPHEN_APPLICATION_ID environment variable." ) + self.environment = ( + environment or os.environ.get("HYPHEN_ENVIRONMENT") or "production" + ) + self.default_context = default_context + self.on_error = on_error self.client = BaseClient(api_key=resolved_api_key, base_url=base_url) - def get_toggle(self, toggle_name: str) -> Union[bool, int, float, str, Dict[str, Any]]: + def _build_payload( + self, context: Optional[ToggleContext] = None + ) -> Dict[str, Any]: + """Build the API request payload for toggle evaluation.""" + ctx = context or self.default_context or ToggleContext() + + payload: Dict[str, Any] = { + "application": self.application_id, + "environment": self.environment, + } + + if ctx.targeting_key: + payload["targetingKey"] = ctx.targeting_key + if ctx.ip_address: + payload["ipAddress"] = ctx.ip_address + if ctx.user: + # Convert snake_case to camelCase for API + user_payload: Dict[str, Any] = {} + for key, value in ctx.user.items(): + if key == "custom_attributes": + user_payload["customAttributes"] = value + else: + user_payload[key] = value + payload["user"] = user_payload + if ctx.custom_attributes: + payload["customAttributes"] = ctx.custom_attributes + + return payload + + def _handle_error(self, error: Exception, default: Any) -> Any: + """Handle errors based on on_error callback configuration.""" + if self.on_error: + self.on_error(error) + return default + raise error + + def evaluate( + self, context: Optional[ToggleContext] = None + ) -> EvaluationResponse: """ - Get a single feature toggle by name. + Evaluate all feature toggles for the given context. Args: - toggle_name: Name of the toggle to retrieve + context: Targeting context for evaluation. If not provided, + uses the default_context. Returns: - The toggle value (can be boolean, number, string, or JSON object) + EvaluationResponse containing all toggle evaluations. Raises: - requests.HTTPError: If the request fails + requests.HTTPError: If the request fails and no on_error callback is set. + """ + try: + payload = self._build_payload(context) + response = self.client.post("/toggle/evaluate", data=payload) + + toggles: Dict[str, Evaluation] = {} + if isinstance(response, dict) and "toggles" in response: + for name, toggle_data in response["toggles"].items(): + toggles[name] = Evaluation( + key=name, + value=toggle_data.get("value"), + value_type=toggle_data.get("type", "unknown"), + reason=toggle_data.get("reason", ""), + error_message=toggle_data.get("errorMessage"), + ) + return EvaluationResponse(toggles=toggles) + except Exception as e: + self._handle_error(e, None) + return EvaluationResponse(toggles={}) + + def get_toggle( + self, + toggle_name: str, + default: Any = None, + context: Optional[ToggleContext] = None, + ) -> Any: + """ + Get a single feature toggle value by name. + + Args: + toggle_name: Name of the toggle to retrieve. + default: Default value to return if toggle is not found or on error. + context: Targeting context for evaluation. + + Returns: + The toggle value, or the default if not found. + + Raises: + requests.HTTPError: If the request fails and no on_error callback is set. + """ + try: + payload = self._build_payload(context) + payload["toggles"] = [toggle_name] + response = self.client.post("/toggle/evaluate", data=payload) + + if isinstance(response, dict) and "toggles" in response: + toggle_data = response["toggles"].get(toggle_name) + if toggle_data is not None: + return toggle_data.get("value", default) + return default + except Exception as e: + return self._handle_error(e, default) + + def get_boolean( + self, + toggle_name: str, + default: bool = False, + context: Optional[ToggleContext] = None, + ) -> bool: + """ + Get a boolean feature toggle value. + + Args: + toggle_name: Name of the toggle to retrieve. + default: Default value if toggle is not found or not a boolean. + context: Targeting context for evaluation. + + Returns: + The boolean toggle value, or the default. + """ + value = self.get_toggle(toggle_name, default=default, context=context) + if isinstance(value, bool): + return value + return default + + def get_string( + self, + toggle_name: str, + default: str = "", + context: Optional[ToggleContext] = None, + ) -> str: + """ + Get a string feature toggle value. + + Args: + toggle_name: Name of the toggle to retrieve. + default: Default value if toggle is not found or not a string. + context: Targeting context for evaluation. + + Returns: + The string toggle value, or the default. """ - endpoint = f"/api/applications/{self.application_id}/toggles/{toggle_name}" - response = self.client.get(endpoint) + value = self.get_toggle(toggle_name, default=default, context=context) + if isinstance(value, str): + return value + return default - # Return the value from the response - if isinstance(response, dict) and "value" in response: - return response["value"] - return response + def get_number( + self, + toggle_name: str, + default: Union[int, float] = 0, + context: Optional[ToggleContext] = None, + ) -> Union[int, float]: + """ + Get a numeric feature toggle value. + + Args: + toggle_name: Name of the toggle to retrieve. + default: Default value if toggle is not found or not a number. + context: Targeting context for evaluation. + + Returns: + The numeric toggle value, or the default. + """ + value = self.get_toggle(toggle_name, default=default, context=context) + if isinstance(value, (int, float)) and not isinstance(value, bool): + return value + return default + + def get_object( + self, + toggle_name: str, + default: Optional[Dict[str, Any]] = None, + context: Optional[ToggleContext] = None, + ) -> Dict[str, Any]: + """ + Get a JSON object feature toggle value. + + Args: + toggle_name: Name of the toggle to retrieve. + default: Default value if toggle is not found or not an object. + context: Targeting context for evaluation. + + Returns: + The object toggle value, or the default. + """ + if default is None: + default = {} + value = self.get_toggle(toggle_name, default=default, context=context) + if isinstance(value, dict): + return value + return default def get_toggles( - self, toggle_names: List[str] - ) -> Dict[str, Union[bool, int, float, str, Dict[str, Any]]]: + self, + toggle_names: List[str], + context: Optional[ToggleContext] = None, + ) -> Dict[str, Any]: """ - Get multiple feature toggles by their names. + Get multiple feature toggle values by their names. Args: - toggle_names: List of toggle names to retrieve + toggle_names: List of toggle names to retrieve. + context: Targeting context for evaluation. Returns: - Dictionary mapping toggle names to their values + Dictionary mapping toggle names to their values. Raises: - requests.HTTPError: If the request fails - """ - endpoint = f"/api/applications/{self.application_id}/toggles" - params = {"names": ",".join(toggle_names)} - response = self.client.get(endpoint, params=params) - - # Parse the response and return a dictionary of toggle names to values - if isinstance(response, list): - return {item["name"]: item["value"] for item in response} - elif isinstance(response, dict): - # If the response is already a dict, return it as-is - return response - return {} + requests.HTTPError: If the request fails and no on_error callback is set. + """ + try: + payload = self._build_payload(context) + payload["toggles"] = toggle_names + response = self.client.post("/toggle/evaluate", data=payload) + + result: Dict[str, Any] = {} + if isinstance(response, dict) and "toggles" in response: + for name in toggle_names: + toggle_data = response["toggles"].get(name) + if toggle_data is not None: + result[name] = toggle_data.get("value") + return result + except Exception as e: + self._handle_error(e, None) + return {} diff --git a/hyphen/link.py b/hyphen/link.py index abaf055..e2cec4f 100644 --- a/hyphen/link.py +++ b/hyphen/link.py @@ -2,17 +2,18 @@ import os from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, cast from hyphen.base_client import BaseClient - - -class QrSize: - """QR code size constants.""" - - SMALL = "small" - MEDIUM = "medium" - LARGE = "large" +from hyphen.types import ( + CreateQrCodeOptions, + CreateShortCodeOptions, + QrCode, + QrCodesResponse, + ShortCode, + ShortCodesResponse, + UpdateShortCodeOptions, +) class Link: @@ -47,8 +48,8 @@ def create_short_code( self, long_url: str, domain: str, - options: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: + options: Optional[CreateShortCodeOptions] = None, + ) -> ShortCode: """ Create a new short code. @@ -58,26 +59,27 @@ def create_short_code( options: Optional parameters like tags, title, etc. Returns: - Dictionary containing the created short code information + ShortCode object containing the created short code information Raises: requests.HTTPError: If the request fails """ endpoint = f"/api/organizations/{self.organization_id}/link/codes" - data = { + data: Dict[str, Any] = { "long_url": long_url, "domain": domain, } if options: data.update(options) - return self.client.post(endpoint, data=data) + response = self.client.post(endpoint, data=data) + return ShortCode.from_dict(response) def update_short_code( self, code: str, - options: Dict[str, Any], - ) -> Dict[str, Any]: + options: UpdateShortCodeOptions, + ) -> ShortCode: """ Update an existing short code. @@ -86,15 +88,16 @@ def update_short_code( options: Parameters to update (title, tags, long_url, etc.) Returns: - Dictionary containing the updated short code information + ShortCode object containing the updated short code information Raises: requests.HTTPError: If the request fails """ endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}" - return self.client.put(endpoint, data=options) + response = self.client.put(endpoint, data=cast(Dict[str, Any], options)) + return ShortCode.from_dict(response) - def get_short_code(self, code: str) -> Dict[str, Any]: + def get_short_code(self, code: str) -> ShortCode: """ Get a specific short code by its identifier. @@ -102,28 +105,33 @@ def get_short_code(self, code: str) -> Dict[str, Any]: code: The code identifier for the short code to retrieve Returns: - Dictionary containing the short code information + ShortCode object containing the short code information Raises: requests.HTTPError: If the request fails """ endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}" - return self.client.get(endpoint) + response = self.client.get(endpoint) + return ShortCode.from_dict(response) def get_short_codes( self, title: Optional[str] = None, tags: Optional[List[str]] = None, - ) -> List[Dict[str, Any]]: + page_number: Optional[int] = None, + page_size: Optional[int] = None, + ) -> ShortCodesResponse: """ Get a list of short codes with optional filtering. Args: title: Optional title to filter short codes tags: Optional list of tags to filter short codes + page_number: Optional page number for pagination + page_size: Optional page size for pagination Returns: - List of dictionaries containing short code information + ShortCodesResponse with paginated list of short codes Raises: requests.HTTPError: If the request fails @@ -135,8 +143,13 @@ def get_short_codes( params["title"] = title if tags: params["tags"] = ",".join(tags) + if page_number is not None: + params["pageNum"] = page_number + if page_size is not None: + params["pageSize"] = page_size - return self.client.get(endpoint, params=params if params else None) + response = self.client.get(endpoint, params=params if params else None) + return ShortCodesResponse.from_dict(response) def get_tags(self) -> List[str]: """ @@ -149,7 +162,8 @@ def get_tags(self) -> List[str]: requests.HTTPError: If the request fails """ endpoint = f"/api/organizations/{self.organization_id}/link/tags" - return self.client.get(endpoint) + response = self.client.get(endpoint) + return list(response) if response else [] def get_short_code_stats( self, @@ -179,29 +193,26 @@ def get_short_code_stats( if end_date: params["end_date"] = end_date.isoformat() - return self.client.get(endpoint, params=params if params else None) + return dict(self.client.get(endpoint, params=params if params else None)) - def delete_short_code(self, code: str) -> Any: + def delete_short_code(self, code: str) -> None: """ Delete a short code. Args: code: The code identifier for the short code to delete - Returns: - Response data (may be None for successful deletion) - Raises: requests.HTTPError: If the request fails """ endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}" - return self.client.delete(endpoint) + self.client.delete(endpoint) def create_qr_code( self, code: str, - options: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: + options: Optional[CreateQrCodeOptions] = None, + ) -> QrCode: """ Create a QR code for a short code. @@ -210,16 +221,24 @@ def create_qr_code( options: Optional parameters (title, backgroundColor, color, size, logo) Returns: - Dictionary containing the QR code information + QrCode object containing the QR code information Raises: requests.HTTPError: If the request fails """ endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}/qr" - data = options or {} - return self.client.post(endpoint, data=data) - - def get_qr_code(self, code: str, qr_id: str) -> Dict[str, Any]: + data: Dict[str, Any] = {} + if options: + # Convert snake_case to camelCase for API + for key, value in options.items(): + if key == "background_color": + data["backgroundColor"] = value + else: + data[key] = value + response = self.client.post(endpoint, data=data) + return QrCode.from_dict(response) + + def get_qr_code(self, code: str, qr_id: str) -> QrCode: """ Get a specific QR code by its ID. @@ -228,31 +247,46 @@ def get_qr_code(self, code: str, qr_id: str) -> Dict[str, Any]: qr_id: The ID of the QR code to retrieve Returns: - Dictionary containing the QR code information + QrCode object containing the QR code information Raises: requests.HTTPError: If the request fails """ endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}/qr/{qr_id}" - return self.client.get(endpoint) + response = self.client.get(endpoint) + return QrCode.from_dict(response) - def get_qr_codes(self, code: str) -> List[Dict[str, Any]]: + def get_qr_codes( + self, + code: str, + page_number: Optional[int] = None, + page_size: Optional[int] = None, + ) -> QrCodesResponse: """ Get all QR codes for a short code. Args: code: The code identifier for the short code + page_number: Optional page number for pagination + page_size: Optional page size for pagination Returns: - List of dictionaries containing QR code information + QrCodesResponse with paginated list of QR codes Raises: requests.HTTPError: If the request fails """ endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}/qr" - return self.client.get(endpoint) + params: Dict[str, Any] = {} + if page_number is not None: + params["pageNum"] = page_number + if page_size is not None: + params["pageSize"] = page_size + + response = self.client.get(endpoint, params=params if params else None) + return QrCodesResponse.from_dict(response) - def delete_qr_code(self, code: str, qr_id: str) -> Any: + def delete_qr_code(self, code: str, qr_id: str) -> None: """ Delete a QR code. @@ -260,11 +294,8 @@ def delete_qr_code(self, code: str, qr_id: str) -> Any: code: The code identifier for the short code qr_id: The ID of the QR code to delete - Returns: - Response data (may be None for successful deletion) - Raises: requests.HTTPError: If the request fails """ endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}/qr/{qr_id}" - return self.client.delete(endpoint) + self.client.delete(endpoint) diff --git a/hyphen/net_info.py b/hyphen/net_info.py index 9fd7a83..653a4df 100644 --- a/hyphen/net_info.py +++ b/hyphen/net_info.py @@ -1,8 +1,9 @@ """NetInfo for IP geolocation in Hyphen SDK.""" -from typing import Any, Dict, List, Optional +from typing import List, Optional, Union from hyphen.base_client import BaseClient +from hyphen.types import IpInfo, IpInfoError class NetInfo: @@ -22,7 +23,7 @@ def __init__( """ self.client = BaseClient(api_key=api_key, base_url=base_url) - def get_ip_info(self, ip_address: str) -> Dict[str, Any]: + def get_ip_info(self, ip_address: str) -> Union[IpInfo, IpInfoError]: """ Get geolocation information for a single IP address. @@ -30,15 +31,18 @@ def get_ip_info(self, ip_address: str) -> Dict[str, Any]: ip_address: IP address to look up Returns: - Dictionary containing IP geolocation information + IpInfo with geolocation data, or IpInfoError if lookup failed Raises: requests.HTTPError: If the request fails """ endpoint = f"/api/net-info/ip/{ip_address}" - return self.client.get(endpoint) + response = self.client.get(endpoint) + if "errorMessage" in response: + return IpInfoError.from_dict(response) + return IpInfo.from_dict(response) - def get_ip_infos(self, ip_addresses: List[str]) -> List[Dict[str, Any]]: + def get_ip_infos(self, ip_addresses: List[str]) -> List[Union[IpInfo, IpInfoError]]: """ Get geolocation information for multiple IP addresses. @@ -46,11 +50,18 @@ def get_ip_infos(self, ip_addresses: List[str]) -> List[Dict[str, Any]]: ip_addresses: List of IP addresses to look up Returns: - List of dictionaries containing IP geolocation information + List of IpInfo or IpInfoError objects for each IP Raises: requests.HTTPError: If the request fails """ endpoint = "/api/net-info/ips" data = {"ips": ip_addresses} - return self.client.post(endpoint, data=data) + response = self.client.post(endpoint, data=data) + results: List[Union[IpInfo, IpInfoError]] = [] + for item in response: + if "errorMessage" in item: + results.append(IpInfoError.from_dict(item)) + else: + results.append(IpInfo.from_dict(item)) + return results diff --git a/hyphen/types.py b/hyphen/types.py new file mode 100644 index 0000000..ac9c828 --- /dev/null +++ b/hyphen/types.py @@ -0,0 +1,362 @@ +"""Type definitions for Hyphen SDK.""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, List, Optional, Union + +from typing_extensions import TypedDict + +# Toggle Types + +class UserContext(TypedDict, total=False): + """User context for feature toggle targeting. + + Attributes: + id: Unique identifier for the user (required for targeting). + email: User's email address. + name: User's display name. + custom_attributes: Additional custom attributes for targeting. + """ + + id: str + email: str + name: str + custom_attributes: Dict[str, Any] + + +@dataclass +class ToggleContext: + """Context for evaluating feature toggles. + + Provides targeting information for feature toggle evaluation, including + user identity, IP address, and custom attributes. + + Attributes: + targeting_key: Primary key for targeting (e.g., user ID, session ID). + ip_address: IP address for geo-based targeting. + user: User context with identity information. + custom_attributes: Additional attributes for custom targeting rules. + """ + + targeting_key: str = "" + ip_address: str = "" + user: Optional[UserContext] = None + custom_attributes: Dict[str, Any] = field(default_factory=dict) + + +class ToggleType(str, Enum): + """Types of toggle values.""" + + BOOLEAN = "boolean" + STRING = "string" + NUMBER = "number" + JSON = "json" + + +@dataclass +class Evaluation: + """Result of a single feature toggle evaluation. + + Attributes: + key: The toggle identifier. + value: The evaluated toggle value. + value_type: The type of the value (boolean, string, number, json). + reason: Explanation of why this value was returned. + error_message: Error message if evaluation failed. + """ + + key: str + value: Union[bool, str, int, float, Dict[str, Any], None] + value_type: str + reason: str = "" + error_message: Optional[str] = None + + +@dataclass +class EvaluationResponse: + """Response from the toggle evaluate endpoint. + + Attributes: + toggles: Dictionary mapping toggle names to their evaluations. + """ + + toggles: Dict[str, Evaluation] + + +# Link Types + +class QrSize(str, Enum): + """QR code size options.""" + + SMALL = "small" + MEDIUM = "medium" + LARGE = "large" + + +class CreateShortCodeOptions(TypedDict, total=False): + """Options for creating a short code. + + Attributes: + code: Custom code to use (optional). + title: Title for the short code. + tags: List of tags to apply. + """ + + code: str + title: str + tags: List[str] + + +class UpdateShortCodeOptions(TypedDict, total=False): + """Options for updating a short code. + + Attributes: + long_url: New long URL. + title: New title. + tags: New list of tags. + """ + + long_url: str + title: str + tags: List[str] + + +class CreateQrCodeOptions(TypedDict, total=False): + """Options for creating a QR code. + + Attributes: + title: Title for the QR code. + background_color: Background color (hex). + color: Foreground color (hex). + size: QR code size. + logo: Logo to embed in the QR code. + """ + + title: str + background_color: str + color: str + size: QrSize + logo: str + + +class OrganizationInfo(TypedDict): + """Organization information in responses.""" + + id: str + name: str + + +@dataclass +class ShortCode: + """A short code/URL response. + + Attributes: + id: Unique identifier for the short code. + code: The short code string. + long_url: The original long URL. + domain: The domain used for the short URL. + created_at: ISO timestamp of creation. + title: Optional title for the short code. + tags: Optional list of tags. + organization_id: Organization info (id and name). + """ + + id: str + code: str + long_url: str + domain: str + created_at: str + title: Optional[str] = None + tags: Optional[List[str]] = None + organization_id: Optional[OrganizationInfo] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ShortCode": + """Create a ShortCode from an API response dictionary.""" + return cls( + id=data.get("id", ""), + code=data.get("code", ""), + long_url=data.get("long_url", ""), + domain=data.get("domain", ""), + created_at=data.get("createdAt", ""), + title=data.get("title"), + tags=data.get("tags"), + organization_id=data.get("organizationId"), + ) + + +@dataclass +class ShortCodesResponse: + """Paginated response for listing short codes. + + Attributes: + total: Total number of short codes. + page_num: Current page number. + page_size: Number of items per page. + data: List of short codes. + """ + + total: int + page_num: int + page_size: int + data: List[ShortCode] + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ShortCodesResponse": + """Create a ShortCodesResponse from an API response dictionary.""" + return cls( + total=data.get("total", 0), + page_num=data.get("pageNum", 0), + page_size=data.get("pageSize", 0), + data=[ShortCode.from_dict(item) for item in data.get("data", [])], + ) + + +@dataclass +class QrCode: + """A QR code response. + + Attributes: + id: Unique identifier for the QR code. + title: Optional title for the QR code. + qr_code: Base64 encoded QR code image. + qr_code_bytes: Raw bytes of the QR code image. + qr_link: URL to the QR code image. + """ + + id: str + title: Optional[str] = None + qr_code: Optional[str] = None + qr_code_bytes: Optional[bytes] = None + qr_link: Optional[str] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "QrCode": + """Create a QrCode from an API response dictionary.""" + qr_code_bytes = None + if "qrCodeBytes" in data and data["qrCodeBytes"]: + # Convert from array to bytes if provided + qr_code_bytes = bytes(data["qrCodeBytes"]) + return cls( + id=data.get("id", ""), + title=data.get("title"), + qr_code=data.get("qrCode"), + qr_code_bytes=qr_code_bytes, + qr_link=data.get("qrLink"), + ) + + +@dataclass +class QrCodesResponse: + """Paginated response for listing QR codes. + + Attributes: + total: Total number of QR codes. + page_num: Current page number. + page_size: Number of items per page. + data: List of QR codes. + """ + + total: int + page_num: int + page_size: int + data: List[QrCode] + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "QrCodesResponse": + """Create a QrCodesResponse from an API response dictionary.""" + return cls( + total=data.get("total", 0), + page_num=data.get("pageNum", 0), + page_size=data.get("pageSize", 0), + data=[QrCode.from_dict(item) for item in data.get("data", [])], + ) + + +# NetInfo Types + +@dataclass +class IpLocation: + """Geographic location information for an IP address. + + Attributes: + country: Country name. + region: Region/state name. + city: City name. + lat: Latitude coordinate. + lng: Longitude coordinate. + postal_code: Postal/ZIP code. + timezone: Timezone identifier. + geoname_id: GeoNames database identifier. + """ + + country: str = "" + region: str = "" + city: str = "" + lat: float = 0.0 + lng: float = 0.0 + postal_code: str = "" + timezone: str = "" + geoname_id: Optional[int] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "IpLocation": + """Create an IpLocation from an API response dictionary.""" + return cls( + country=data.get("country", ""), + region=data.get("region", ""), + city=data.get("city", ""), + lat=data.get("lat", 0.0), + lng=data.get("lng", 0.0), + postal_code=data.get("postalCode", ""), + timezone=data.get("timezone", ""), + geoname_id=data.get("geonameId"), + ) + + +@dataclass +class IpInfo: + """IP address information response. + + Attributes: + ip: The IP address. + ip_type: The IP address type (e.g., "ipv4", "ipv6"). + location: Geographic location information. + """ + + ip: str + ip_type: str + location: IpLocation + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "IpInfo": + """Create an IpInfo from an API response dictionary.""" + return cls( + ip=data.get("ip", ""), + ip_type=data.get("type", ""), + location=IpLocation.from_dict(data.get("location", {})), + ) + + +@dataclass +class IpInfoError: + """Error response for a failed IP lookup. + + Attributes: + ip: The IP address that failed lookup. + error_type: The error type identifier. + error_message: Description of the error. + """ + + ip: str + error_type: str + error_message: str + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "IpInfoError": + """Create an IpInfoError from an API response dictionary.""" + return cls( + ip=data.get("ip", ""), + error_type=data.get("type", ""), + error_message=data.get("errorMessage", ""), + ) diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..a190abb --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +python = "3.12" diff --git a/pyproject.toml b/pyproject.toml index 8445e6c..c48363d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ dependencies = [ "requests>=2.31.0", + "typing_extensions>=4.0.0", ] [project.optional-dependencies] @@ -65,7 +66,7 @@ select = ["E", "F", "I", "N", "W", "UP"] ignore = [] [tool.mypy] -python_version = "3.8" +python_version = "3.9" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true diff --git a/tests/test_feature_toggle.py b/tests/test_feature_toggle.py index de3365a..ebd3442 100644 --- a/tests/test_feature_toggle.py +++ b/tests/test_feature_toggle.py @@ -5,81 +5,465 @@ import pytest -from hyphen import FeatureToggle - - -def test_feature_toggle_init_with_params() -> None: - """Test FeatureToggle initialization with parameters.""" - toggle = FeatureToggle(application_id="app_123", api_key="key_123") - assert toggle.application_id == "app_123" - - -def test_feature_toggle_init_with_env() -> None: - """Test FeatureToggle initialization with environment variables.""" - with patch.dict( - os.environ, - { - "HYPHEN_APPLICATION_ID": "app_env", - "HYPHEN_API_KEY": "key_env", - }, - ): - toggle = FeatureToggle() - assert toggle.application_id == "app_env" - - -def test_feature_toggle_init_missing_app_id() -> None: - """Test FeatureToggle raises error when application ID is missing.""" - with patch.dict(os.environ, {}, clear=True): - with pytest.raises(ValueError, match="Application ID is required"): - FeatureToggle(api_key="test_key") - - -def test_feature_toggle_init_missing_api_key() -> None: - """Test FeatureToggle raises error when API key is missing.""" - with patch.dict(os.environ, {}, clear=True): - with pytest.raises(ValueError, match="API key is required"): - FeatureToggle(application_id="app_123") - - -def test_feature_toggle_prefers_public_api_key() -> None: - """Test FeatureToggle uses HYPHEN_PUBLIC_API_KEY if available.""" - with patch.dict( - os.environ, - { - "HYPHEN_APPLICATION_ID": "app_env", - "HYPHEN_PUBLIC_API_KEY": "public_key", - }, - ): - toggle = FeatureToggle() - assert toggle.client.api_key == "public_key" - - -@patch("hyphen.feature_toggle.BaseClient") -def test_get_toggle(mock_client_class: Mock) -> None: - """Test get_toggle method.""" - mock_client = Mock() - mock_client.get.return_value = {"value": True} - mock_client_class.return_value = mock_client - - toggle = FeatureToggle(application_id="app_123", api_key="key_123") - result = toggle.get_toggle("test-toggle") - - assert result is True - mock_client.get.assert_called_once_with("/api/applications/app_123/toggles/test-toggle") - - -@patch("hyphen.feature_toggle.BaseClient") -def test_get_toggles(mock_client_class: Mock) -> None: - """Test get_toggles method.""" - mock_client = Mock() - mock_client.get.return_value = [ - {"name": "toggle1", "value": True}, - {"name": "toggle2", "value": 42}, - ] - mock_client_class.return_value = mock_client - - toggle = FeatureToggle(application_id="app_123", api_key="key_123") - result = toggle.get_toggles(["toggle1", "toggle2"]) - - assert result == {"toggle1": True, "toggle2": 42} - mock_client.get.assert_called_once() +from hyphen import FeatureToggle, ToggleContext + + +class TestFeatureToggleInit: + """Tests for FeatureToggle initialization.""" + + def test_init_with_params(self) -> None: + """Test initialization with parameters.""" + toggle = FeatureToggle(application_id="the_app_id", api_key="the_api_key") + + assert toggle.application_id == "the_app_id" + assert toggle.environment == "production" + + def test_init_with_env_vars(self) -> None: + """Test initialization with environment variables.""" + with patch.dict( + os.environ, + { + "HYPHEN_APPLICATION_ID": "the_env_app_id", + "HYPHEN_API_KEY": "the_env_api_key", + "HYPHEN_ENVIRONMENT": "the_env_environment", + }, + ): + toggle = FeatureToggle() + + assert toggle.application_id == "the_env_app_id" + assert toggle.environment == "the_env_environment" + + def test_init_missing_app_id_raises_error(self) -> None: + """Test that missing application ID raises ValueError.""" + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValueError, match="Application ID is required"): + FeatureToggle(api_key="a_key") + + def test_init_missing_api_key_raises_error(self) -> None: + """Test that missing API key raises ValueError.""" + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValueError, match="API key is required"): + FeatureToggle(application_id="an_app_id") + + def test_init_prefers_public_api_key(self) -> None: + """Test that HYPHEN_PUBLIC_API_KEY is used when available.""" + with patch.dict( + os.environ, + { + "HYPHEN_APPLICATION_ID": "an_app_id", + "HYPHEN_PUBLIC_API_KEY": "the_public_key", + }, + ): + toggle = FeatureToggle() + + assert toggle.client.api_key == "the_public_key" + + def test_init_environment_defaults_to_production(self) -> None: + """Test that environment defaults to production.""" + with patch.dict(os.environ, {}, clear=True): + toggle = FeatureToggle(application_id="an_app_id", api_key="a_key") + + assert toggle.environment == "production" + + def test_init_with_default_context(self) -> None: + """Test initialization with default context.""" + context = ToggleContext(targeting_key="the_targeting_key") + toggle = FeatureToggle( + application_id="an_app_id", + api_key="a_key", + default_context=context, + ) + + assert toggle.default_context == context + assert toggle.default_context.targeting_key == "the_targeting_key" + + +class TestBuildPayload: + """Tests for _build_payload method.""" + + def test_build_payload_with_minimal_context(self) -> None: + """Test payload building with minimal context.""" + toggle = FeatureToggle( + application_id="the_app_id", + api_key="a_key", + environment="the_environment", + ) + + payload = toggle._build_payload() + + assert payload == { + "application": "the_app_id", + "environment": "the_environment", + } + + def test_build_payload_with_full_context(self) -> None: + """Test payload building with full context.""" + toggle = FeatureToggle(application_id="the_app_id", api_key="a_key") + context = ToggleContext( + targeting_key="the_targeting_key", + ip_address="192.168.1.1", + user={"id": "the_user_id", "email": "user@example.com"}, + custom_attributes={"plan": "premium"}, + ) + + payload = toggle._build_payload(context) + + assert payload["application"] == "the_app_id" + assert payload["targetingKey"] == "the_targeting_key" + assert payload["ipAddress"] == "192.168.1.1" + assert payload["user"] == {"id": "the_user_id", "email": "user@example.com"} + assert payload["customAttributes"] == {"plan": "premium"} + + def test_build_payload_uses_default_context(self) -> None: + """Test that default context is used when no context is provided.""" + default_context = ToggleContext(targeting_key="the_default_key") + toggle = FeatureToggle( + application_id="an_app_id", + api_key="a_key", + default_context=default_context, + ) + + payload = toggle._build_payload() + + assert payload["targetingKey"] == "the_default_key" + + def test_build_payload_override_context_takes_precedence(self) -> None: + """Test that provided context overrides default context.""" + default_context = ToggleContext(targeting_key="default_key") + override_context = ToggleContext(targeting_key="the_override_key") + toggle = FeatureToggle( + application_id="an_app_id", + api_key="a_key", + default_context=default_context, + ) + + payload = toggle._build_payload(override_context) + + assert payload["targetingKey"] == "the_override_key" + + +class TestGetToggle: + """Tests for get_toggle method.""" + + @patch("hyphen.feature_toggle.BaseClient") + def test_get_toggle_returns_value(self, mock_client_class: Mock) -> None: + """Test that get_toggle returns the toggle value.""" + mock_client = Mock() + mock_client.post.return_value = { + "toggles": {"the-toggle": {"value": True, "type": "boolean", "reason": "default"}} + } + mock_client_class.return_value = mock_client + + toggle = FeatureToggle(application_id="an_app_id", api_key="a_key") + result = toggle.get_toggle("the-toggle") + + assert result is True + + @patch("hyphen.feature_toggle.BaseClient") + def test_get_toggle_sends_correct_payload(self, mock_client_class: Mock) -> None: + """Test that get_toggle sends the correct API payload.""" + mock_client = Mock() + mock_client.post.return_value = {"toggles": {"a-toggle": {"value": True}}} + mock_client_class.return_value = mock_client + + toggle = FeatureToggle( + application_id="the_app_id", + api_key="a_key", + environment="the_environment", + ) + toggle.get_toggle("the_toggle_name") + + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + assert call_args[0][0] == "/toggle/evaluate" + assert call_args[1]["data"]["application"] == "the_app_id" + assert call_args[1]["data"]["environment"] == "the_environment" + assert call_args[1]["data"]["toggles"] == ["the_toggle_name"] + + @patch("hyphen.feature_toggle.BaseClient") + def test_get_toggle_returns_default_when_not_found(self, mock_client_class: Mock) -> None: + """Test that get_toggle returns default when toggle not found.""" + mock_client = Mock() + mock_client.post.return_value = {"toggles": {}} + mock_client_class.return_value = mock_client + + toggle = FeatureToggle(application_id="an_app_id", api_key="a_key") + result = toggle.get_toggle("missing-toggle", default="the_default") + + assert result == "the_default" + + @patch("hyphen.feature_toggle.BaseClient") + def test_get_toggle_with_on_error_returns_default(self, mock_client_class: Mock) -> None: + """Test that get_toggle returns default and calls on_error on exception.""" + mock_client = Mock() + mock_client.post.side_effect = Exception("API error") + mock_client_class.return_value = mock_client + + errors: list = [] + toggle = FeatureToggle( + application_id="an_app_id", + api_key="a_key", + on_error=lambda e: errors.append(e), + ) + result = toggle.get_toggle("a-toggle", default="the_default") + + assert result == "the_default" + assert len(errors) == 1 + assert str(errors[0]) == "API error" + + @patch("hyphen.feature_toggle.BaseClient") + def test_get_toggle_without_on_error_raises(self, mock_client_class: Mock) -> None: + """Test that get_toggle raises exception when no on_error callback.""" + mock_client = Mock() + mock_client.post.side_effect = Exception("the API error") + mock_client_class.return_value = mock_client + + toggle = FeatureToggle(application_id="an_app_id", api_key="a_key") + + with pytest.raises(Exception, match="the API error"): + toggle.get_toggle("a-toggle") + + +class TestGetBoolean: + """Tests for get_boolean method.""" + + @patch("hyphen.feature_toggle.BaseClient") + def test_get_boolean_returns_true(self, mock_client_class: Mock) -> None: + """Test that get_boolean returns True when toggle is True.""" + mock_client = Mock() + mock_client.post.return_value = { + "toggles": {"a-toggle": {"value": True, "type": "boolean"}} + } + mock_client_class.return_value = mock_client + + toggle = FeatureToggle(application_id="an_app_id", api_key="a_key") + result = toggle.get_boolean("a-toggle", default=False) + + assert result is True + + @patch("hyphen.feature_toggle.BaseClient") + def test_get_boolean_returns_false(self, mock_client_class: Mock) -> None: + """Test that get_boolean returns False when toggle is False.""" + mock_client = Mock() + mock_client.post.return_value = { + "toggles": {"a-toggle": {"value": False, "type": "boolean"}} + } + mock_client_class.return_value = mock_client + + toggle = FeatureToggle(application_id="an_app_id", api_key="a_key") + result = toggle.get_boolean("a-toggle", default=True) + + assert result is False + + @patch("hyphen.feature_toggle.BaseClient") + def test_get_boolean_returns_default_for_non_boolean(self, mock_client_class: Mock) -> None: + """Test that get_boolean returns default when value is not boolean.""" + mock_client = Mock() + mock_client.post.return_value = { + "toggles": {"a-toggle": {"value": "not a boolean", "type": "string"}} + } + mock_client_class.return_value = mock_client + + toggle = FeatureToggle(application_id="an_app_id", api_key="a_key") + result = toggle.get_boolean("a-toggle", default=True) + + assert result is True + + +class TestGetString: + """Tests for get_string method.""" + + @patch("hyphen.feature_toggle.BaseClient") + def test_get_string_returns_value(self, mock_client_class: Mock) -> None: + """Test that get_string returns string value.""" + mock_client = Mock() + mock_client.post.return_value = { + "toggles": {"a-toggle": {"value": "the_string_value", "type": "string"}} + } + mock_client_class.return_value = mock_client + + toggle = FeatureToggle(application_id="an_app_id", api_key="a_key") + result = toggle.get_string("a-toggle", default="") + + assert result == "the_string_value" + + @patch("hyphen.feature_toggle.BaseClient") + def test_get_string_returns_default_for_non_string(self, mock_client_class: Mock) -> None: + """Test that get_string returns default when value is not string.""" + mock_client = Mock() + mock_client.post.return_value = { + "toggles": {"a-toggle": {"value": 123, "type": "number"}} + } + mock_client_class.return_value = mock_client + + toggle = FeatureToggle(application_id="an_app_id", api_key="a_key") + result = toggle.get_string("a-toggle", default="the_default") + + assert result == "the_default" + + +class TestGetNumber: + """Tests for get_number method.""" + + @patch("hyphen.feature_toggle.BaseClient") + def test_get_number_returns_int(self, mock_client_class: Mock) -> None: + """Test that get_number returns integer value.""" + mock_client = Mock() + mock_client.post.return_value = { + "toggles": {"a-toggle": {"value": 42, "type": "number"}} + } + mock_client_class.return_value = mock_client + + toggle = FeatureToggle(application_id="an_app_id", api_key="a_key") + result = toggle.get_number("a-toggle", default=0) + + assert result == 42 + + @patch("hyphen.feature_toggle.BaseClient") + def test_get_number_returns_float(self, mock_client_class: Mock) -> None: + """Test that get_number returns float value.""" + mock_client = Mock() + mock_client.post.return_value = { + "toggles": {"a-toggle": {"value": 3.14, "type": "number"}} + } + mock_client_class.return_value = mock_client + + toggle = FeatureToggle(application_id="an_app_id", api_key="a_key") + result = toggle.get_number("a-toggle", default=0) + + assert result == 3.14 + + @patch("hyphen.feature_toggle.BaseClient") + def test_get_number_returns_default_for_boolean(self, mock_client_class: Mock) -> None: + """Test that get_number returns default when value is boolean.""" + mock_client = Mock() + mock_client.post.return_value = { + "toggles": {"a-toggle": {"value": True, "type": "boolean"}} + } + mock_client_class.return_value = mock_client + + toggle = FeatureToggle(application_id="an_app_id", api_key="a_key") + result = toggle.get_number("a-toggle", default=99) + + assert result == 99 + + +class TestGetObject: + """Tests for get_object method.""" + + @patch("hyphen.feature_toggle.BaseClient") + def test_get_object_returns_dict(self, mock_client_class: Mock) -> None: + """Test that get_object returns dictionary value.""" + mock_client = Mock() + mock_client.post.return_value = { + "toggles": {"a-toggle": {"value": {"key": "the_value"}, "type": "json"}} + } + mock_client_class.return_value = mock_client + + toggle = FeatureToggle(application_id="an_app_id", api_key="a_key") + result = toggle.get_object("a-toggle") + + assert result == {"key": "the_value"} + + @patch("hyphen.feature_toggle.BaseClient") + def test_get_object_returns_default_for_non_dict(self, mock_client_class: Mock) -> None: + """Test that get_object returns default when value is not dict.""" + mock_client = Mock() + mock_client.post.return_value = { + "toggles": {"a-toggle": {"value": "not a dict", "type": "string"}} + } + mock_client_class.return_value = mock_client + + toggle = FeatureToggle(application_id="an_app_id", api_key="a_key") + result = toggle.get_object("a-toggle", default={"default": "the_default_value"}) + + assert result == {"default": "the_default_value"} + + @patch("hyphen.feature_toggle.BaseClient") + def test_get_object_default_is_empty_dict(self, mock_client_class: Mock) -> None: + """Test that get_object default is empty dict when not specified.""" + mock_client = Mock() + mock_client.post.return_value = {"toggles": {}} + mock_client_class.return_value = mock_client + + toggle = FeatureToggle(application_id="an_app_id", api_key="a_key") + result = toggle.get_object("missing-toggle") + + assert result == {} + + +class TestGetToggles: + """Tests for get_toggles method.""" + + @patch("hyphen.feature_toggle.BaseClient") + def test_get_toggles_returns_multiple_values(self, mock_client_class: Mock) -> None: + """Test that get_toggles returns multiple toggle values.""" + mock_client = Mock() + mock_client.post.return_value = { + "toggles": { + "toggle-a": {"value": True, "type": "boolean"}, + "toggle-b": {"value": 42, "type": "number"}, + "toggle-c": {"value": "the_string", "type": "string"}, + } + } + mock_client_class.return_value = mock_client + + toggle = FeatureToggle(application_id="an_app_id", api_key="a_key") + result = toggle.get_toggles(["toggle-a", "toggle-b", "toggle-c"]) + + assert result == {"toggle-a": True, "toggle-b": 42, "toggle-c": "the_string"} + + @patch("hyphen.feature_toggle.BaseClient") + def test_get_toggles_sends_toggle_names_in_payload(self, mock_client_class: Mock) -> None: + """Test that get_toggles sends toggle names in payload.""" + mock_client = Mock() + mock_client.post.return_value = {"toggles": {}} + mock_client_class.return_value = mock_client + + toggle = FeatureToggle(application_id="an_app_id", api_key="a_key") + toggle.get_toggles(["the-toggle-1", "the-toggle-2"]) + + call_args = mock_client.post.call_args + assert call_args[1]["data"]["toggles"] == ["the-toggle-1", "the-toggle-2"] + + +class TestEvaluate: + """Tests for evaluate method.""" + + @patch("hyphen.feature_toggle.BaseClient") + def test_evaluate_returns_evaluation_response(self, mock_client_class: Mock) -> None: + """Test that evaluate returns EvaluationResponse with Evaluation objects.""" + mock_client = Mock() + mock_client.post.return_value = { + "toggles": { + "the-toggle": { + "value": True, + "type": "boolean", + "reason": "the_reason", + } + } + } + mock_client_class.return_value = mock_client + + toggle = FeatureToggle(application_id="an_app_id", api_key="a_key") + result = toggle.evaluate() + + assert "the-toggle" in result.toggles + assert result.toggles["the-toggle"].key == "the-toggle" + assert result.toggles["the-toggle"].value is True + assert result.toggles["the-toggle"].value_type == "boolean" + assert result.toggles["the-toggle"].reason == "the_reason" + + @patch("hyphen.feature_toggle.BaseClient") + def test_evaluate_with_context(self, mock_client_class: Mock) -> None: + """Test that evaluate passes context to API.""" + mock_client = Mock() + mock_client.post.return_value = {"toggles": {}} + mock_client_class.return_value = mock_client + + toggle = FeatureToggle(application_id="the_app_id", api_key="a_key") + context = ToggleContext(targeting_key="the_targeting_key") + toggle.evaluate(context) + + call_args = mock_client.post.call_args + assert call_args[1]["data"]["targetingKey"] == "the_targeting_key" diff --git a/tests/test_link.py b/tests/test_link.py index e0f110c..0ac17f3 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -6,7 +6,7 @@ import pytest -from hyphen import Link +from hyphen import Link, QrCode, QrCodesResponse, ShortCode, ShortCodesResponse def test_link_init_with_params() -> None: @@ -37,9 +37,16 @@ def test_link_init_missing_org_id() -> None: @patch("hyphen.link.BaseClient") def test_create_short_code(mock_client_class: Mock) -> None: - """Test create_short_code method.""" + """Test create_short_code method returns ShortCode.""" mock_client = Mock() - mock_client.post.return_value = {"code": "abc123", "short_url": "https://test.h4n.link/abc123"} + mock_client.post.return_value = { + "id": "sc_123", + "code": "abc123", + "long_url": "https://hyphen.ai", + "domain": "test.h4n.link", + "createdAt": "2025-01-01T00:00:00Z", + "tags": ["test"], + } mock_client_class.return_value = mock_client link = Link(organization_id="org_123", api_key="key_123") @@ -49,21 +56,31 @@ def test_create_short_code(mock_client_class: Mock) -> None: options={"tags": ["test"]}, ) - assert result["code"] == "abc123" + assert isinstance(result, ShortCode) + assert result.code == "abc123" + assert result.long_url == "https://hyphen.ai" mock_client.post.assert_called_once() @patch("hyphen.link.BaseClient") def test_update_short_code(mock_client_class: Mock) -> None: - """Test update_short_code method.""" + """Test update_short_code method returns ShortCode.""" mock_client = Mock() - mock_client.put.return_value = {"code": "abc123", "title": "Updated"} + mock_client.put.return_value = { + "id": "sc_123", + "code": "abc123", + "long_url": "https://hyphen.ai", + "domain": "test.h4n.link", + "createdAt": "2025-01-01T00:00:00Z", + "title": "Updated", + } mock_client_class.return_value = mock_client link = Link(organization_id="org_123", api_key="key_123") result = link.update_short_code("abc123", {"title": "Updated"}) - assert result["title"] == "Updated" + assert isinstance(result, ShortCode) + assert result.title == "Updated" mock_client.put.assert_called_once_with( "/api/organizations/org_123/link/codes/abc123", data={"title": "Updated"} @@ -72,29 +89,54 @@ def test_update_short_code(mock_client_class: Mock) -> None: @patch("hyphen.link.BaseClient") def test_get_short_code(mock_client_class: Mock) -> None: - """Test get_short_code method.""" + """Test get_short_code method returns ShortCode.""" mock_client = Mock() - mock_client.get.return_value = {"code": "abc123", "long_url": "https://hyphen.ai"} + mock_client.get.return_value = { + "id": "sc_123", + "code": "abc123", + "long_url": "https://hyphen.ai", + "domain": "test.h4n.link", + "createdAt": "2025-01-01T00:00:00Z", + } mock_client_class.return_value = mock_client link = Link(organization_id="org_123", api_key="key_123") result = link.get_short_code("abc123") - assert result["code"] == "abc123" + assert isinstance(result, ShortCode) + assert result.code == "abc123" + assert result.long_url == "https://hyphen.ai" mock_client.get.assert_called_once_with("/api/organizations/org_123/link/codes/abc123") @patch("hyphen.link.BaseClient") def test_get_short_codes(mock_client_class: Mock) -> None: - """Test get_short_codes method.""" + """Test get_short_codes method returns ShortCodesResponse.""" mock_client = Mock() - mock_client.get.return_value = [{"code": "abc123"}, {"code": "def456"}] + mock_client.get.return_value = { + "total": 2, + "pageNum": 1, + "pageSize": 10, + "data": [ + { + "id": "1", "code": "abc123", "long_url": "https://a.com", + "domain": "s.lnk", "createdAt": "2025-01-01" + }, + { + "id": "2", "code": "def456", "long_url": "https://b.com", + "domain": "s.lnk", "createdAt": "2025-01-02" + }, + ], + } mock_client_class.return_value = mock_client link = Link(organization_id="org_123", api_key="key_123") result = link.get_short_codes(title="Test", tags=["tag1", "tag2"]) - assert len(result) == 2 + assert isinstance(result, ShortCodesResponse) + assert result.total == 2 + assert len(result.data) == 2 + assert result.data[0].code == "abc123" mock_client.get.assert_called_once() @@ -131,7 +173,7 @@ def test_get_short_code_stats(mock_client_class: Mock) -> None: @patch("hyphen.link.BaseClient") def test_delete_short_code(mock_client_class: Mock) -> None: - """Test delete_short_code method.""" + """Test delete_short_code method returns None.""" mock_client = Mock() mock_client.delete.return_value = None mock_client_class.return_value = mock_client @@ -145,49 +187,66 @@ def test_delete_short_code(mock_client_class: Mock) -> None: @patch("hyphen.link.BaseClient") def test_create_qr_code(mock_client_class: Mock) -> None: - """Test create_qr_code method.""" + """Test create_qr_code method returns QrCode.""" mock_client = Mock() - mock_client.post.return_value = {"qr_id": "qr_123", "url": "https://example.com/qr.png"} + mock_client.post.return_value = { + "id": "qr_123", + "title": "My QR", + "qrCode": "base64data", + "qrLink": "https://example.com/qr.png", + } mock_client_class.return_value = mock_client link = Link(organization_id="org_123", api_key="key_123") result = link.create_qr_code("abc123", options={"title": "My QR"}) - assert result["qr_id"] == "qr_123" + assert isinstance(result, QrCode) + assert result.id == "qr_123" + assert result.title == "My QR" mock_client.post.assert_called_once() @patch("hyphen.link.BaseClient") def test_get_qr_code(mock_client_class: Mock) -> None: - """Test get_qr_code method.""" + """Test get_qr_code method returns QrCode.""" mock_client = Mock() - mock_client.get.return_value = {"qr_id": "qr_123"} + mock_client.get.return_value = {"id": "qr_123", "title": "My QR"} mock_client_class.return_value = mock_client link = Link(organization_id="org_123", api_key="key_123") result = link.get_qr_code("abc123", "qr_123") - assert result["qr_id"] == "qr_123" + assert isinstance(result, QrCode) + assert result.id == "qr_123" mock_client.get.assert_called_once_with("/api/organizations/org_123/link/codes/abc123/qr/qr_123") @patch("hyphen.link.BaseClient") def test_get_qr_codes(mock_client_class: Mock) -> None: - """Test get_qr_codes method.""" + """Test get_qr_codes method returns QrCodesResponse.""" mock_client = Mock() - mock_client.get.return_value = [{"qr_id": "qr_123"}, {"qr_id": "qr_456"}] + mock_client.get.return_value = { + "total": 2, + "pageNum": 1, + "pageSize": 10, + "data": [{"id": "qr_123"}, {"id": "qr_456"}], + } mock_client_class.return_value = mock_client link = Link(organization_id="org_123", api_key="key_123") result = link.get_qr_codes("abc123") - assert len(result) == 2 - mock_client.get.assert_called_once_with("/api/organizations/org_123/link/codes/abc123/qr") + assert isinstance(result, QrCodesResponse) + assert result.total == 2 + assert len(result.data) == 2 + mock_client.get.assert_called_once_with( + "/api/organizations/org_123/link/codes/abc123/qr", params=None + ) @patch("hyphen.link.BaseClient") def test_delete_qr_code(mock_client_class: Mock) -> None: - """Test delete_qr_code method.""" + """Test delete_qr_code method returns None.""" mock_client = Mock() mock_client.delete.return_value = None mock_client_class.return_value = mock_client diff --git a/tests/test_net_info.py b/tests/test_net_info.py index f24941b..f49b261 100644 --- a/tests/test_net_info.py +++ b/tests/test_net_info.py @@ -2,35 +2,60 @@ from unittest.mock import Mock, patch -from hyphen import NetInfo +from hyphen import IpInfo, IpInfoError, NetInfo @patch("hyphen.net_info.BaseClient") def test_get_ip_info(mock_client_class: Mock) -> None: - """Test get_ip_info method.""" + """Test get_ip_info method returns IpInfo.""" mock_client = Mock() mock_client.get.return_value = { "ip": "8.8.8.8", - "country": "US", - "city": "Mountain View", + "type": "ipv4", + "location": { + "country": "United States", + "city": "Mountain View", + }, } mock_client_class.return_value = mock_client net_info = NetInfo(api_key="key_123") result = net_info.get_ip_info("8.8.8.8") - assert result["ip"] == "8.8.8.8" - assert result["country"] == "US" + assert isinstance(result, IpInfo) + assert result.ip == "8.8.8.8" + assert result.ip_type == "ipv4" + assert result.location.country == "United States" + assert result.location.city == "Mountain View" mock_client.get.assert_called_once_with("/api/net-info/ip/8.8.8.8") +@patch("hyphen.net_info.BaseClient") +def test_get_ip_info_error(mock_client_class: Mock) -> None: + """Test get_ip_info method returns IpInfoError on error response.""" + mock_client = Mock() + mock_client.get.return_value = { + "ip": "invalid", + "type": "error", + "errorMessage": "Invalid IP address", + } + mock_client_class.return_value = mock_client + + net_info = NetInfo(api_key="key_123") + result = net_info.get_ip_info("invalid") + + assert isinstance(result, IpInfoError) + assert result.ip == "invalid" + assert result.error_message == "Invalid IP address" + + @patch("hyphen.net_info.BaseClient") def test_get_ip_infos(mock_client_class: Mock) -> None: - """Test get_ip_infos method.""" + """Test get_ip_infos method returns list of IpInfo.""" mock_client = Mock() mock_client.post.return_value = [ - {"ip": "8.8.8.8", "country": "US"}, - {"ip": "1.1.1.1", "country": "AU"}, + {"ip": "8.8.8.8", "type": "ipv4", "location": {"country": "United States"}}, + {"ip": "1.1.1.1", "type": "ipv4", "location": {"country": "Australia"}}, ] mock_client_class.return_value = mock_client @@ -38,9 +63,32 @@ def test_get_ip_infos(mock_client_class: Mock) -> None: result = net_info.get_ip_infos(["8.8.8.8", "1.1.1.1"]) assert len(result) == 2 - assert result[0]["ip"] == "8.8.8.8" - assert result[1]["ip"] == "1.1.1.1" + assert isinstance(result[0], IpInfo) + assert result[0].ip == "8.8.8.8" + assert result[0].location.country == "United States" + assert isinstance(result[1], IpInfo) + assert result[1].ip == "1.1.1.1" + assert result[1].location.country == "Australia" mock_client.post.assert_called_once_with( "/api/net-info/ips", data={"ips": ["8.8.8.8", "1.1.1.1"]} ) + + +@patch("hyphen.net_info.BaseClient") +def test_get_ip_infos_with_errors(mock_client_class: Mock) -> None: + """Test get_ip_infos method handles mixed results with errors.""" + mock_client = Mock() + mock_client.post.return_value = [ + {"ip": "8.8.8.8", "type": "ipv4", "location": {"country": "United States"}}, + {"ip": "invalid", "type": "error", "errorMessage": "Invalid IP"}, + ] + mock_client_class.return_value = mock_client + + net_info = NetInfo(api_key="key_123") + result = net_info.get_ip_infos(["8.8.8.8", "invalid"]) + + assert len(result) == 2 + assert isinstance(result[0], IpInfo) + assert isinstance(result[1], IpInfoError) + assert result[1].error_message == "Invalid IP" diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..5ba0846 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,321 @@ +"""Tests for types module.""" + +from hyphen import ( + Evaluation, + EvaluationResponse, + IpInfo, + IpInfoError, + IpLocation, + QrCode, + QrCodesResponse, + QrSize, + ShortCode, + ShortCodesResponse, + ToggleContext, + ToggleType, +) + + +class TestToggleContext: + """Tests for ToggleContext dataclass.""" + + def test_default_values(self) -> None: + """Test that ToggleContext has correct default values.""" + context = ToggleContext() + + assert context.targeting_key == "" + assert context.ip_address == "" + assert context.user is None + assert context.custom_attributes == {} + + def test_with_targeting_key(self) -> None: + """Test ToggleContext with targeting_key.""" + context = ToggleContext(targeting_key="the_targeting_key") + + assert context.targeting_key == "the_targeting_key" + + def test_with_ip_address(self) -> None: + """Test ToggleContext with ip_address.""" + context = ToggleContext(ip_address="192.168.1.1") + + assert context.ip_address == "192.168.1.1" + + def test_with_user(self) -> None: + """Test ToggleContext with user context.""" + user = {"id": "the_user_id", "email": "user@example.com"} + context = ToggleContext(user=user) + + assert context.user == {"id": "the_user_id", "email": "user@example.com"} + + def test_with_custom_attributes(self) -> None: + """Test ToggleContext with custom_attributes.""" + context = ToggleContext(custom_attributes={"plan": "premium", "beta": True}) + + assert context.custom_attributes == {"plan": "premium", "beta": True} + + def test_with_all_fields(self) -> None: + """Test ToggleContext with all fields populated.""" + context = ToggleContext( + targeting_key="the_key", + ip_address="10.0.0.1", + user={"id": "123", "name": "Test User"}, + custom_attributes={"tier": "enterprise"}, + ) + + assert context.targeting_key == "the_key" + assert context.ip_address == "10.0.0.1" + assert context.user == {"id": "123", "name": "Test User"} + assert context.custom_attributes == {"tier": "enterprise"} + + +class TestEvaluation: + """Tests for Evaluation dataclass.""" + + def test_boolean_value(self) -> None: + """Test Evaluation with boolean value.""" + evaluation = Evaluation( + key="the_key", value=True, value_type="boolean", reason="the_reason" + ) + + assert evaluation.key == "the_key" + assert evaluation.value is True + assert evaluation.value_type == "boolean" + assert evaluation.reason == "the_reason" + + def test_string_value(self) -> None: + """Test Evaluation with string value.""" + evaluation = Evaluation(key="the_key", value="the_value", value_type="string") + + assert evaluation.value == "the_value" + assert evaluation.value_type == "string" + + def test_number_value(self) -> None: + """Test Evaluation with number value.""" + evaluation = Evaluation(key="the_key", value=42, value_type="number", reason="targeting") + + assert evaluation.value == 42 + assert evaluation.value_type == "number" + + def test_json_value(self) -> None: + """Test Evaluation with JSON object value.""" + evaluation = Evaluation( + key="the_key", + value={"key": "the_value", "nested": {"a": 1}}, + value_type="json", + reason="rule_match", + ) + + assert evaluation.value == {"key": "the_value", "nested": {"a": 1}} + assert evaluation.value_type == "json" + assert evaluation.reason == "rule_match" + + def test_with_error_message(self) -> None: + """Test Evaluation with error message.""" + evaluation = Evaluation( + key="the_key", + value=None, + value_type="boolean", + error_message="Toggle not found", + ) + + assert evaluation.error_message == "Toggle not found" + + +class TestEvaluationResponse: + """Tests for EvaluationResponse dataclass.""" + + def test_with_toggles(self) -> None: + """Test EvaluationResponse with toggle evaluations.""" + toggles = { + "feature-a": Evaluation(key="feature-a", value=True, value_type="boolean"), + "feature-b": Evaluation(key="feature-b", value="enabled", value_type="string"), + } + response = EvaluationResponse(toggles=toggles) + + assert len(response.toggles) == 2 + assert response.toggles["feature-a"].value is True + assert response.toggles["feature-b"].value == "enabled" + + +class TestToggleType: + """Tests for ToggleType enum.""" + + def test_toggle_types(self) -> None: + """Test ToggleType enum values.""" + assert ToggleType.BOOLEAN == "boolean" + assert ToggleType.STRING == "string" + assert ToggleType.NUMBER == "number" + assert ToggleType.JSON == "json" + + +class TestShortCode: + """Tests for ShortCode dataclass.""" + + def test_from_dict(self) -> None: + """Test ShortCode.from_dict creates correct object.""" + data = { + "id": "the_id", + "code": "the_code", + "long_url": "https://example.com", + "domain": "short.link", + "createdAt": "2025-01-01T00:00:00Z", + "title": "The Title", + "tags": ["tag1", "tag2"], + } + + short_code = ShortCode.from_dict(data) + + assert short_code.id == "the_id" + assert short_code.code == "the_code" + assert short_code.long_url == "https://example.com" + assert short_code.domain == "short.link" + assert short_code.created_at == "2025-01-01T00:00:00Z" + assert short_code.title == "The Title" + assert short_code.tags == ["tag1", "tag2"] + + +class TestShortCodesResponse: + """Tests for ShortCodesResponse dataclass.""" + + def test_from_dict(self) -> None: + """Test ShortCodesResponse.from_dict creates correct object.""" + data = { + "total": 2, + "pageNum": 1, + "pageSize": 10, + "data": [ + { + "id": "1", "code": "a", "long_url": "https://a.com", + "domain": "s.lnk", "createdAt": "2025-01-01" + }, + { + "id": "2", "code": "b", "long_url": "https://b.com", + "domain": "s.lnk", "createdAt": "2025-01-02" + }, + ], + } + + response = ShortCodesResponse.from_dict(data) + + assert response.total == 2 + assert response.page_num == 1 + assert response.page_size == 10 + assert len(response.data) == 2 + assert response.data[0].code == "a" + + +class TestQrCode: + """Tests for QrCode dataclass.""" + + def test_from_dict(self) -> None: + """Test QrCode.from_dict creates correct object.""" + data = { + "id": "the_qr_id", + "title": "The QR Title", + "qrCode": "base64data", + "qrLink": "https://qr.link/image.png", + } + + qr_code = QrCode.from_dict(data) + + assert qr_code.id == "the_qr_id" + assert qr_code.title == "The QR Title" + assert qr_code.qr_code == "base64data" + assert qr_code.qr_link == "https://qr.link/image.png" + + +class TestQrCodesResponse: + """Tests for QrCodesResponse dataclass.""" + + def test_from_dict(self) -> None: + """Test QrCodesResponse.from_dict creates correct object.""" + data = { + "total": 1, + "pageNum": 1, + "pageSize": 10, + "data": [{"id": "qr1", "title": "QR 1"}], + } + + response = QrCodesResponse.from_dict(data) + + assert response.total == 1 + assert len(response.data) == 1 + assert response.data[0].id == "qr1" + + +class TestQrSize: + """Tests for QrSize enum.""" + + def test_qr_sizes(self) -> None: + """Test QrSize enum values.""" + assert QrSize.SMALL == "small" + assert QrSize.MEDIUM == "medium" + assert QrSize.LARGE == "large" + + +class TestIpLocation: + """Tests for IpLocation dataclass.""" + + def test_from_dict(self) -> None: + """Test IpLocation.from_dict creates correct object.""" + data = { + "country": "United States", + "region": "California", + "city": "San Francisco", + "lat": 37.7749, + "lng": -122.4194, + "postalCode": "94102", + "timezone": "America/Los_Angeles", + "geonameId": 5391959, + } + + location = IpLocation.from_dict(data) + + assert location.country == "United States" + assert location.region == "California" + assert location.city == "San Francisco" + assert location.lat == 37.7749 + assert location.lng == -122.4194 + assert location.postal_code == "94102" + assert location.timezone == "America/Los_Angeles" + assert location.geoname_id == 5391959 + + +class TestIpInfo: + """Tests for IpInfo dataclass.""" + + def test_from_dict(self) -> None: + """Test IpInfo.from_dict creates correct object.""" + data = { + "ip": "8.8.8.8", + "type": "ipv4", + "location": { + "country": "United States", + "city": "Mountain View", + }, + } + + ip_info = IpInfo.from_dict(data) + + assert ip_info.ip == "8.8.8.8" + assert ip_info.ip_type == "ipv4" + assert ip_info.location.country == "United States" + assert ip_info.location.city == "Mountain View" + + +class TestIpInfoError: + """Tests for IpInfoError dataclass.""" + + def test_from_dict(self) -> None: + """Test IpInfoError.from_dict creates correct object.""" + data = { + "ip": "invalid", + "type": "error", + "errorMessage": "Invalid IP address", + } + + error = IpInfoError.from_dict(data) + + assert error.ip == "invalid" + assert error.error_type == "error" + assert error.error_message == "Invalid IP address" From f80c7f98dbad4436a9223dd93da11d3773d2683a Mon Sep 17 00:00:00 2001 From: half-ogre Date: Tue, 20 Jan 2026 10:26:09 -0800 Subject: [PATCH 02/13] require 3.10 or greater --- .github/workflows/tests.yaml | 2 +- pyproject.toml | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index db8db07..39298b5 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ['3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 diff --git a/pyproject.toml b/pyproject.toml index c48363d..055d205 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "hyphen" version = "0.1.0" description = "Python SDK for Hyphen - Feature toggles, IP geolocation, and link shortening" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.10" license = {text = "MIT"} authors = [ {name = "Hyphen, Inc."} @@ -18,11 +18,10 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] dependencies = [ @@ -59,14 +58,14 @@ addopts = "-v --cov=hyphen --cov-report=term-missing" [tool.ruff] line-length = 100 -target-version = "py38" +target-version = "py310" [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP"] ignore = [] [tool.mypy] -python_version = "3.9" +python_version = "3.10" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true From 63204cff52eb385da6583b962d31abbc0e2d23cf Mon Sep 17 00:00:00 2001 From: half-ogre Date: Tue, 20 Jan 2026 11:23:16 -0800 Subject: [PATCH 03/13] acceptance tests against real Hyphebn --- .github/workflows/acceptance.yaml | 27 ++++++ tests/acceptance/README.md | 33 +++++++ tests/acceptance/__init__.py | 1 + tests/acceptance/conftest.py | 43 +++++++++ tests/acceptance/test_link.py | 153 ++++++++++++++++++++++++++++++ tests/acceptance/test_net_info.py | 58 +++++++++++ tests/acceptance/test_toggle.py | 106 +++++++++++++++++++++ 7 files changed, 421 insertions(+) create mode 100644 .github/workflows/acceptance.yaml create mode 100644 tests/acceptance/README.md create mode 100644 tests/acceptance/__init__.py create mode 100644 tests/acceptance/conftest.py create mode 100644 tests/acceptance/test_link.py create mode 100644 tests/acceptance/test_net_info.py create mode 100644 tests/acceptance/test_toggle.py diff --git a/.github/workflows/acceptance.yaml b/.github/workflows/acceptance.yaml new file mode 100644 index 0000000..58d65e5 --- /dev/null +++ b/.github/workflows/acceptance.yaml @@ -0,0 +1,27 @@ +name: Acceptance Tests + +on: + workflow_dispatch: + +jobs: + acceptance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Run acceptance tests + run: pytest tests/acceptance/ -v + env: + HYPHEN_API_KEY: ${{ secrets.HYPHEN_API_KEY }} + HYPHEN_PUBLIC_API_KEY: ${{ secrets.HYPHEN_PUBLIC_API_KEY }} + HYPHEN_APPLICATION_ID: ${{ secrets.HYPHEN_APPLICATION_ID }} + HYPHEN_ORGANIZATION_ID: ${{ secrets.HYPHEN_ORGANIZATION_ID }} + HYPHEN_LINK_DOMAIN: ${{ secrets.HYPHEN_LINK_DOMAIN }} diff --git a/tests/acceptance/README.md b/tests/acceptance/README.md new file mode 100644 index 0000000..8286a72 --- /dev/null +++ b/tests/acceptance/README.md @@ -0,0 +1,33 @@ +# Acceptance Tests + +Integration tests that run against the real Hyphen API. + +## Required Environment Variables + +### Toggle Tests +- `HYPHEN_PUBLIC_API_KEY`: Public API key for Toggle service (starts with `public_`) +- `HYPHEN_APPLICATION_ID`: Application ID for Toggle evaluations + +### NetInfo Tests +- `HYPHEN_API_KEY`: API key for NetInfo service + +### Link Tests +- `HYPHEN_API_KEY`: API key for Link service +- `HYPHEN_ORGANIZATION_ID`: Organization ID +- `HYPHEN_LINK_DOMAIN`: Domain for short codes (e.g., `test.h4n.link`) + +## Running Tests + +Run all acceptance tests: +```bash +pytest tests/acceptance/ -v +``` + +Run specific test suites: +```bash +pytest tests/acceptance/test_toggle.py -v +pytest tests/acceptance/test_net_info.py -v +pytest tests/acceptance/test_link.py -v +``` + +Tests are excluded from normal test runs (require `tests/acceptance/` path). diff --git a/tests/acceptance/__init__.py b/tests/acceptance/__init__.py new file mode 100644 index 0000000..8ac7a8a --- /dev/null +++ b/tests/acceptance/__init__.py @@ -0,0 +1 @@ +"""Acceptance tests for Hyphen SDK.""" diff --git a/tests/acceptance/conftest.py b/tests/acceptance/conftest.py new file mode 100644 index 0000000..e23b599 --- /dev/null +++ b/tests/acceptance/conftest.py @@ -0,0 +1,43 @@ +"""Fixtures for acceptance tests.""" + +import os + +import pytest + + +def _require_env(name: str) -> str: + """Get required environment variable or skip test.""" + value = os.environ.get(name) + if not value: + pytest.skip(f"Missing required environment variable: {name}") + return value + + +@pytest.fixture +def public_api_key() -> str: + """Public API key for Toggle service.""" + return _require_env("HYPHEN_PUBLIC_API_KEY") + + +@pytest.fixture +def application_id() -> str: + """Application ID for Toggle evaluations.""" + return _require_env("HYPHEN_APPLICATION_ID") + + +@pytest.fixture +def api_key() -> str: + """API key for authenticated services.""" + return _require_env("HYPHEN_API_KEY") + + +@pytest.fixture +def organization_id() -> str: + """Organization ID.""" + return _require_env("HYPHEN_ORGANIZATION_ID") + + +@pytest.fixture +def link_domain() -> str: + """Domain for short codes.""" + return _require_env("HYPHEN_LINK_DOMAIN") diff --git a/tests/acceptance/test_link.py b/tests/acceptance/test_link.py new file mode 100644 index 0000000..96a358a --- /dev/null +++ b/tests/acceptance/test_link.py @@ -0,0 +1,153 @@ +"""Acceptance tests for Link.""" + +import time + +from hyphen import Link, QrCode, QrCodesResponse, ShortCode, ShortCodesResponse + + +class TestLinkAcceptance: + """Acceptance tests for Link service.""" + + def test_create_and_delete_short_code( + self, api_key: str, organization_id: str, link_domain: str + ) -> None: + """Test creating and deleting a short code.""" + link = Link(organization_id=organization_id, api_key=api_key) + unique_id = str(int(time.time() * 1000)) + + # Create + result = link.create_short_code( + long_url=f"https://example.com/test-{unique_id}", + domain=link_domain, + options={"title": f"Test Short Code {unique_id}"}, + ) + + assert isinstance(result, ShortCode) + assert result.id != "" + assert result.code != "" + assert result.domain == link_domain + + # Cleanup + link.delete_short_code(result.code) + + def test_get_short_code( + self, api_key: str, organization_id: str, link_domain: str + ) -> None: + """Test getting a short code by code.""" + link = Link(organization_id=organization_id, api_key=api_key) + unique_id = str(int(time.time() * 1000)) + + # Create + created = link.create_short_code( + long_url=f"https://example.com/test-{unique_id}", + domain=link_domain, + ) + + try: + # Get + result = link.get_short_code(created.code) + + assert isinstance(result, ShortCode) + assert result.code == created.code + assert result.id == created.id + finally: + # Cleanup + link.delete_short_code(created.code) + + def test_get_short_codes( + self, api_key: str, organization_id: str + ) -> None: + """Test listing short codes.""" + link = Link(organization_id=organization_id, api_key=api_key) + + result = link.get_short_codes() + + assert isinstance(result, ShortCodesResponse) + assert result.total >= 0 + assert isinstance(result.data, list) + + def test_update_short_code( + self, api_key: str, organization_id: str, link_domain: str + ) -> None: + """Test updating a short code.""" + link = Link(organization_id=organization_id, api_key=api_key) + unique_id = str(int(time.time() * 1000)) + + # Create + created = link.create_short_code( + long_url=f"https://example.com/test-{unique_id}", + domain=link_domain, + options={"title": "Original Title"}, + ) + + try: + # Update + result = link.update_short_code( + created.code, + {"title": "Updated Title"}, + ) + + assert isinstance(result, ShortCode) + assert result.title == "Updated Title" + finally: + # Cleanup + link.delete_short_code(created.code) + + def test_get_tags(self, api_key: str, organization_id: str) -> None: + """Test getting all tags.""" + link = Link(organization_id=organization_id, api_key=api_key) + + result = link.get_tags() + + assert isinstance(result, list) + + def test_create_and_delete_qr_code( + self, api_key: str, organization_id: str, link_domain: str + ) -> None: + """Test creating and deleting a QR code.""" + link = Link(organization_id=organization_id, api_key=api_key) + unique_id = str(int(time.time() * 1000)) + + # Create short code first + short_code = link.create_short_code( + long_url=f"https://example.com/qr-test-{unique_id}", + domain=link_domain, + ) + + try: + # Create QR code + qr = link.create_qr_code( + short_code.code, + options={"title": f"Test QR {unique_id}"}, + ) + + assert isinstance(qr, QrCode) + assert qr.id != "" + + # Delete QR code + link.delete_qr_code(short_code.code, qr.id) + finally: + # Cleanup short code + link.delete_short_code(short_code.code) + + def test_get_qr_codes( + self, api_key: str, organization_id: str, link_domain: str + ) -> None: + """Test listing QR codes for a short code.""" + link = Link(organization_id=organization_id, api_key=api_key) + unique_id = str(int(time.time() * 1000)) + + # Create short code + short_code = link.create_short_code( + long_url=f"https://example.com/qr-list-{unique_id}", + domain=link_domain, + ) + + try: + result = link.get_qr_codes(short_code.code) + + assert isinstance(result, QrCodesResponse) + assert result.total >= 0 + finally: + # Cleanup + link.delete_short_code(short_code.code) diff --git a/tests/acceptance/test_net_info.py b/tests/acceptance/test_net_info.py new file mode 100644 index 0000000..d901a32 --- /dev/null +++ b/tests/acceptance/test_net_info.py @@ -0,0 +1,58 @@ +"""Acceptance tests for NetInfo.""" + +from hyphen import IpInfo, IpInfoError, NetInfo + + +class TestNetInfoAcceptance: + """Acceptance tests for NetInfo service.""" + + def test_get_ip_info_valid_ip(self, api_key: str) -> None: + """Test get_ip_info with a valid IP address.""" + net_info = NetInfo(api_key=api_key) + + result = net_info.get_ip_info("8.8.8.8") + + assert isinstance(result, IpInfo) + assert result.ip == "8.8.8.8" + assert result.ip_type == "ipv4" + assert result.location is not None + assert result.location.country == "United States" + + def test_get_ip_info_ipv6(self, api_key: str) -> None: + """Test get_ip_info with an IPv6 address.""" + net_info = NetInfo(api_key=api_key) + + result = net_info.get_ip_info("2001:4860:4860::8888") + + assert isinstance(result, IpInfo) + assert result.ip_type == "ipv6" + + def test_get_ip_info_invalid_ip(self, api_key: str) -> None: + """Test get_ip_info with an invalid IP address.""" + net_info = NetInfo(api_key=api_key) + + result = net_info.get_ip_info("invalid-ip") + + assert isinstance(result, IpInfoError) + assert result.error_message != "" + + def test_get_ip_infos_multiple(self, api_key: str) -> None: + """Test get_ip_infos with multiple IP addresses.""" + net_info = NetInfo(api_key=api_key) + + result = net_info.get_ip_infos(["8.8.8.8", "1.1.1.1"]) + + assert len(result) == 2 + assert all(isinstance(r, (IpInfo, IpInfoError)) for r in result) + + def test_get_ip_infos_mixed_valid_invalid(self, api_key: str) -> None: + """Test get_ip_infos with mix of valid and invalid IPs.""" + net_info = NetInfo(api_key=api_key) + + result = net_info.get_ip_infos(["8.8.8.8", "invalid-ip"]) + + assert len(result) == 2 + # First should be valid + assert isinstance(result[0], IpInfo) + # Second should be error + assert isinstance(result[1], IpInfoError) diff --git a/tests/acceptance/test_toggle.py b/tests/acceptance/test_toggle.py new file mode 100644 index 0000000..8be349a --- /dev/null +++ b/tests/acceptance/test_toggle.py @@ -0,0 +1,106 @@ +"""Acceptance tests for FeatureToggle.""" + +from hyphen import FeatureToggle, ToggleContext + + +class TestToggleAcceptance: + """Acceptance tests for Toggle service.""" + + def test_evaluate_returns_toggles( + self, public_api_key: str, application_id: str + ) -> None: + """Test that evaluate returns toggle evaluations.""" + toggle = FeatureToggle( + application_id=application_id, + api_key=public_api_key, + ) + + result = toggle.evaluate() + + assert result is not None + assert hasattr(result, "toggles") + assert isinstance(result.toggles, dict) + + def test_get_boolean_with_default( + self, public_api_key: str, application_id: str + ) -> None: + """Test get_boolean returns default for nonexistent toggle.""" + toggle = FeatureToggle( + application_id=application_id, + api_key=public_api_key, + ) + + result = toggle.get_boolean("nonexistent-toggle-abc123", default=True) + + assert result is True + + def test_get_string_with_default( + self, public_api_key: str, application_id: str + ) -> None: + """Test get_string returns default for nonexistent toggle.""" + toggle = FeatureToggle( + application_id=application_id, + api_key=public_api_key, + ) + + result = toggle.get_string("nonexistent-toggle-abc123", default="fallback") + + assert result == "fallback" + + def test_get_number_with_default( + self, public_api_key: str, application_id: str + ) -> None: + """Test get_number returns default for nonexistent toggle.""" + toggle = FeatureToggle( + application_id=application_id, + api_key=public_api_key, + ) + + result = toggle.get_number("nonexistent-toggle-abc123", default=42) + + assert result == 42 + + def test_get_object_with_default( + self, public_api_key: str, application_id: str + ) -> None: + """Test get_object returns default for nonexistent toggle.""" + toggle = FeatureToggle( + application_id=application_id, + api_key=public_api_key, + ) + + result = toggle.get_object("nonexistent-toggle-abc123", default={"key": "value"}) + + assert result == {"key": "value"} + + def test_evaluate_with_targeting_context( + self, public_api_key: str, application_id: str + ) -> None: + """Test evaluate with targeting context.""" + toggle = FeatureToggle( + application_id=application_id, + api_key=public_api_key, + ) + context = ToggleContext( + targeting_key="test-user-123", + ip_address="8.8.8.8", + custom_attributes={"plan": "premium"}, + ) + + result = toggle.evaluate(context) + + assert result is not None + assert isinstance(result.toggles, dict) + + def test_get_toggles_multiple( + self, public_api_key: str, application_id: str + ) -> None: + """Test get_toggles with multiple toggle names.""" + toggle = FeatureToggle( + application_id=application_id, + api_key=public_api_key, + ) + + result = toggle.get_toggles(["toggle-a", "toggle-b", "toggle-c"]) + + assert isinstance(result, dict) From 50e28eed554392f9923c0a41a0423b2f61ca79cf Mon Sep 17 00:00:00 2001 From: half-ogre Date: Tue, 20 Jan 2026 11:27:23 -0800 Subject: [PATCH 04/13] fixed syntax issues with 3.10 floor --- hyphen/base_client.py | 14 +++++------ hyphen/feature_toggle.py | 51 ++++++++++++++++++++------------------- hyphen/link.py | 42 ++++++++++++++++---------------- hyphen/net_info.py | 9 ++++--- hyphen/types.py | 52 ++++++++++++++++++++-------------------- 5 files changed, 84 insertions(+), 84 deletions(-) diff --git a/hyphen/base_client.py b/hyphen/base_client.py index f688d58..2f05afb 100644 --- a/hyphen/base_client.py +++ b/hyphen/base_client.py @@ -1,7 +1,7 @@ """Base client for Hyphen SDK.""" import os -from typing import Any, Dict, Optional +from typing import Any import requests @@ -9,7 +9,7 @@ class BaseClient: """Base client class for making HTTP requests to Hyphen API.""" - def __init__(self, api_key: Optional[str] = None, base_url: str = "https://api.hyphen.ai"): + def __init__(self, api_key: str | None = None, base_url: str = "https://api.hyphen.ai"): """ Initialize the base client. @@ -36,8 +36,8 @@ def _request( self, method: str, endpoint: str, - data: Optional[Dict[str, Any]] = None, - params: Optional[Dict[str, Any]] = None, + data: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, ) -> Any: """ Make an HTTP request to the Hyphen API. @@ -69,15 +69,15 @@ def _request( return response.json() - def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Any: + def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any: """Make a GET request.""" return self._request("GET", endpoint, params=params) - def post(self, endpoint: str, data: Optional[Dict[str, Any]] = None) -> Any: + def post(self, endpoint: str, data: dict[str, Any] | None = None) -> Any: """Make a POST request.""" return self._request("POST", endpoint, data=data) - def put(self, endpoint: str, data: Optional[Dict[str, Any]] = None) -> Any: + def put(self, endpoint: str, data: dict[str, Any] | None = None) -> Any: """Make a PUT request.""" return self._request("PUT", endpoint, data=data) diff --git a/hyphen/feature_toggle.py b/hyphen/feature_toggle.py index fc2bdf5..c6c0bcf 100644 --- a/hyphen/feature_toggle.py +++ b/hyphen/feature_toggle.py @@ -1,7 +1,8 @@ """Feature Toggle management for Hyphen SDK.""" import os -from typing import Any, Callable, Dict, List, Optional, Union +from collections.abc import Callable +from typing import Any from hyphen.base_client import BaseClient from hyphen.types import Evaluation, EvaluationResponse, ToggleContext @@ -24,12 +25,12 @@ class FeatureToggle: def __init__( self, - application_id: Optional[str] = None, - environment: Optional[str] = None, - api_key: Optional[str] = None, + application_id: str | None = None, + environment: str | None = None, + api_key: str | None = None, base_url: str = "https://api.hyphen.ai", - default_context: Optional[ToggleContext] = None, - on_error: Optional[Callable[[Exception], None]] = None, + default_context: ToggleContext | None = None, + on_error: Callable[[Exception], None] | None = None, ): """ Initialize the FeatureToggle client. @@ -68,12 +69,12 @@ def __init__( self.client = BaseClient(api_key=resolved_api_key, base_url=base_url) def _build_payload( - self, context: Optional[ToggleContext] = None - ) -> Dict[str, Any]: + self, context: ToggleContext | None = None + ) -> dict[str, Any]: """Build the API request payload for toggle evaluation.""" ctx = context or self.default_context or ToggleContext() - payload: Dict[str, Any] = { + payload: dict[str, Any] = { "application": self.application_id, "environment": self.environment, } @@ -84,7 +85,7 @@ def _build_payload( payload["ipAddress"] = ctx.ip_address if ctx.user: # Convert snake_case to camelCase for API - user_payload: Dict[str, Any] = {} + user_payload: dict[str, Any] = {} for key, value in ctx.user.items(): if key == "custom_attributes": user_payload["customAttributes"] = value @@ -104,7 +105,7 @@ def _handle_error(self, error: Exception, default: Any) -> Any: raise error def evaluate( - self, context: Optional[ToggleContext] = None + self, context: ToggleContext | None = None ) -> EvaluationResponse: """ Evaluate all feature toggles for the given context. @@ -123,7 +124,7 @@ def evaluate( payload = self._build_payload(context) response = self.client.post("/toggle/evaluate", data=payload) - toggles: Dict[str, Evaluation] = {} + toggles: dict[str, Evaluation] = {} if isinstance(response, dict) and "toggles" in response: for name, toggle_data in response["toggles"].items(): toggles[name] = Evaluation( @@ -142,7 +143,7 @@ def get_toggle( self, toggle_name: str, default: Any = None, - context: Optional[ToggleContext] = None, + context: ToggleContext | None = None, ) -> Any: """ Get a single feature toggle value by name. @@ -175,7 +176,7 @@ def get_boolean( self, toggle_name: str, default: bool = False, - context: Optional[ToggleContext] = None, + context: ToggleContext | None = None, ) -> bool: """ Get a boolean feature toggle value. @@ -197,7 +198,7 @@ def get_string( self, toggle_name: str, default: str = "", - context: Optional[ToggleContext] = None, + context: ToggleContext | None = None, ) -> str: """ Get a string feature toggle value. @@ -218,9 +219,9 @@ def get_string( def get_number( self, toggle_name: str, - default: Union[int, float] = 0, - context: Optional[ToggleContext] = None, - ) -> Union[int, float]: + default: int | float = 0, + context: ToggleContext | None = None, + ) -> int | float: """ Get a numeric feature toggle value. @@ -240,9 +241,9 @@ def get_number( def get_object( self, toggle_name: str, - default: Optional[Dict[str, Any]] = None, - context: Optional[ToggleContext] = None, - ) -> Dict[str, Any]: + default: dict[str, Any] | None = None, + context: ToggleContext | None = None, + ) -> dict[str, Any]: """ Get a JSON object feature toggle value. @@ -263,9 +264,9 @@ def get_object( def get_toggles( self, - toggle_names: List[str], - context: Optional[ToggleContext] = None, - ) -> Dict[str, Any]: + toggle_names: list[str], + context: ToggleContext | None = None, + ) -> dict[str, Any]: """ Get multiple feature toggle values by their names. @@ -284,7 +285,7 @@ def get_toggles( payload["toggles"] = toggle_names response = self.client.post("/toggle/evaluate", data=payload) - result: Dict[str, Any] = {} + result: dict[str, Any] = {} if isinstance(response, dict) and "toggles" in response: for name in toggle_names: toggle_data = response["toggles"].get(name) diff --git a/hyphen/link.py b/hyphen/link.py index e2cec4f..8c06f98 100644 --- a/hyphen/link.py +++ b/hyphen/link.py @@ -2,7 +2,7 @@ import os from datetime import datetime -from typing import Any, Dict, List, Optional, cast +from typing import Any, cast from hyphen.base_client import BaseClient from hyphen.types import ( @@ -21,8 +21,8 @@ class Link: def __init__( self, - organization_id: Optional[str] = None, - api_key: Optional[str] = None, + organization_id: str | None = None, + api_key: str | None = None, base_url: str = "https://api.hyphen.ai", ): """ @@ -48,7 +48,7 @@ def create_short_code( self, long_url: str, domain: str, - options: Optional[CreateShortCodeOptions] = None, + options: CreateShortCodeOptions | None = None, ) -> ShortCode: """ Create a new short code. @@ -65,7 +65,7 @@ def create_short_code( requests.HTTPError: If the request fails """ endpoint = f"/api/organizations/{self.organization_id}/link/codes" - data: Dict[str, Any] = { + data: dict[str, Any] = { "long_url": long_url, "domain": domain, } @@ -94,7 +94,7 @@ def update_short_code( requests.HTTPError: If the request fails """ endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}" - response = self.client.put(endpoint, data=cast(Dict[str, Any], options)) + response = self.client.put(endpoint, data=cast(dict[str, Any], options)) return ShortCode.from_dict(response) def get_short_code(self, code: str) -> ShortCode: @@ -116,10 +116,10 @@ def get_short_code(self, code: str) -> ShortCode: def get_short_codes( self, - title: Optional[str] = None, - tags: Optional[List[str]] = None, - page_number: Optional[int] = None, - page_size: Optional[int] = None, + title: str | None = None, + tags: list[str] | None = None, + page_number: int | None = None, + page_size: int | None = None, ) -> ShortCodesResponse: """ Get a list of short codes with optional filtering. @@ -137,7 +137,7 @@ def get_short_codes( requests.HTTPError: If the request fails """ endpoint = f"/api/organizations/{self.organization_id}/link/codes" - params: Dict[str, Any] = {} + params: dict[str, Any] = {} if title: params["title"] = title @@ -151,7 +151,7 @@ def get_short_codes( response = self.client.get(endpoint, params=params if params else None) return ShortCodesResponse.from_dict(response) - def get_tags(self) -> List[str]: + def get_tags(self) -> list[str]: """ Get all tags for the organization. @@ -168,9 +168,9 @@ def get_tags(self) -> List[str]: def get_short_code_stats( self, code: str, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None, - ) -> Dict[str, Any]: + start_date: datetime | None = None, + end_date: datetime | None = None, + ) -> dict[str, Any]: """ Get statistics for a short code. @@ -186,7 +186,7 @@ def get_short_code_stats( requests.HTTPError: If the request fails """ endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}/stats" - params: Dict[str, Any] = {} + params: dict[str, Any] = {} if start_date: params["start_date"] = start_date.isoformat() @@ -211,7 +211,7 @@ def delete_short_code(self, code: str) -> None: def create_qr_code( self, code: str, - options: Optional[CreateQrCodeOptions] = None, + options: CreateQrCodeOptions | None = None, ) -> QrCode: """ Create a QR code for a short code. @@ -227,7 +227,7 @@ def create_qr_code( requests.HTTPError: If the request fails """ endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}/qr" - data: Dict[str, Any] = {} + data: dict[str, Any] = {} if options: # Convert snake_case to camelCase for API for key, value in options.items(): @@ -259,8 +259,8 @@ def get_qr_code(self, code: str, qr_id: str) -> QrCode: def get_qr_codes( self, code: str, - page_number: Optional[int] = None, - page_size: Optional[int] = None, + page_number: int | None = None, + page_size: int | None = None, ) -> QrCodesResponse: """ Get all QR codes for a short code. @@ -277,7 +277,7 @@ def get_qr_codes( requests.HTTPError: If the request fails """ endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}/qr" - params: Dict[str, Any] = {} + params: dict[str, Any] = {} if page_number is not None: params["pageNum"] = page_number if page_size is not None: diff --git a/hyphen/net_info.py b/hyphen/net_info.py index 653a4df..4df1324 100644 --- a/hyphen/net_info.py +++ b/hyphen/net_info.py @@ -1,6 +1,5 @@ """NetInfo for IP geolocation in Hyphen SDK.""" -from typing import List, Optional, Union from hyphen.base_client import BaseClient from hyphen.types import IpInfo, IpInfoError @@ -11,7 +10,7 @@ class NetInfo: def __init__( self, - api_key: Optional[str] = None, + api_key: str | None = None, base_url: str = "https://api.hyphen.ai", ): """ @@ -23,7 +22,7 @@ def __init__( """ self.client = BaseClient(api_key=api_key, base_url=base_url) - def get_ip_info(self, ip_address: str) -> Union[IpInfo, IpInfoError]: + def get_ip_info(self, ip_address: str) -> IpInfo | IpInfoError: """ Get geolocation information for a single IP address. @@ -42,7 +41,7 @@ def get_ip_info(self, ip_address: str) -> Union[IpInfo, IpInfoError]: return IpInfoError.from_dict(response) return IpInfo.from_dict(response) - def get_ip_infos(self, ip_addresses: List[str]) -> List[Union[IpInfo, IpInfoError]]: + def get_ip_infos(self, ip_addresses: list[str]) -> list[IpInfo | IpInfoError]: """ Get geolocation information for multiple IP addresses. @@ -58,7 +57,7 @@ def get_ip_infos(self, ip_addresses: List[str]) -> List[Union[IpInfo, IpInfoErro endpoint = "/api/net-info/ips" data = {"ips": ip_addresses} response = self.client.post(endpoint, data=data) - results: List[Union[IpInfo, IpInfoError]] = [] + results: list[IpInfo | IpInfoError] = [] for item in response: if "errorMessage" in item: results.append(IpInfoError.from_dict(item)) diff --git a/hyphen/types.py b/hyphen/types.py index ac9c828..92eacdf 100644 --- a/hyphen/types.py +++ b/hyphen/types.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict, List, Optional, Union +from typing import Any from typing_extensions import TypedDict @@ -21,7 +21,7 @@ class UserContext(TypedDict, total=False): id: str email: str name: str - custom_attributes: Dict[str, Any] + custom_attributes: dict[str, Any] @dataclass @@ -40,8 +40,8 @@ class ToggleContext: targeting_key: str = "" ip_address: str = "" - user: Optional[UserContext] = None - custom_attributes: Dict[str, Any] = field(default_factory=dict) + user: UserContext | None = None + custom_attributes: dict[str, Any] = field(default_factory=dict) class ToggleType(str, Enum): @@ -66,10 +66,10 @@ class Evaluation: """ key: str - value: Union[bool, str, int, float, Dict[str, Any], None] + value: bool | str | int | float | dict[str, Any] | None value_type: str reason: str = "" - error_message: Optional[str] = None + error_message: str | None = None @dataclass @@ -80,7 +80,7 @@ class EvaluationResponse: toggles: Dictionary mapping toggle names to their evaluations. """ - toggles: Dict[str, Evaluation] + toggles: dict[str, Evaluation] # Link Types @@ -104,7 +104,7 @@ class CreateShortCodeOptions(TypedDict, total=False): code: str title: str - tags: List[str] + tags: list[str] class UpdateShortCodeOptions(TypedDict, total=False): @@ -118,7 +118,7 @@ class UpdateShortCodeOptions(TypedDict, total=False): long_url: str title: str - tags: List[str] + tags: list[str] class CreateQrCodeOptions(TypedDict, total=False): @@ -166,12 +166,12 @@ class ShortCode: long_url: str domain: str created_at: str - title: Optional[str] = None - tags: Optional[List[str]] = None - organization_id: Optional[OrganizationInfo] = None + title: str | None = None + tags: list[str] | None = None + organization_id: OrganizationInfo | None = None @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "ShortCode": + def from_dict(cls, data: dict[str, Any]) -> "ShortCode": """Create a ShortCode from an API response dictionary.""" return cls( id=data.get("id", ""), @@ -199,10 +199,10 @@ class ShortCodesResponse: total: int page_num: int page_size: int - data: List[ShortCode] + data: list[ShortCode] @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "ShortCodesResponse": + def from_dict(cls, data: dict[str, Any]) -> "ShortCodesResponse": """Create a ShortCodesResponse from an API response dictionary.""" return cls( total=data.get("total", 0), @@ -225,13 +225,13 @@ class QrCode: """ id: str - title: Optional[str] = None - qr_code: Optional[str] = None - qr_code_bytes: Optional[bytes] = None - qr_link: Optional[str] = None + title: str | None = None + qr_code: str | None = None + qr_code_bytes: bytes | None = None + qr_link: str | None = None @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "QrCode": + def from_dict(cls, data: dict[str, Any]) -> "QrCode": """Create a QrCode from an API response dictionary.""" qr_code_bytes = None if "qrCodeBytes" in data and data["qrCodeBytes"]: @@ -260,10 +260,10 @@ class QrCodesResponse: total: int page_num: int page_size: int - data: List[QrCode] + data: list[QrCode] @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "QrCodesResponse": + def from_dict(cls, data: dict[str, Any]) -> "QrCodesResponse": """Create a QrCodesResponse from an API response dictionary.""" return cls( total=data.get("total", 0), @@ -297,10 +297,10 @@ class IpLocation: lng: float = 0.0 postal_code: str = "" timezone: str = "" - geoname_id: Optional[int] = None + geoname_id: int | None = None @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "IpLocation": + def from_dict(cls, data: dict[str, Any]) -> "IpLocation": """Create an IpLocation from an API response dictionary.""" return cls( country=data.get("country", ""), @@ -329,7 +329,7 @@ class IpInfo: location: IpLocation @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "IpInfo": + def from_dict(cls, data: dict[str, Any]) -> "IpInfo": """Create an IpInfo from an API response dictionary.""" return cls( ip=data.get("ip", ""), @@ -353,7 +353,7 @@ class IpInfoError: error_message: str @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "IpInfoError": + def from_dict(cls, data: dict[str, Any]) -> "IpInfoError": """Create an IpInfoError from an API response dictionary.""" return cls( ip=data.get("ip", ""), From 4367fc1b5e33715d074b882cc49bf0b3aa54b409 Mon Sep 17 00:00:00 2001 From: half-ogre Date: Wed, 21 Jan 2026 19:41:54 -0800 Subject: [PATCH 05/13] myriad changes getting acceptance tests passing --- hyphen/base_client.py | 15 +- hyphen/feature_toggle.py | 25 ++- hyphen/link.py | 12 +- hyphen/net_info.py | 18 ++- tests/acceptance/conftest.py | 34 ++++ tests/acceptance/test_link.py | 109 +++++++------ tests/acceptance/test_net_info.py | 46 ++---- tests/acceptance/test_toggle.py | 179 +++++++++++++++++++--- tests/acceptance/testutil/__init__.py | 5 + tests/acceptance/testutil/toggle_admin.py | 133 ++++++++++++++++ 10 files changed, 448 insertions(+), 128 deletions(-) create mode 100644 tests/acceptance/testutil/__init__.py create mode 100644 tests/acceptance/testutil/toggle_admin.py diff --git a/hyphen/base_client.py b/hyphen/base_client.py index 2f05afb..b4a088b 100644 --- a/hyphen/base_client.py +++ b/hyphen/base_client.py @@ -27,8 +27,7 @@ def __init__(self, api_key: str | None = None, base_url: str = "https://api.hyph self.session = requests.Session() self.session.headers.update( { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", + "x-api-key": self.api_key, } ) @@ -55,11 +54,13 @@ def _request( requests.HTTPError: If the request fails """ url = f"{self.base_url}{endpoint}" + headers = {"Content-Type": "application/json"} if data is not None else {} response = self.session.request( method=method, url=url, json=data, params=params, + headers=headers, ) response.raise_for_status() @@ -74,13 +75,21 @@ def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any: return self._request("GET", endpoint, params=params) def post(self, endpoint: str, data: dict[str, Any] | None = None) -> Any: - """Make a POST request.""" + """Make a POST request with a dict body.""" + return self._request("POST", endpoint, data=data) + + def post_raw(self, endpoint: str, data: Any) -> Any: + """Make a POST request with raw data (e.g., a list).""" return self._request("POST", endpoint, data=data) def put(self, endpoint: str, data: dict[str, Any] | None = None) -> Any: """Make a PUT request.""" return self._request("PUT", endpoint, data=data) + def patch(self, endpoint: str, data: dict[str, Any] | None = None) -> Any: + """Make a PATCH request.""" + return self._request("PATCH", endpoint, data=data) + def delete(self, endpoint: str) -> Any: """Make a DELETE request.""" return self._request("DELETE", endpoint) diff --git a/hyphen/feature_toggle.py b/hyphen/feature_toggle.py index c6c0bcf..c471993 100644 --- a/hyphen/feature_toggle.py +++ b/hyphen/feature_toggle.py @@ -1,6 +1,7 @@ """Feature Toggle management for Hyphen SDK.""" import os +import random from collections.abc import Callable from typing import Any @@ -28,7 +29,7 @@ def __init__( application_id: str | None = None, environment: str | None = None, api_key: str | None = None, - base_url: str = "https://api.hyphen.ai", + base_url: str = "https://toggle.hyphen.cloud", default_context: ToggleContext | None = None, on_error: Callable[[Exception], None] | None = None, ): @@ -62,7 +63,7 @@ def __init__( ) self.environment = ( - environment or os.environ.get("HYPHEN_ENVIRONMENT") or "production" + environment or os.environ.get("HYPHEN_ENVIRONMENT") or "development" ) self.default_context = default_context self.on_error = on_error @@ -79,8 +80,14 @@ def _build_payload( "environment": self.environment, } - if ctx.targeting_key: - payload["targetingKey"] = ctx.targeting_key + # targetingKey is required - use provided, user.id, or generate one + targeting_key = ctx.targeting_key + if not targeting_key and ctx.user and ctx.user.get("id"): + targeting_key = ctx.user["id"] + if not targeting_key: + targeting_key = self._generate_targeting_key() + payload["targetingKey"] = targeting_key + if ctx.ip_address: payload["ipAddress"] = ctx.ip_address if ctx.user: @@ -97,6 +104,16 @@ def _build_payload( return payload + def _generate_targeting_key(self) -> str: + """Generate a random targeting key.""" + components = [] + if self.application_id: + components.append(self.application_id) + if self.environment: + components.append(self.environment) + components.append(str(random.randint(0, 2**63 - 1))) + return "-".join(components) + def _handle_error(self, error: Exception, default: Any) -> Any: """Handle errors based on on_error callback configuration.""" if self.on_error: diff --git a/hyphen/link.py b/hyphen/link.py index 8c06f98..50d7658 100644 --- a/hyphen/link.py +++ b/hyphen/link.py @@ -94,7 +94,7 @@ def update_short_code( requests.HTTPError: If the request fails """ endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}" - response = self.client.put(endpoint, data=cast(dict[str, Any], options)) + response = self.client.patch(endpoint, data=cast(dict[str, Any], options)) return ShortCode.from_dict(response) def get_short_code(self, code: str) -> ShortCode: @@ -161,7 +161,7 @@ def get_tags(self) -> list[str]: Raises: requests.HTTPError: If the request fails """ - endpoint = f"/api/organizations/{self.organization_id}/link/tags" + endpoint = f"/api/organizations/{self.organization_id}/link/codes/tags" response = self.client.get(endpoint) return list(response) if response else [] @@ -226,7 +226,7 @@ def create_qr_code( Raises: requests.HTTPError: If the request fails """ - endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}/qr" + endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}/qrs" data: dict[str, Any] = {} if options: # Convert snake_case to camelCase for API @@ -252,7 +252,7 @@ def get_qr_code(self, code: str, qr_id: str) -> QrCode: Raises: requests.HTTPError: If the request fails """ - endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}/qr/{qr_id}" + endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}/qrs/{qr_id}" response = self.client.get(endpoint) return QrCode.from_dict(response) @@ -276,7 +276,7 @@ def get_qr_codes( Raises: requests.HTTPError: If the request fails """ - endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}/qr" + endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}/qrs" params: dict[str, Any] = {} if page_number is not None: params["pageNum"] = page_number @@ -297,5 +297,5 @@ def delete_qr_code(self, code: str, qr_id: str) -> None: Raises: requests.HTTPError: If the request fails """ - endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}/qr/{qr_id}" + endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}/qrs/{qr_id}" self.client.delete(endpoint) diff --git a/hyphen/net_info.py b/hyphen/net_info.py index 4df1324..f2e9f8b 100644 --- a/hyphen/net_info.py +++ b/hyphen/net_info.py @@ -11,7 +11,7 @@ class NetInfo: def __init__( self, api_key: str | None = None, - base_url: str = "https://api.hyphen.ai", + base_url: str = "https://net.info", ): """ Initialize the NetInfo client. @@ -35,7 +35,7 @@ def get_ip_info(self, ip_address: str) -> IpInfo | IpInfoError: Raises: requests.HTTPError: If the request fails """ - endpoint = f"/api/net-info/ip/{ip_address}" + endpoint = f"/ip/{ip_address}" response = self.client.get(endpoint) if "errorMessage" in response: return IpInfoError.from_dict(response) @@ -53,12 +53,18 @@ def get_ip_infos(self, ip_addresses: list[str]) -> list[IpInfo | IpInfoError]: Raises: requests.HTTPError: If the request fails + ValueError: If ip_addresses is empty """ - endpoint = "/api/net-info/ips" - data = {"ips": ip_addresses} - response = self.client.post(endpoint, data=data) + if not ip_addresses: + raise ValueError( + "The provided IPs array is invalid. It should be a non-empty array of strings." + ) + endpoint = "/ip" + # Send array directly, not wrapped in object + response = self.client.post_raw(endpoint, data=ip_addresses) results: list[IpInfo | IpInfoError] = [] - for item in response: + # Response is {"data": [...]} + for item in response.get("data", []): if "errorMessage" in item: results.append(IpInfoError.from_dict(item)) else: diff --git a/tests/acceptance/conftest.py b/tests/acceptance/conftest.py index e23b599..75cc3d3 100644 --- a/tests/acceptance/conftest.py +++ b/tests/acceptance/conftest.py @@ -13,6 +13,22 @@ def _require_env(name: str) -> str: return value +def _is_dev_mode() -> bool: + """Check if HYPHEN_DEV is set to true.""" + return os.environ.get("HYPHEN_DEV", "").lower() == "true" + + +# Dev URLs +DEV_TOGGLE_URL = "https://dev-horizon.hyphen.ai" +DEV_NETINFO_URL = "https://dev.net.info" +DEV_LINK_URL = "https://dev-api.hyphen.ai" + +# Production URLs +PROD_TOGGLE_URL = "https://toggle.hyphen.cloud" +PROD_NETINFO_URL = "https://net.info" +PROD_LINK_URL = "https://api.hyphen.ai" + + @pytest.fixture def public_api_key() -> str: """Public API key for Toggle service.""" @@ -41,3 +57,21 @@ def organization_id() -> str: def link_domain() -> str: """Domain for short codes.""" return _require_env("HYPHEN_LINK_DOMAIN") + + +@pytest.fixture +def toggle_base_url() -> str: + """Base URL for Toggle service.""" + return DEV_TOGGLE_URL if _is_dev_mode() else PROD_TOGGLE_URL + + +@pytest.fixture +def netinfo_base_url() -> str: + """Base URL for NetInfo service.""" + return DEV_NETINFO_URL if _is_dev_mode() else PROD_NETINFO_URL + + +@pytest.fixture +def link_base_url() -> str: + """Base URL for Link service.""" + return DEV_LINK_URL if _is_dev_mode() else PROD_LINK_URL diff --git a/tests/acceptance/test_link.py b/tests/acceptance/test_link.py index 96a358a..75a0b44 100644 --- a/tests/acceptance/test_link.py +++ b/tests/acceptance/test_link.py @@ -2,6 +2,8 @@ import time +import pytest + from hyphen import Link, QrCode, QrCodesResponse, ShortCode, ShortCodesResponse @@ -9,10 +11,10 @@ class TestLinkAcceptance: """Acceptance tests for Link service.""" def test_create_and_delete_short_code( - self, api_key: str, organization_id: str, link_domain: str + self, api_key: str, organization_id: str, link_domain: str, link_base_url: str ) -> None: """Test creating and deleting a short code.""" - link = Link(organization_id=organization_id, api_key=api_key) + link = Link(organization_id=organization_id, api_key=api_key, base_url=link_base_url) unique_id = str(int(time.time() * 1000)) # Create @@ -28,13 +30,13 @@ def test_create_and_delete_short_code( assert result.domain == link_domain # Cleanup - link.delete_short_code(result.code) + link.delete_short_code(result.id) def test_get_short_code( - self, api_key: str, organization_id: str, link_domain: str + self, api_key: str, organization_id: str, link_domain: str, link_base_url: str ) -> None: - """Test getting a short code by code.""" - link = Link(organization_id=organization_id, api_key=api_key) + """Test getting a short code by ID.""" + link = Link(organization_id=organization_id, api_key=api_key, base_url=link_base_url) unique_id = str(int(time.time() * 1000)) # Create @@ -43,22 +45,21 @@ def test_get_short_code( domain=link_domain, ) - try: - # Get - result = link.get_short_code(created.code) + # Get + result = link.get_short_code(created.id) + + assert isinstance(result, ShortCode) + assert result.code == created.code + assert result.id == created.id - assert isinstance(result, ShortCode) - assert result.code == created.code - assert result.id == created.id - finally: - # Cleanup - link.delete_short_code(created.code) + # Cleanup + link.delete_short_code(created.id) def test_get_short_codes( - self, api_key: str, organization_id: str + self, api_key: str, organization_id: str, link_base_url: str ) -> None: """Test listing short codes.""" - link = Link(organization_id=organization_id, api_key=api_key) + link = Link(organization_id=organization_id, api_key=api_key, base_url=link_base_url) result = link.get_short_codes() @@ -67,10 +68,10 @@ def test_get_short_codes( assert isinstance(result.data, list) def test_update_short_code( - self, api_key: str, organization_id: str, link_domain: str + self, api_key: str, organization_id: str, link_domain: str, link_base_url: str ) -> None: """Test updating a short code.""" - link = Link(organization_id=organization_id, api_key=api_key) + link = Link(organization_id=organization_id, api_key=api_key, base_url=link_base_url) unique_id = str(int(time.time() * 1000)) # Create @@ -80,32 +81,31 @@ def test_update_short_code( options={"title": "Original Title"}, ) - try: - # Update - result = link.update_short_code( - created.code, - {"title": "Updated Title"}, - ) + # Update + result = link.update_short_code( + created.id, + {"title": "Updated Title"}, + ) + + assert isinstance(result, ShortCode) + assert result.title == "Updated Title" - assert isinstance(result, ShortCode) - assert result.title == "Updated Title" - finally: - # Cleanup - link.delete_short_code(created.code) + # Cleanup + link.delete_short_code(created.id) - def test_get_tags(self, api_key: str, organization_id: str) -> None: + def test_get_tags(self, api_key: str, organization_id: str, link_base_url: str) -> None: """Test getting all tags.""" - link = Link(organization_id=organization_id, api_key=api_key) + link = Link(organization_id=organization_id, api_key=api_key, base_url=link_base_url) result = link.get_tags() assert isinstance(result, list) def test_create_and_delete_qr_code( - self, api_key: str, organization_id: str, link_domain: str + self, api_key: str, organization_id: str, link_domain: str, link_base_url: str ) -> None: """Test creating and deleting a QR code.""" - link = Link(organization_id=organization_id, api_key=api_key) + link = Link(organization_id=organization_id, api_key=api_key, base_url=link_base_url) unique_id = str(int(time.time() * 1000)) # Create short code first @@ -114,27 +114,25 @@ def test_create_and_delete_qr_code( domain=link_domain, ) - try: - # Create QR code - qr = link.create_qr_code( - short_code.code, - options={"title": f"Test QR {unique_id}"}, - ) + # Create QR code + qr = link.create_qr_code( + short_code.id, + options={"title": f"Test QR {unique_id}"}, + ) - assert isinstance(qr, QrCode) - assert qr.id != "" + assert isinstance(qr, QrCode) + assert qr.id != "" - # Delete QR code - link.delete_qr_code(short_code.code, qr.id) - finally: - # Cleanup short code - link.delete_short_code(short_code.code) + # Delete QR code + link.delete_qr_code(short_code.id, qr.id) + # Cleanup short code + link.delete_short_code(short_code.id) def test_get_qr_codes( - self, api_key: str, organization_id: str, link_domain: str + self, api_key: str, organization_id: str, link_domain: str, link_base_url: str ) -> None: """Test listing QR codes for a short code.""" - link = Link(organization_id=organization_id, api_key=api_key) + link = Link(organization_id=organization_id, api_key=api_key, base_url=link_base_url) unique_id = str(int(time.time() * 1000)) # Create short code @@ -143,11 +141,10 @@ def test_get_qr_codes( domain=link_domain, ) - try: - result = link.get_qr_codes(short_code.code) + result = link.get_qr_codes(short_code.id) - assert isinstance(result, QrCodesResponse) - assert result.total >= 0 - finally: - # Cleanup - link.delete_short_code(short_code.code) + assert isinstance(result, QrCodesResponse) + assert result.total >= 0 + + # Cleanup + link.delete_short_code(short_code.id) diff --git a/tests/acceptance/test_net_info.py b/tests/acceptance/test_net_info.py index d901a32..954624b 100644 --- a/tests/acceptance/test_net_info.py +++ b/tests/acceptance/test_net_info.py @@ -6,53 +6,41 @@ class TestNetInfoAcceptance: """Acceptance tests for NetInfo service.""" - def test_get_ip_info_valid_ip(self, api_key: str) -> None: + def test_get_ip_info_valid_ip(self, api_key: str, netinfo_base_url: str) -> None: """Test get_ip_info with a valid IP address.""" - net_info = NetInfo(api_key=api_key) + net_info = NetInfo(api_key=api_key, base_url=netinfo_base_url) result = net_info.get_ip_info("8.8.8.8") assert isinstance(result, IpInfo) assert result.ip == "8.8.8.8" - assert result.ip_type == "ipv4" assert result.location is not None - assert result.location.country == "United States" + assert result.location.country != "" - def test_get_ip_info_ipv6(self, api_key: str) -> None: - """Test get_ip_info with an IPv6 address.""" - net_info = NetInfo(api_key=api_key) + def test_get_ip_info_cloudflare_dns(self, api_key: str, netinfo_base_url: str) -> None: + """Test get_ip_info with Cloudflare DNS.""" + net_info = NetInfo(api_key=api_key, base_url=netinfo_base_url) - result = net_info.get_ip_info("2001:4860:4860::8888") + result = net_info.get_ip_info("1.1.1.1") assert isinstance(result, IpInfo) - assert result.ip_type == "ipv6" - - def test_get_ip_info_invalid_ip(self, api_key: str) -> None: - """Test get_ip_info with an invalid IP address.""" - net_info = NetInfo(api_key=api_key) - - result = net_info.get_ip_info("invalid-ip") - - assert isinstance(result, IpInfoError) - assert result.error_message != "" + assert result.ip == "1.1.1.1" + assert result.location is not None - def test_get_ip_infos_multiple(self, api_key: str) -> None: + def test_get_ip_infos_multiple(self, api_key: str, netinfo_base_url: str) -> None: """Test get_ip_infos with multiple IP addresses.""" - net_info = NetInfo(api_key=api_key) + net_info = NetInfo(api_key=api_key, base_url=netinfo_base_url) result = net_info.get_ip_infos(["8.8.8.8", "1.1.1.1"]) assert len(result) == 2 assert all(isinstance(r, (IpInfo, IpInfoError)) for r in result) - def test_get_ip_infos_mixed_valid_invalid(self, api_key: str) -> None: - """Test get_ip_infos with mix of valid and invalid IPs.""" - net_info = NetInfo(api_key=api_key) + def test_get_ip_infos_empty_raises_error(self, api_key: str, netinfo_base_url: str) -> None: + """Test get_ip_infos with empty array raises error.""" + import pytest - result = net_info.get_ip_infos(["8.8.8.8", "invalid-ip"]) + net_info = NetInfo(api_key=api_key, base_url=netinfo_base_url) - assert len(result) == 2 - # First should be valid - assert isinstance(result[0], IpInfo) - # Second should be error - assert isinstance(result[1], IpInfoError) + with pytest.raises(ValueError): + net_info.get_ip_infos([]) diff --git a/tests/acceptance/test_toggle.py b/tests/acceptance/test_toggle.py index 8be349a..8f8b58e 100644 --- a/tests/acceptance/test_toggle.py +++ b/tests/acceptance/test_toggle.py @@ -1,85 +1,215 @@ """Acceptance tests for FeatureToggle.""" +import time + +import pytest + from hyphen import FeatureToggle, ToggleContext +from tests.acceptance.testutil import ToggleAdmin -class TestToggleAcceptance: - """Acceptance tests for Toggle service.""" +@pytest.fixture +def admin() -> ToggleAdmin: + """Create a ToggleAdmin instance.""" + return ToggleAdmin() - def test_evaluate_returns_toggles( - self, public_api_key: str, application_id: str - ) -> None: - """Test that evaluate returns toggle evaluations.""" - toggle = FeatureToggle( - application_id=application_id, - api_key=public_api_key, - ) - result = toggle.evaluate() +class TestToggleAcceptance: + """Acceptance tests for Toggle service.""" - assert result is not None - assert hasattr(result, "toggles") - assert isinstance(result.toggles, dict) + # NOTE: Tests that create toggles dynamically may experience ephemeral failures + # due to a backend caching issue: https://github.com/Hyphen/apix/issues/1670 - def test_get_boolean_with_default( - self, public_api_key: str, application_id: str + def test_get_boolean_returns_true_when_toggle_default_is_true( + self, + admin: ToggleAdmin, + public_api_key: str, + application_id: str, + toggle_base_url: str, + ) -> None: + """Test GetBoolean returns true when toggle default is true.""" + if not admin.is_configured(): + pytest.skip(f"Toggle admin not configured: missing {admin.missing_config()}") + + toggle_key = f"test-bool-true-{int(time.time() * 1000)}" + admin.create_boolean_toggle(toggle_key, True) + try: + toggle = FeatureToggle( + application_id=application_id, + api_key=public_api_key, + base_url=toggle_base_url, + ) + + result = toggle.get_boolean(toggle_key, default=False) + + # May fail due to backend caching issue (see note above) + assert result is True + finally: + admin.delete_toggle(toggle_key) + + def test_get_boolean_returns_false_when_toggle_default_is_false( + self, + admin: ToggleAdmin, + public_api_key: str, + application_id: str, + toggle_base_url: str, + ) -> None: + """Test GetBoolean returns false when toggle default is false.""" + if not admin.is_configured(): + pytest.skip(f"Toggle admin not configured: missing {admin.missing_config()}") + + toggle_key = f"test-bool-false-{int(time.time() * 1000)}" + admin.create_boolean_toggle(toggle_key, False) + try: + toggle = FeatureToggle( + application_id=application_id, + api_key=public_api_key, + base_url=toggle_base_url, + ) + + result = toggle.get_boolean(toggle_key, default=True) + + # May fail due to backend caching issue (see note above) + assert result is False + finally: + admin.delete_toggle(toggle_key) + + def test_get_boolean_returns_default_for_nonexistent_toggle( + self, public_api_key: str, application_id: str, toggle_base_url: str ) -> None: """Test get_boolean returns default for nonexistent toggle.""" toggle = FeatureToggle( application_id=application_id, api_key=public_api_key, + base_url=toggle_base_url, ) result = toggle.get_boolean("nonexistent-toggle-abc123", default=True) assert result is True - def test_get_string_with_default( - self, public_api_key: str, application_id: str + def test_get_string_returns_configured_value( + self, + admin: ToggleAdmin, + public_api_key: str, + application_id: str, + toggle_base_url: str, + ) -> None: + """Test GetString returns configured value.""" + if not admin.is_configured(): + pytest.skip(f"Toggle admin not configured: missing {admin.missing_config()}") + + toggle_key = f"test-string-{int(time.time() * 1000)}" + expected_value = "the-configured-string-value" + admin.create_string_toggle(toggle_key, expected_value) + try: + toggle = FeatureToggle( + application_id=application_id, + api_key=public_api_key, + base_url=toggle_base_url, + ) + + result = toggle.get_string(toggle_key, default="a-default-value") + + # May fail due to backend caching issue (see note above) + assert result == expected_value + finally: + admin.delete_toggle(toggle_key) + + def test_get_string_returns_default_for_nonexistent_toggle( + self, public_api_key: str, application_id: str, toggle_base_url: str ) -> None: """Test get_string returns default for nonexistent toggle.""" toggle = FeatureToggle( application_id=application_id, api_key=public_api_key, + base_url=toggle_base_url, ) result = toggle.get_string("nonexistent-toggle-abc123", default="fallback") assert result == "fallback" - def test_get_number_with_default( - self, public_api_key: str, application_id: str + def test_get_number_returns_configured_value( + self, + admin: ToggleAdmin, + public_api_key: str, + application_id: str, + toggle_base_url: str, + ) -> None: + """Test GetNumber returns configured value.""" + if not admin.is_configured(): + pytest.skip(f"Toggle admin not configured: missing {admin.missing_config()}") + + toggle_key = f"test-number-{int(time.time() * 1000)}" + expected_value = 42.5 + admin.create_number_toggle(toggle_key, expected_value) + try: + toggle = FeatureToggle( + application_id=application_id, + api_key=public_api_key, + base_url=toggle_base_url, + ) + + result = toggle.get_number(toggle_key, default=0.0) + + # May fail due to backend caching issue (see note above) + assert result == expected_value + finally: + admin.delete_toggle(toggle_key) + + def test_get_number_returns_default_for_nonexistent_toggle( + self, public_api_key: str, application_id: str, toggle_base_url: str ) -> None: """Test get_number returns default for nonexistent toggle.""" toggle = FeatureToggle( application_id=application_id, api_key=public_api_key, + base_url=toggle_base_url, ) result = toggle.get_number("nonexistent-toggle-abc123", default=42) assert result == 42 - def test_get_object_with_default( - self, public_api_key: str, application_id: str + def test_get_object_returns_default_for_nonexistent_toggle( + self, public_api_key: str, application_id: str, toggle_base_url: str ) -> None: """Test get_object returns default for nonexistent toggle.""" toggle = FeatureToggle( application_id=application_id, api_key=public_api_key, + base_url=toggle_base_url, ) result = toggle.get_object("nonexistent-toggle-abc123", default={"key": "value"}) assert result == {"key": "value"} + def test_evaluate_returns_toggles( + self, public_api_key: str, application_id: str, toggle_base_url: str + ) -> None: + """Test that evaluate returns toggle evaluations.""" + toggle = FeatureToggle( + application_id=application_id, + api_key=public_api_key, + base_url=toggle_base_url, + ) + + result = toggle.evaluate() + + assert result is not None + assert hasattr(result, "toggles") + assert isinstance(result.toggles, dict) + def test_evaluate_with_targeting_context( - self, public_api_key: str, application_id: str + self, public_api_key: str, application_id: str, toggle_base_url: str ) -> None: """Test evaluate with targeting context.""" toggle = FeatureToggle( application_id=application_id, api_key=public_api_key, + base_url=toggle_base_url, ) context = ToggleContext( targeting_key="test-user-123", @@ -93,12 +223,13 @@ def test_evaluate_with_targeting_context( assert isinstance(result.toggles, dict) def test_get_toggles_multiple( - self, public_api_key: str, application_id: str + self, public_api_key: str, application_id: str, toggle_base_url: str ) -> None: """Test get_toggles with multiple toggle names.""" toggle = FeatureToggle( application_id=application_id, api_key=public_api_key, + base_url=toggle_base_url, ) result = toggle.get_toggles(["toggle-a", "toggle-b", "toggle-c"]) diff --git a/tests/acceptance/testutil/__init__.py b/tests/acceptance/testutil/__init__.py new file mode 100644 index 0000000..c95cee0 --- /dev/null +++ b/tests/acceptance/testutil/__init__.py @@ -0,0 +1,5 @@ +"""Test utilities for acceptance tests.""" + +from tests.acceptance.testutil.toggle_admin import ToggleAdmin + +__all__ = ["ToggleAdmin"] diff --git a/tests/acceptance/testutil/toggle_admin.py b/tests/acceptance/testutil/toggle_admin.py new file mode 100644 index 0000000..ac0e1d0 --- /dev/null +++ b/tests/acceptance/testutil/toggle_admin.py @@ -0,0 +1,133 @@ +"""Toggle admin utility for acceptance tests. + +Provides methods to create and delete toggles via the Hyphen Management API. +This is used for test setup and teardown in acceptance tests. +""" + +import os +from dataclasses import dataclass, field +from typing import Any + +import requests + + +@dataclass +class Target: + """Represents a targeting rule for a toggle.""" + + logic: str # JSONLogic expression + value: Any # Value to return if logic evaluates to true + + +@dataclass +class ToggleAdmin: + """Provides methods to create and delete toggles via the Hyphen Management API.""" + + api_key: str = field(default_factory=lambda: os.environ.get("HYPHEN_API_KEY", "")) + organization_id: str = field( + default_factory=lambda: os.environ.get("HYPHEN_ORGANIZATION_ID", "") + ) + project_id: str = field( + default_factory=lambda: os.environ.get("HYPHEN_PROJECT_ID", "") + ) + base_url: str = field(default_factory=lambda: _get_base_url()) + + def is_configured(self) -> bool: + """Return True if all required environment variables are set.""" + return bool(self.api_key and self.organization_id and self.project_id) + + def missing_config(self) -> list[str]: + """Return list of missing configuration items.""" + missing = [] + if not self.api_key: + missing.append("HYPHEN_API_KEY") + if not self.organization_id: + missing.append("HYPHEN_ORGANIZATION_ID") + if not self.project_id: + missing.append("HYPHEN_PROJECT_ID") + return missing + + def create_boolean_toggle(self, key: str, default_value: bool) -> None: + """Create a boolean toggle with the given key and default value.""" + self._create_toggle(key, "boolean", default_value, None) + + def create_string_toggle(self, key: str, default_value: str) -> None: + """Create a string toggle with the given key and default value.""" + self._create_toggle(key, "string", default_value, None) + + def create_number_toggle(self, key: str, default_value: float) -> None: + """Create a number toggle with the given key and default value.""" + self._create_toggle(key, "number", default_value, None) + + def create_boolean_toggle_with_targets( + self, key: str, default_value: bool, targets: list[Target] + ) -> None: + """Create a boolean toggle with targeting rules.""" + self._create_toggle(key, "boolean", default_value, targets) + + def create_string_toggle_with_targets( + self, key: str, default_value: str, targets: list[Target] + ) -> None: + """Create a string toggle with targeting rules.""" + self._create_toggle(key, "string", default_value, targets) + + def _create_toggle( + self, + key: str, + toggle_type: str, + default_value: Any, + targets: list[Target] | None, + ) -> None: + """Create a toggle via the Management API.""" + url = ( + f"{self.base_url}/api/organizations/{self.organization_id}" + f"/projects/{self.project_id}/toggles/" + ) + + target_list = [] + if targets: + target_list = [{"logic": t.logic, "value": t.value} for t in targets] + + req_body = { + "key": key, + "type": toggle_type, + "targets": target_list, + "defaultValue": default_value, + "description": "Created by acceptance test", + } + + response = requests.post( + url, + json=req_body, + headers={ + "Content-Type": "application/json", + "x-api-key": self.api_key, + }, + timeout=30, + ) + + if response.status_code not in (200, 201): + raise RuntimeError(f"Unexpected status code: {response.status_code}") + + def delete_toggle(self, key: str) -> None: + """Delete a toggle by its key.""" + url = ( + f"{self.base_url}/api/organizations/{self.organization_id}" + f"/projects/{self.project_id}/toggles/{key}" + ) + + response = requests.delete( + url, + headers={"x-api-key": self.api_key}, + timeout=30, + ) + + if response.status_code not in (200, 204, 404): + raise RuntimeError(f"Unexpected status code: {response.status_code}") + + +def _get_base_url() -> str: + """Get the base URL based on HYPHEN_DEV environment variable.""" + if os.environ.get("HYPHEN_DEV", "").lower() == "true": + return "https://dev-api.hyphen.ai" + return "https://api.hyphen.ai" From 4deb6432508b3ccaade49bce135a53d26b2bbdb6 Mon Sep 17 00:00:00 2001 From: half-ogre Date: Wed, 21 Jan 2026 20:01:56 -0800 Subject: [PATCH 06/13] more acceptance tests --- hyphen/link.py | 4 +- tests/acceptance/test_link.py | 33 ++++++- tests/acceptance/test_toggle.py | 137 +++++++++++++++++++++++++- tests/acceptance/testutil/__init__.py | 4 +- 4 files changed, 171 insertions(+), 7 deletions(-) diff --git a/hyphen/link.py b/hyphen/link.py index 50d7658..a6fc0fb 100644 --- a/hyphen/link.py +++ b/hyphen/link.py @@ -189,9 +189,9 @@ def get_short_code_stats( params: dict[str, Any] = {} if start_date: - params["start_date"] = start_date.isoformat() + params["startDate"] = start_date.strftime("%Y-%m-%dT%H:%M:%SZ") if end_date: - params["end_date"] = end_date.isoformat() + params["endDate"] = end_date.strftime("%Y-%m-%dT%H:%M:%SZ") return dict(self.client.get(endpoint, params=params if params else None)) diff --git a/tests/acceptance/test_link.py b/tests/acceptance/test_link.py index 75a0b44..e06c4f2 100644 --- a/tests/acceptance/test_link.py +++ b/tests/acceptance/test_link.py @@ -1,8 +1,7 @@ """Acceptance tests for Link.""" import time - -import pytest +from datetime import datetime, timedelta from hyphen import Link, QrCode, QrCodesResponse, ShortCode, ShortCodesResponse @@ -148,3 +147,33 @@ def test_get_qr_codes( # Cleanup link.delete_short_code(short_code.id) + + def test_get_short_code_stats( + self, api_key: str, organization_id: str, link_domain: str, link_base_url: str + ) -> None: + """Test getting statistics for a short code.""" + link = Link(organization_id=organization_id, api_key=api_key, base_url=link_base_url) + unique_id = str(int(time.time() * 1000)) + + # Create short code + short_code = link.create_short_code( + long_url=f"https://example.com/stats-test-{unique_id}", + domain=link_domain, + options={"title": f"Test Stats {unique_id}"}, + ) + + try: + end_date = datetime.now() + start_date = end_date - timedelta(days=30) + + result = link.get_short_code_stats( + short_code.id, + start_date=start_date, + end_date=end_date, + ) + + assert isinstance(result, dict) + # Stats should contain clicks info + assert "clicks" in result or result is not None + finally: + link.delete_short_code(short_code.id) diff --git a/tests/acceptance/test_toggle.py b/tests/acceptance/test_toggle.py index 8f8b58e..4f3251f 100644 --- a/tests/acceptance/test_toggle.py +++ b/tests/acceptance/test_toggle.py @@ -5,7 +5,7 @@ import pytest from hyphen import FeatureToggle, ToggleContext -from tests.acceptance.testutil import ToggleAdmin +from tests.acceptance.testutil import Target, ToggleAdmin @pytest.fixture @@ -235,3 +235,138 @@ def test_get_toggles_multiple( result = toggle.get_toggles(["toggle-a", "toggle-b", "toggle-c"]) assert isinstance(result, dict) + + def test_get_boolean_returns_targeted_value_when_user_id_matches( + self, + admin: ToggleAdmin, + public_api_key: str, + application_id: str, + toggle_base_url: str, + ) -> None: + """Test GetBoolean returns targeted value when user.id matches.""" + if not admin.is_configured(): + pytest.skip(f"Toggle admin not configured: missing {admin.missing_config()}") + + toggle_key = f"test-targeting-user-{int(time.time() * 1000)}" + # JSONLogic: if user.id == "the-vip-user", return true + targets = [ + Target(logic='{"==": [{"var": "user.id"}, "the-vip-user"]}', value=True) + ] + admin.create_boolean_toggle_with_targets(toggle_key, False, targets) + try: + toggle = FeatureToggle( + application_id=application_id, + api_key=public_api_key, + base_url=toggle_base_url, + ) + + # With matching user ID + result_with_match = toggle.get_boolean( + toggle_key, + default=False, + context=ToggleContext(user={"id": "the-vip-user"}), + ) + + # With non-matching user ID + result_without_match = toggle.get_boolean( + toggle_key, + default=False, + context=ToggleContext(user={"id": "a-regular-user"}), + ) + + # May fail due to backend caching issue (see note above) + assert result_with_match is True, "should return targeted value for matching user" + assert result_without_match is False, "should return default value for non-matching user" + finally: + admin.delete_toggle(toggle_key) + + def test_get_string_returns_targeted_value_based_on_custom_attribute( + self, + admin: ToggleAdmin, + public_api_key: str, + application_id: str, + toggle_base_url: str, + ) -> None: + """Test GetString returns targeted value based on custom attribute.""" + if not admin.is_configured(): + pytest.skip(f"Toggle admin not configured: missing {admin.missing_config()}") + + toggle_key = f"test-targeting-attr-{int(time.time() * 1000)}" + # JSONLogic: if customAttributes.plan == "premium", return "the-premium-feature-value" + targets = [ + Target( + logic='{"==": [{"var": "customAttributes.plan"}, "premium"]}', + value="the-premium-feature-value", + ) + ] + admin.create_string_toggle_with_targets(toggle_key, "the-default-feature-value", targets) + try: + toggle = FeatureToggle( + application_id=application_id, + api_key=public_api_key, + base_url=toggle_base_url, + ) + + # With matching custom attribute + result_premium = toggle.get_string( + toggle_key, + default="a-fallback", + context=ToggleContext(custom_attributes={"plan": "premium"}), + ) + + # With non-matching custom attribute + result_free = toggle.get_string( + toggle_key, + default="a-fallback", + context=ToggleContext(custom_attributes={"plan": "free"}), + ) + + # May fail due to backend caching issue (see note above) + assert result_premium == "the-premium-feature-value", "should return targeted value for premium plan" + assert result_free == "the-default-feature-value", "should return default value for free plan" + finally: + admin.delete_toggle(toggle_key) + + def test_get_boolean_returns_targeted_value_based_on_targeting_key( + self, + admin: ToggleAdmin, + public_api_key: str, + application_id: str, + toggle_base_url: str, + ) -> None: + """Test GetBoolean returns targeted value based on targeting key.""" + if not admin.is_configured(): + pytest.skip(f"Toggle admin not configured: missing {admin.missing_config()}") + + toggle_key = f"test-targeting-key-{int(time.time() * 1000)}" + # JSONLogic: if targetingKey == "the-beta-tester", return true + targets = [ + Target(logic='{"==": [{"var": "targetingKey"}, "the-beta-tester"]}', value=True) + ] + admin.create_boolean_toggle_with_targets(toggle_key, False, targets) + try: + toggle = FeatureToggle( + application_id=application_id, + api_key=public_api_key, + base_url=toggle_base_url, + ) + + # With matching targeting key + result_beta = toggle.get_boolean( + toggle_key, + default=False, + context=ToggleContext(targeting_key="the-beta-tester"), + ) + + # With non-matching targeting key + result_regular = toggle.get_boolean( + toggle_key, + default=False, + context=ToggleContext(targeting_key="a-regular-user"), + ) + + # May fail due to backend caching issue (see note above) + assert result_beta is True, "should return targeted value for beta tester" + assert result_regular is False, "should return default value for regular user" + finally: + admin.delete_toggle(toggle_key) diff --git a/tests/acceptance/testutil/__init__.py b/tests/acceptance/testutil/__init__.py index c95cee0..29e0f3f 100644 --- a/tests/acceptance/testutil/__init__.py +++ b/tests/acceptance/testutil/__init__.py @@ -1,5 +1,5 @@ """Test utilities for acceptance tests.""" -from tests.acceptance.testutil.toggle_admin import ToggleAdmin +from tests.acceptance.testutil.toggle_admin import Target, ToggleAdmin -__all__ = ["ToggleAdmin"] +__all__ = ["Target", "ToggleAdmin"] From 5a0fe1e8c14600051a5ea82366047ea562334c8d Mon Sep 17 00:00:00 2001 From: half-ogre Date: Wed, 21 Jan 2026 20:11:15 -0800 Subject: [PATCH 07/13] follow the Node SDK patterns --- CODE_OF_CONDUCT.md | 128 +++++++++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 24 +++++++++ SECURITY.md | 9 ++++ 3 files changed, 161 insertions(+) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..5e5de92 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at in the repo issues +or maintainers public email address. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..038049f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,24 @@ +# Contributing +When contributing to this repository, please first discuss the change you wish to make via issue, email, or any other method with the owners of this repository before making a change. + +Please note we have a [Code of Conduct](CODE_OF_CONDUCT.md), please follow it in all your interactions with the project. + +We release new versions of this project (maintenance/features) on a monthly cadence so please be aware that some items will not get released right away. + +# Pull Request Process +You can contribute changes to this repo by opening a pull request: + +1) After forking this repository to your Git account, make the proposed changes on your forked branch. +2) Run tests and linting locally `pip install -e ".[dev]" && pytest && ruff check hyphen tests` to ensure that your changes do not break any existing functionality. +3) Commit your changes and push them to your forked repository. +4) Navigate to the main `python-sdk` repository and select the *Pull Requests* tab. +5) Click the *New pull request* button, then select the option "Compare across forks" +6) Leave the base branch set to main. Set the compare branch to your forked branch, and open the pull request. +7) Once your pull request is created, ensure that all checks have passed and that your branch has no conflicts with the base branch. If there are any issues, resolve these changes in your local repository, and then commit and push them to git. +8) Similarly, respond to any reviewer comments or requests for changes by making edits to your local repository and pushing them to Git. +9) Once the pull request has been reviewed, those with write access to the branch will be able to merge your changes into the `python-sdk` repository. + +If you need more information on the steps to create a pull request, you can find a detailed walkthrough in the [Github documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork) + +# Code of Conduct +Please refer to our [Code of Conduct](CODE_OF_CONDUCT.md) readme for how to contribute to this open source project and work within the community. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8ff7d51 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Security Policy + +To report a security vulnerability, please post an issue in our repository for [hyphen](https://github.com/Hyphen/python-sdk/issues) and mark it with `security vulnerability`. You need to add in the issue description the following information: + +- **Vulnerability Type**: Describe the type of vulnerability (e.g., XSS, CSRF, SQL Injection). +- **Vulnerability Description**: Describe the vulnerability in detail, including how it can be exploited and what impact it may have. +- **Proof of Concept**: If possible, provide a proof of concept (PoC) that demonstrates the vulnerability. + +Once the issue has been validated, we will open a [Github Security Advisory](https://docs.github.com/en/code-security/repository-security-advisories/about-github-security-advisories-for-repositories), if necessary. When the issue has been resolved, we will alert users of the past vulnerability by publishing the security advisory. From 5b9c94579b8046ed9787a596115d63005aa00daf Mon Sep 17 00:00:00 2001 From: half-ogre Date: Wed, 21 Jan 2026 20:11:25 -0800 Subject: [PATCH 08/13] next pre-release version --- hyphen/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hyphen/__init__.py b/hyphen/__init__.py index 87008d9..b953d34 100644 --- a/hyphen/__init__.py +++ b/hyphen/__init__.py @@ -22,7 +22,7 @@ UserContext, ) -__version__ = "0.1.0" +__version__ = "0.0.1a1" __all__ = [ # Services "FeatureToggle", diff --git a/pyproject.toml b/pyproject.toml index 055d205..be2be40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "hyphen" -version = "0.1.0" +version = "0.0.1a1" description = "Python SDK for Hyphen - Feature toggles, IP geolocation, and link shortening" readme = "README.md" requires-python = ">=3.10" From c43fa23c28ce61b06229a9700e4ee27bbde8d192 Mon Sep 17 00:00:00 2001 From: half-ogre Date: Wed, 21 Jan 2026 20:14:44 -0800 Subject: [PATCH 09/13] add release workflow --- .github/workflows/release.yaml | 51 ++++++++++++++++++++++++++++++++++ README.md | 17 ++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..d2c430c --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,51 @@ +name: Release + +on: + workflow_dispatch: + release: + types: [released] + +permissions: + contents: read + +env: + HYPHEN_PUBLIC_API_KEY: ${{ secrets.HYPHEN_PUBLIC_API_KEY }} + HYPHEN_API_KEY: ${{ secrets.HYPHEN_API_KEY }} + HYPHEN_APPLICATION_ID: ${{ secrets.HYPHEN_APPLICATION_ID }} + HYPHEN_ORGANIZATION_ID: ${{ secrets.HYPHEN_ORGANIZATION_ID }} + HYPHEN_PROJECT_ID: ${{ secrets.HYPHEN_PROJECT_ID }} + HYPHEN_LINK_DOMAIN: ${{ secrets.HYPHEN_LINK_DOMAIN }} + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + pip install -e ".[dev]" + pip install build twine + + - name: Run linting + run: ruff check hyphen tests + + - name: Run type checking + run: mypy hyphen + + - name: Run tests + run: pytest + + - name: Build package + run: python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/README.md b/README.md index a4e81f7..2cf594c 100644 --- a/README.md +++ b/README.md @@ -400,6 +400,23 @@ ruff check hyphen tests mypy hyphen ``` +### Releasing + +Releases are published to [PyPI](https://pypi.org/project/hyphen/) automatically when a GitHub Release is created. + +To release a new version: + +1. Update the version in `pyproject.toml` and `hyphen/__init__.py` +2. Commit the version change: `git commit -am "chore: bump version to X.Y.Z"` +3. Push to main: `git push origin main` +4. Create a new [GitHub Release](https://github.com/Hyphen/python-sdk/releases/new): + - Tag: `vX.Y.Z` (e.g., `v0.1.0`) + - Title: `vX.Y.Z` + - Description: Release notes +5. The release workflow will automatically run tests and publish to PyPI + +**Note:** Ensure `PYPI_API_TOKEN` is configured in the repository secrets. + ## Contributing We welcome contributions! Please follow these steps: From b430f693a6afab5fad806c766f07dd9be3e4cac2 Mon Sep 17 00:00:00 2001 From: half-ogre Date: Wed, 21 Jan 2026 20:17:39 -0800 Subject: [PATCH 10/13] run tests nightly --- .github/workflows/acceptance.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/acceptance.yaml b/.github/workflows/acceptance.yaml index 58d65e5..c0bb087 100644 --- a/.github/workflows/acceptance.yaml +++ b/.github/workflows/acceptance.yaml @@ -2,6 +2,8 @@ name: Acceptance Tests on: workflow_dispatch: + schedule: + - cron: '0 6 * * *' # Daily at 6am UTC jobs: acceptance: From 4ccf6b8854e53cdfe4d170104c4ad86707582ec4 Mon Sep 17 00:00:00 2001 From: half-ogre Date: Wed, 21 Jan 2026 20:27:09 -0800 Subject: [PATCH 11/13] lint issues --- tests/acceptance/test_toggle.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/acceptance/test_toggle.py b/tests/acceptance/test_toggle.py index 4f3251f..ec5a296 100644 --- a/tests/acceptance/test_toggle.py +++ b/tests/acceptance/test_toggle.py @@ -275,8 +275,8 @@ def test_get_boolean_returns_targeted_value_when_user_id_matches( ) # May fail due to backend caching issue (see note above) - assert result_with_match is True, "should return targeted value for matching user" - assert result_without_match is False, "should return default value for non-matching user" + assert result_with_match is True, "should return targeted value for match" + assert result_without_match is False, "should return default for non-match" finally: admin.delete_toggle(toggle_key) @@ -322,8 +322,8 @@ def test_get_string_returns_targeted_value_based_on_custom_attribute( ) # May fail due to backend caching issue (see note above) - assert result_premium == "the-premium-feature-value", "should return targeted value for premium plan" - assert result_free == "the-default-feature-value", "should return default value for free plan" + assert result_premium == "the-premium-feature-value" + assert result_free == "the-default-feature-value" finally: admin.delete_toggle(toggle_key) From 777b84aa17f2960ac89c80b446656e13e1a35ad9 Mon Sep 17 00:00:00 2001 From: half-ogre Date: Wed, 21 Jan 2026 20:35:53 -0800 Subject: [PATCH 12/13] fix unit tests drifting from changes --- .github/workflows/acceptance.yaml | 8 +++++--- hyphen/feature_toggle.py | 2 +- tests/test_feature_toggle.py | 7 +++---- tests/test_link.py | 12 ++++++------ tests/test_net_info.py | 27 ++++++++++++++------------- 5 files changed, 29 insertions(+), 27 deletions(-) diff --git a/.github/workflows/acceptance.yaml b/.github/workflows/acceptance.yaml index c0bb087..7791fde 100644 --- a/.github/workflows/acceptance.yaml +++ b/.github/workflows/acceptance.yaml @@ -22,8 +22,10 @@ jobs: - name: Run acceptance tests run: pytest tests/acceptance/ -v env: + HYPHEN_DEV: ${{ vars.HYPHEN_DEV }} HYPHEN_API_KEY: ${{ secrets.HYPHEN_API_KEY }} HYPHEN_PUBLIC_API_KEY: ${{ secrets.HYPHEN_PUBLIC_API_KEY }} - HYPHEN_APPLICATION_ID: ${{ secrets.HYPHEN_APPLICATION_ID }} - HYPHEN_ORGANIZATION_ID: ${{ secrets.HYPHEN_ORGANIZATION_ID }} - HYPHEN_LINK_DOMAIN: ${{ secrets.HYPHEN_LINK_DOMAIN }} + HYPHEN_APPLICATION_ID: ${{ vars.HYPHEN_APPLICATION_ID }} + HYPHEN_ORGANIZATION_ID: ${{ vars.HYPHEN_ORGANIZATION_ID }} + HYPHEN_PROJECT_ID: ${{ vars.HYPHEN_PROJECT_ID }} + HYPHEN_LINK_DOMAIN: ${{ vars.HYPHEN_LINK_DOMAIN }} diff --git a/hyphen/feature_toggle.py b/hyphen/feature_toggle.py index c471993..392897d 100644 --- a/hyphen/feature_toggle.py +++ b/hyphen/feature_toggle.py @@ -63,7 +63,7 @@ def __init__( ) self.environment = ( - environment or os.environ.get("HYPHEN_ENVIRONMENT") or "development" + environment or os.environ.get("HYPHEN_ENVIRONMENT") or "production" ) self.default_context = default_context self.on_error = on_error diff --git a/tests/test_feature_toggle.py b/tests/test_feature_toggle.py index ebd3442..15a48f9 100644 --- a/tests/test_feature_toggle.py +++ b/tests/test_feature_toggle.py @@ -91,10 +91,9 @@ def test_build_payload_with_minimal_context(self) -> None: payload = toggle._build_payload() - assert payload == { - "application": "the_app_id", - "environment": "the_environment", - } + assert payload["application"] == "the_app_id" + assert payload["environment"] == "the_environment" + assert "targetingKey" in payload # Always generated when not provided def test_build_payload_with_full_context(self) -> None: """Test payload building with full context.""" diff --git a/tests/test_link.py b/tests/test_link.py index 0ac17f3..e107a1a 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -66,7 +66,7 @@ def test_create_short_code(mock_client_class: Mock) -> None: def test_update_short_code(mock_client_class: Mock) -> None: """Test update_short_code method returns ShortCode.""" mock_client = Mock() - mock_client.put.return_value = { + mock_client.patch.return_value = { "id": "sc_123", "code": "abc123", "long_url": "https://hyphen.ai", @@ -81,7 +81,7 @@ def test_update_short_code(mock_client_class: Mock) -> None: assert isinstance(result, ShortCode) assert result.title == "Updated" - mock_client.put.assert_called_once_with( + mock_client.patch.assert_called_once_with( "/api/organizations/org_123/link/codes/abc123", data={"title": "Updated"} ) @@ -152,7 +152,7 @@ def test_get_tags(mock_client_class: Mock) -> None: assert len(result) == 3 assert "tag1" in result - mock_client.get.assert_called_once_with("/api/organizations/org_123/link/tags") + mock_client.get.assert_called_once_with("/api/organizations/org_123/link/codes/tags") @patch("hyphen.link.BaseClient") @@ -218,7 +218,7 @@ def test_get_qr_code(mock_client_class: Mock) -> None: assert isinstance(result, QrCode) assert result.id == "qr_123" - mock_client.get.assert_called_once_with("/api/organizations/org_123/link/codes/abc123/qr/qr_123") + mock_client.get.assert_called_once_with("/api/organizations/org_123/link/codes/abc123/qrs/qr_123") @patch("hyphen.link.BaseClient") @@ -240,7 +240,7 @@ def test_get_qr_codes(mock_client_class: Mock) -> None: assert result.total == 2 assert len(result.data) == 2 mock_client.get.assert_called_once_with( - "/api/organizations/org_123/link/codes/abc123/qr", params=None + "/api/organizations/org_123/link/codes/abc123/qrs", params=None ) @@ -255,4 +255,4 @@ def test_delete_qr_code(mock_client_class: Mock) -> None: result = link.delete_qr_code("abc123", "qr_123") assert result is None - mock_client.delete.assert_called_once_with("/api/organizations/org_123/link/codes/abc123/qr/qr_123") + mock_client.delete.assert_called_once_with("/api/organizations/org_123/link/codes/abc123/qrs/qr_123") diff --git a/tests/test_net_info.py b/tests/test_net_info.py index f49b261..8814acc 100644 --- a/tests/test_net_info.py +++ b/tests/test_net_info.py @@ -27,7 +27,7 @@ def test_get_ip_info(mock_client_class: Mock) -> None: assert result.ip_type == "ipv4" assert result.location.country == "United States" assert result.location.city == "Mountain View" - mock_client.get.assert_called_once_with("/api/net-info/ip/8.8.8.8") + mock_client.get.assert_called_once_with("/ip/8.8.8.8") @patch("hyphen.net_info.BaseClient") @@ -53,10 +53,12 @@ def test_get_ip_info_error(mock_client_class: Mock) -> None: def test_get_ip_infos(mock_client_class: Mock) -> None: """Test get_ip_infos method returns list of IpInfo.""" mock_client = Mock() - mock_client.post.return_value = [ - {"ip": "8.8.8.8", "type": "ipv4", "location": {"country": "United States"}}, - {"ip": "1.1.1.1", "type": "ipv4", "location": {"country": "Australia"}}, - ] + mock_client.post_raw.return_value = { + "data": [ + {"ip": "8.8.8.8", "type": "ipv4", "location": {"country": "United States"}}, + {"ip": "1.1.1.1", "type": "ipv4", "location": {"country": "Australia"}}, + ] + } mock_client_class.return_value = mock_client net_info = NetInfo(api_key="key_123") @@ -69,20 +71,19 @@ def test_get_ip_infos(mock_client_class: Mock) -> None: assert isinstance(result[1], IpInfo) assert result[1].ip == "1.1.1.1" assert result[1].location.country == "Australia" - mock_client.post.assert_called_once_with( - "/api/net-info/ips", - data={"ips": ["8.8.8.8", "1.1.1.1"]} - ) + mock_client.post_raw.assert_called_once_with("/ip", data=["8.8.8.8", "1.1.1.1"]) @patch("hyphen.net_info.BaseClient") def test_get_ip_infos_with_errors(mock_client_class: Mock) -> None: """Test get_ip_infos method handles mixed results with errors.""" mock_client = Mock() - mock_client.post.return_value = [ - {"ip": "8.8.8.8", "type": "ipv4", "location": {"country": "United States"}}, - {"ip": "invalid", "type": "error", "errorMessage": "Invalid IP"}, - ] + mock_client.post_raw.return_value = { + "data": [ + {"ip": "8.8.8.8", "type": "ipv4", "location": {"country": "United States"}}, + {"ip": "invalid", "type": "error", "errorMessage": "Invalid IP"}, + ] + } mock_client_class.return_value = mock_client net_info = NetInfo(api_key="key_123") From b95063cc19560f3fa5bd81d5deccb6e20f754a09 Mon Sep 17 00:00:00 2001 From: half-ogre Date: Wed, 21 Jan 2026 21:43:47 -0800 Subject: [PATCH 13/13] don't run acceptance tests for release, for now --- .github/workflows/release.yaml | 14 +++----------- README.md | 2 +- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d2c430c..0034491 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -7,14 +7,7 @@ on: permissions: contents: read - -env: - HYPHEN_PUBLIC_API_KEY: ${{ secrets.HYPHEN_PUBLIC_API_KEY }} - HYPHEN_API_KEY: ${{ secrets.HYPHEN_API_KEY }} - HYPHEN_APPLICATION_ID: ${{ secrets.HYPHEN_APPLICATION_ID }} - HYPHEN_ORGANIZATION_ID: ${{ secrets.HYPHEN_ORGANIZATION_ID }} - HYPHEN_PROJECT_ID: ${{ secrets.HYPHEN_PROJECT_ID }} - HYPHEN_LINK_DOMAIN: ${{ secrets.HYPHEN_LINK_DOMAIN }} + id-token: write # Required for OIDC publishing to PyPI jobs: release: @@ -40,12 +33,11 @@ jobs: run: mypy hyphen - name: Run tests - run: pytest + run: pytest --ignore=tests/acceptance - name: Build package run: python -m build - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} + # No password needed - uses OIDC trusted publishing diff --git a/README.md b/README.md index 2cf594c..0869d01 100644 --- a/README.md +++ b/README.md @@ -415,7 +415,7 @@ To release a new version: - Description: Release notes 5. The release workflow will automatically run tests and publish to PyPI -**Note:** Ensure `PYPI_API_TOKEN` is configured in the repository secrets. +**Note:** Publishing uses PyPI's trusted publisher (OIDC) - no API token needed. ## Contributing