diff --git a/.github/workflows/acceptance.yaml b/.github/workflows/acceptance.yaml index 210fc0c..7791fde 100644 --- a/.github/workflows/acceptance.yaml +++ b/.github/workflows/acceptance.yaml @@ -1,20 +1,31 @@ -name: Acceptance Tests - +name: Acceptance Tests + on: workflow_dispatch: schedule: - - cron: '0 6 * * *' # Daily at 6am UTC - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Placeholder - run: echo "Tests workflow placeholder - will be expanded" + - cron: '0 6 * * *' # Daily at 6am UTC + +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_DEV: ${{ vars.HYPHEN_DEV }} + HYPHEN_API_KEY: ${{ secrets.HYPHEN_API_KEY }} + HYPHEN_PUBLIC_API_KEY: ${{ secrets.HYPHEN_PUBLIC_API_KEY }} + 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/.github/workflows/release.yaml b/.github/workflows/release.yaml index 78519f2..0034491 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,18 +1,43 @@ name: Release - + on: - workflow_dispatch: - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Placeholder - run: echo "Tests workflow placeholder - will be expanded" + workflow_dispatch: + release: + types: [released] + +permissions: + contents: read + id-token: write # Required for OIDC publishing to PyPI + +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 --ignore=tests/acceptance + + - name: Build package + run: python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + # No password needed - uses OIDC trusted publishing diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index c5fc414..39298b5 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,21 +1,40 @@ -name: Tests - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Placeholder - run: echo "Tests workflow placeholder - will be expanded" +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10', '3.11', '3.12', '3.13'] + + 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/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/README.md b/README.md index ddefc69..0869d01 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: @@ -331,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:** Publishing uses PyPI's trusted publisher (OIDC) - no API token needed. + ## Contributing We welcome contributions! Please follow these steps: @@ -349,4 +435,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/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. diff --git a/hyphen/__init__.py b/hyphen/__init__.py index daf5942..b953d34 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"] +__version__ = "0.0.1a1" +__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/base_client.py b/hyphen/base_client.py index f688d58..b4a088b 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. @@ -27,8 +27,7 @@ def __init__(self, api_key: Optional[str] = None, base_url: str = "https://api.h self.session = requests.Session() self.session.headers.update( { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", + "x-api-key": self.api_key, } ) @@ -36,8 +35,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. @@ -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() @@ -69,18 +70,26 @@ 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: - """Make a POST request.""" + def post(self, endpoint: str, data: dict[str, Any] | None = None) -> Any: + """Make a POST request with a dict body.""" return self._request("POST", endpoint, data=data) - def put(self, endpoint: str, data: Optional[Dict[str, Any]] = None) -> Any: + 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 ec3c499..392897d 100644 --- a/hyphen/feature_toggle.py +++ b/hyphen/feature_toggle.py @@ -1,19 +1,37 @@ """Feature Toggle management for Hyphen SDK.""" import os -from typing import Any, Dict, List, Optional, Union +import random +from collections.abc import Callable +from typing import Any 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, - api_key: Optional[str] = None, - base_url: str = "https://api.hyphen.ai", + application_id: str | None = None, + environment: str | None = None, + api_key: str | None = None, + base_url: str = "https://toggle.hyphen.cloud", + default_context: ToggleContext | None = None, + on_error: Callable[[Exception], None] | None = None, ): """ Initialize the FeatureToggle client. @@ -21,11 +39,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 +62,253 @@ 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: 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] = { + "application": self.application_id, + "environment": self.environment, + } + + # 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: + # 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 _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: + self.on_error(error) + return default + raise error + + def evaluate( + self, context: ToggleContext | None = None + ) -> EvaluationResponse: + """ + Evaluate all feature toggles for the given context. + + Args: + context: Targeting context for evaluation. If not provided, + uses the default_context. + + Returns: + EvaluationResponse containing all toggle evaluations. + + Raises: + 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: ToggleContext | None = None, + ) -> Any: """ - Get a single feature toggle by name. + Get a single feature toggle value by name. Args: - toggle_name: Name of the toggle to retrieve + 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 (can be boolean, number, string, or JSON object) + The toggle value, or the default if not found. 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) + 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: ToggleContext | None = 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: ToggleContext | None = 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. + """ + value = self.get_toggle(toggle_name, default=default, context=context) + if isinstance(value, str): + return value + return default + + def get_number( + self, + toggle_name: str, + default: int | float = 0, + context: ToggleContext | None = None, + ) -> 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: dict[str, Any] | None = None, + context: ToggleContext | None = None, + ) -> dict[str, Any]: """ - endpoint = f"/api/applications/{self.application_id}/toggles/{toggle_name}" - response = self.client.get(endpoint) + Get a JSON object feature toggle value. - # Return the value from the response - if isinstance(response, dict) and "value" in response: - return response["value"] - return response + 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: ToggleContext | None = 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..a6fc0fb 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, 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: @@ -20,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", ): """ @@ -47,8 +48,8 @@ def create_short_code( self, long_url: str, domain: str, - options: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: + options: CreateShortCodeOptions | None = 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.patch(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,43 +105,53 @@ 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]]: + 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. 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 """ endpoint = f"/api/organizations/{self.organization_id}/link/codes" - params: Dict[str, Any] = {} + params: dict[str, Any] = {} if title: 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]: + def get_tags(self) -> list[str]: """ Get all tags for the organization. @@ -148,15 +161,16 @@ def get_tags(self) -> List[str]: Raises: requests.HTTPError: If the request fails """ - endpoint = f"/api/organizations/{self.organization_id}/link/tags" - return self.client.get(endpoint) + endpoint = f"/api/organizations/{self.organization_id}/link/codes/tags" + response = self.client.get(endpoint) + return list(response) if response else [] 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. @@ -172,36 +186,33 @@ 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() + 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 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: CreateQrCodeOptions | None = 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]: + endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}/qrs" + 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) + endpoint = f"/api/organizations/{self.organization_id}/link/codes/{code}/qrs/{qr_id}" + 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: int | None = None, + page_size: int | None = 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) + 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 + if page_size is not None: + params["pageSize"] = page_size - def delete_qr_code(self, code: str, qr_id: str) -> Any: + 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) -> 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) + 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 9fd7a83..f2e9f8b 100644 --- a/hyphen/net_info.py +++ b/hyphen/net_info.py @@ -1,8 +1,8 @@ """NetInfo for IP geolocation in Hyphen SDK.""" -from typing import Any, Dict, List, Optional from hyphen.base_client import BaseClient +from hyphen.types import IpInfo, IpInfoError class NetInfo: @@ -10,8 +10,8 @@ class NetInfo: def __init__( self, - api_key: Optional[str] = None, - base_url: str = "https://api.hyphen.ai", + api_key: str | None = None, + base_url: str = "https://net.info", ): """ Initialize the NetInfo client. @@ -22,7 +22,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) -> IpInfo | IpInfoError: """ Get geolocation information for a single IP address. @@ -30,15 +30,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) + endpoint = f"/ip/{ip_address}" + 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[IpInfo | IpInfoError]: """ Get geolocation information for multiple IP addresses. @@ -46,11 +49,24 @@ 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 + ValueError: If ip_addresses is empty """ - endpoint = "/api/net-info/ips" - data = {"ips": ip_addresses} - return 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] = [] + # Response is {"data": [...]} + for item in response.get("data", []): + 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..92eacdf --- /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 + +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: UserContext | None = 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: bool | str | int | float | dict[str, Any] | None + value_type: str + reason: str = "" + error_message: str | None = 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: str | None = None + tags: list[str] | None = None + organization_id: OrganizationInfo | None = 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: 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": + """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: int | None = 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..be2be40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,10 @@ 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.8" +requires-python = ">=3.10" license = {text = "MIT"} authors = [ {name = "Hyphen, Inc."} @@ -18,15 +18,15 @@ 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 = [ "requests>=2.31.0", + "typing_extensions>=4.0.0", ] [project.optional-dependencies] @@ -58,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.8" +python_version = "3.10" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true 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..75cc3d3 --- /dev/null +++ b/tests/acceptance/conftest.py @@ -0,0 +1,77 @@ +"""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 + + +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.""" + 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") + + +@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 new file mode 100644 index 0000000..e06c4f2 --- /dev/null +++ b/tests/acceptance/test_link.py @@ -0,0 +1,179 @@ +"""Acceptance tests for Link.""" + +import time +from datetime import datetime, timedelta + +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, link_base_url: str + ) -> None: + """Test creating and deleting 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 + 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.id) + + def test_get_short_code( + self, api_key: str, organization_id: str, link_domain: str, link_base_url: str + ) -> None: + """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 + created = link.create_short_code( + long_url=f"https://example.com/test-{unique_id}", + domain=link_domain, + ) + + # Get + result = link.get_short_code(created.id) + + assert isinstance(result, ShortCode) + assert result.code == created.code + assert result.id == created.id + + # Cleanup + link.delete_short_code(created.id) + + def test_get_short_codes( + 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, base_url=link_base_url) + + 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, link_base_url: str + ) -> None: + """Test updating 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 + created = link.create_short_code( + long_url=f"https://example.com/test-{unique_id}", + domain=link_domain, + options={"title": "Original Title"}, + ) + + # Update + result = link.update_short_code( + created.id, + {"title": "Updated Title"}, + ) + + assert isinstance(result, ShortCode) + assert result.title == "Updated Title" + + # Cleanup + link.delete_short_code(created.id) + + 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, 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, link_base_url: str + ) -> None: + """Test creating and deleting a QR 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 first + short_code = link.create_short_code( + long_url=f"https://example.com/qr-test-{unique_id}", + domain=link_domain, + ) + + # 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 != "" + + # 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, link_base_url: str + ) -> None: + """Test listing QR codes 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/qr-list-{unique_id}", + domain=link_domain, + ) + + result = link.get_qr_codes(short_code.id) + + assert isinstance(result, QrCodesResponse) + assert result.total >= 0 + + # 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_net_info.py b/tests/acceptance/test_net_info.py new file mode 100644 index 0000000..954624b --- /dev/null +++ b/tests/acceptance/test_net_info.py @@ -0,0 +1,46 @@ +"""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, netinfo_base_url: str) -> None: + """Test get_ip_info with a valid IP address.""" + 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.location is not None + assert result.location.country != "" + + 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("1.1.1.1") + + assert isinstance(result, IpInfo) + assert result.ip == "1.1.1.1" + assert result.location is not 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, 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_empty_raises_error(self, api_key: str, netinfo_base_url: str) -> None: + """Test get_ip_infos with empty array raises error.""" + import pytest + + net_info = NetInfo(api_key=api_key, base_url=netinfo_base_url) + + with pytest.raises(ValueError): + net_info.get_ip_infos([]) diff --git a/tests/acceptance/test_toggle.py b/tests/acceptance/test_toggle.py new file mode 100644 index 0000000..ec5a296 --- /dev/null +++ b/tests/acceptance/test_toggle.py @@ -0,0 +1,372 @@ +"""Acceptance tests for FeatureToggle.""" + +import time + +import pytest + +from hyphen import FeatureToggle, ToggleContext +from tests.acceptance.testutil import Target, ToggleAdmin + + +@pytest.fixture +def admin() -> ToggleAdmin: + """Create a ToggleAdmin instance.""" + return ToggleAdmin() + + +class TestToggleAcceptance: + """Acceptance tests for Toggle service.""" + + # 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_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_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_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_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, 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", + 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, 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"]) + + 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 match" + assert result_without_match is False, "should return default for non-match" + 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" + assert result_free == "the-default-feature-value" + 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 new file mode 100644 index 0000000..29e0f3f --- /dev/null +++ b/tests/acceptance/testutil/__init__.py @@ -0,0 +1,5 @@ +"""Test utilities for acceptance tests.""" + +from tests.acceptance.testutil.toggle_admin import Target, ToggleAdmin + +__all__ = ["Target", "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" diff --git a/tests/test_feature_toggle.py b/tests/test_feature_toggle.py index de3365a..15a48f9 100644 --- a/tests/test_feature_toggle.py +++ b/tests/test_feature_toggle.py @@ -5,81 +5,464 @@ 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" + 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.""" + 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..e107a1a 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,22 +56,32 @@ 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.patch.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" - mock_client.put.assert_called_once_with( + assert isinstance(result, ShortCode) + assert result.title == "Updated" + mock_client.patch.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() @@ -110,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") @@ -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" - mock_client.get.assert_called_once_with("/api/organizations/org_123/link/codes/abc123/qr/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/qrs/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/qrs", 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 @@ -196,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 f24941b..8814acc 100644 --- a/tests/test_net_info.py +++ b/tests/test_net_info.py @@ -2,45 +2,94 @@ 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" - mock_client.get.assert_called_once_with("/api/net-info/ip/8.8.8.8") + 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("/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"}, - ] + 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") 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" - mock_client.post.assert_called_once_with( - "/api/net-info/ips", - data={"ips": ["8.8.8.8", "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_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_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") + 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"