From bb64b27f6e3f863ce1bfe56fc3c303a73b42d411 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 13 Sep 2025 06:45:14 +0300 Subject: [PATCH 1/3] Initial commit with task details for issue #15 Adding CLAUDE.md with task information for AI processing. This file will be removed when the task is complete. Issue: https://github.com/linksplatform/Data.Doublets.Gql/issues/15 --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..83534650 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +Issue to solve: https://github.com/linksplatform/Data.Doublets.Gql/issues/15 +Your prepared branch: issue-15-d13701fb +Your prepared working directory: /tmp/gh-issue-solver-1757735110665 + +Proceed. \ No newline at end of file From 0e421df8cbca77e5478911a1ba3abd8c00520997 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 13 Sep 2025 06:54:09 +0300 Subject: [PATCH 2/3] Implement Python Doublets Adapter via GraphQL client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements the complete solution for issue #15, providing a native Python interface for Doublets operations with pluggable backends. Key Features: - High-level Doublets class with Pythonic CRUD operations - Pluggable backend architecture (GraphQL, Mock, custom) - Native Python API following Python conventions - Support for iteration, len(), contains(), list comprehensions - Graceful degradation when GraphQL dependencies unavailable - Comprehensive test suite and examples - Updated package structure for PyPI publication Architecture: - Doublets: Main high-level interface - DoubletsBackend: Abstract backend interface for extensibility - GraphQLBackend: Production backend using existing GraphQL server - MockBackend: In-memory backend for testing/development - DeepClient: Low-level GraphQL client (backward compatible) The design allows swapping GraphQL with native C++ library in the future as mentioned in the issue, while maintaining the same Python API. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- python/README.md | 247 ++++++++++++++++++- python/deepclient/__init__.py | 38 ++- python/deepclient/backends.py | 281 +++++++++++++++++++++ python/deepclient/client.py | 2 +- python/deepclient/doublets.py | 226 +++++++++++++++++ python/examples/basic_usage.py | 131 ++++++++++ python/examples/custom_backend.py | 248 +++++++++++++++++++ python/setup.py | 48 ++-- python/tests/test_client.py | 2 +- python/tests/test_doublets.py | 393 ++++++++++++++++++++++++++++++ 10 files changed, 1593 insertions(+), 23 deletions(-) create mode 100644 python/deepclient/backends.py create mode 100644 python/deepclient/doublets.py create mode 100644 python/examples/basic_usage.py create mode 100644 python/examples/custom_backend.py create mode 100644 python/tests/test_doublets.py diff --git a/python/README.md b/python/README.md index fa491a89..1f2873fc 100644 --- a/python/README.md +++ b/python/README.md @@ -1,7 +1,246 @@ -
+# Python Doublets Adapter -# linksgql -### Python bridge GraphQl to Links +A native Python interface for Doublets operations via GraphQL client, designed for the LinksPlatform ecosystem. -
+## Overview + +This package provides a high-level, Pythonic interface for working with Doublets while supporting pluggable backends. It includes: + +- **Native Python API**: Intuitive CRUD operations following Python conventions +- **Pluggable Architecture**: Support for GraphQL, native C++ library, and custom backends +- **GraphQL Client**: Separate low-level GraphQL client for direct usage +- **Type Safety**: Full type hints for better development experience +- **Comprehensive Testing**: Unit tests for all components + +## Installation + +### From PyPI (when published) + +```bash +pip install doublets-gql +``` + +### From Source + +```bash +git clone https://github.com/linksplatform/Data.Doublets.Gql.git +cd Data.Doublets.Gql/python +pip install -e . +``` + +### Development Installation + +```bash +pip install -e .[dev] +``` + +## Quick Start + +### Basic Usage with Mock Backend + +```python +from deepclient import Doublets, MockBackend + +# Create a Doublets instance with mock backend (for testing) +doublets = Doublets(MockBackend()) + +# Create links +link1 = doublets.create() # Self-referencing link +link2 = doublets.create(source=1, target=2) # Link from 1 to 2 +link3 = doublets.create(source=link1.id, target=link2.id) + +print(f"Created: {link1}") # Link(id=1, source=1, target=1) +print(f"Created: {link2}") # Link(id=2, source=1, target=2) +print(f"Created: {link3}") # Link(id=3, source=1, target=2) + +# Search and iterate +print(f"Total links: {len(doublets)}") + +for link in doublets: + print(f" {link}") + +# Search by criteria +links_from_1 = doublets.search(source=1) +links_to_2 = doublets.search(target=2) + +# Update and delete +updated = doublets.update(link2.id, source=10, target=20) +success = doublets.delete(link1.id) +``` + +### GraphQL Backend Usage + +```python +from deepclient import Doublets, GraphQLBackend + +# Connect to GraphQL server +doublets = Doublets(GraphQLBackend( + 'http://localhost:60341/v1/graphql', + headers={'Authorization': 'Bearer your-token-here'} # Optional +)) + +# Same API as mock backend +total = doublets.count() +new_link = doublets.create(source=1, target=2) +recent_links = doublets.search(limit=10) +``` + +## Architecture + +### Core Components + +1. **Doublets**: Main high-level interface +2. **DoubletsBackend**: Abstract backend interface +3. **GraphQLBackend**: GraphQL implementation +4. **MockBackend**: In-memory implementation for testing +5. **DeepClient**: Low-level GraphQL client +6. **Link**: Data structure representing a doublet + +### Pluggable Backends + +The architecture supports swapping backends without changing client code: + +```python +# Start with GraphQL +doublets = Doublets(GraphQLBackend('http://server/graphql')) + +# Later switch to native C++ library (when available) +# doublets._backend = NativeBackend('/path/to/library.so') + +# Same API works with any backend +link = doublets.create(source=1, target=2) +``` + +## API Reference + +### Doublets Class + +The main interface for Doublets operations. + +#### Methods + +- `create(source=None, target=None) -> Link`: Create a new link +- `get(link_id) -> Optional[Link]`: Get link by ID +- `update(link_id, source, target) -> Link`: Update existing link +- `delete(link_id) -> bool`: Delete link by ID +- `search(source=None, target=None, limit=None, offset=None) -> List[Link]`: Search links +- `count(source=None, target=None) -> int`: Count matching links +- `each(callback, source=None, target=None)`: Iterate with callback + +#### Pythonic Features + +- `len(doublets)`: Get total link count +- `for link in doublets`: Iterate over all links +- `link_id in doublets`: Check if link exists +- List comprehensions: `[link for link in doublets if link.source == 1]` + +### Link Class + +Data structure representing a doublet. + +```python +@dataclass +class Link: + id: int # Unique link identifier + source: int # Source link ID (from_id) + target: int # Target link ID (to_id) +``` + +### Backend Interface + +To create custom backends, implement the `DoubletsBackend` abstract class: + +```python +class CustomBackend(DoubletsBackend): + def create(self, source=None, target=None) -> Link: ... + def get(self, link_id) -> Optional[Link]: ... + def update(self, link_id, source, target) -> Link: ... + def delete(self, link_id) -> bool: ... + def search(self, source=None, target=None, limit=None, offset=None) -> List[Link]: ... + def count(self, source=None, target=None) -> int: ... +``` + +## GraphQL Client + +For direct GraphQL usage without the high-level interface: + +```python +from deepclient import DeepClient + +client = DeepClient('http://localhost:60341/v1/graphql') + +# Direct GraphQL operations +response = client.query(''' + query { + links { + id + from_id + to_id + } + } +''') + +# Or use convenience methods +links = client.select(42, 'from_id', 'to_id') +new_link = client.insert_one(1, "test data", 'id', 'type_id') +``` + +## Examples + +See the `examples/` directory for comprehensive usage examples: + +- `basic_usage.py`: Basic operations with different backends +- `custom_backend.py`: Creating custom backend implementations + +## Testing + +Run the test suite: + +```bash +# Install development dependencies +pip install -e .[dev] + +# Run tests +python -m pytest tests/ + +# Run with coverage +python -m pytest tests/ --cov=deepclient --cov-report=html +``` + +## Future Development + +### Native C++ Backend + +The architecture is designed to support a future native C++ backend: + +```python +# Future usage (when C++ library is ready) +from deepclient import NativeBackend + +doublets = Doublets(NativeBackend('/path/to/doublets.so')) +# Same API, better performance +``` + +### Integration with PyO3 + +The package is designed to work with [PyO3](https://lib.rs/crates/pyo3) for Rust/C++ integration, as mentioned in the original issue. + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Add tests for new functionality +4. Ensure all tests pass +5. Submit a pull request + +## License + +LGPLv3 - See LICENSE file for details. + +## Links + +- [Repository](https://github.com/linksplatform/Data.Doublets.Gql) +- [Issues](https://github.com/linksplatform/Data.Doublets.Gql/issues) +- [LinksPlatform Organization](https://github.com/linksplatform) +- [Discord Community](https://discord.gg/eEXJyjWv5e) diff --git a/python/deepclient/__init__.py b/python/deepclient/__init__.py index fc6a31a1..11856b5c 100644 --- a/python/deepclient/__init__.py +++ b/python/deepclient/__init__.py @@ -1,4 +1,38 @@ # -*- coding: utf-8 -*- -"""Provides GraphQL client""" -from .client import DeepClient +""" +Python Doublets Adapter - Native Python interface for Doublets operations. + +This package provides a high-level, Pythonic interface for working with +Doublets while supporting pluggable backends (GraphQL, native C++, etc.). +""" from .exceptions import GraphQlQueryError, DeepClientError +from .doublets import Doublets, Link, DoubletsBackend + +# Try to import GraphQL-related components, but don't fail if dependencies aren't available +try: + from .client import DeepClient + from .backends import GraphQLBackend, MockBackend + _GRAPHQL_AVAILABLE = True +except ImportError: + # GraphQL dependencies not available + from .backends import MockBackend + DeepClient = None + GraphQLBackend = None + _GRAPHQL_AVAILABLE = False + +# For backward compatibility +__all__ = [ + 'GraphQlQueryError', + 'DeepClientError', + 'Doublets', # Main high-level interface + 'Link', # Link data structure + 'DoubletsBackend', # Backend interface + 'MockBackend', # Mock backend for testing +] + +# Add GraphQL components only if available +if _GRAPHQL_AVAILABLE: + __all__.extend([ + 'DeepClient', # Original GraphQL client + 'GraphQLBackend', # GraphQL backend implementation + ]) diff --git a/python/deepclient/backends.py b/python/deepclient/backends.py new file mode 100644 index 00000000..f1fe6649 --- /dev/null +++ b/python/deepclient/backends.py @@ -0,0 +1,281 @@ +# -*- coding: utf-8 -*- +""" +Backend implementations for the Doublets adapter. + +This module provides concrete implementations of DoubletsBackend +for different storage systems (GraphQL, native library, etc.). +""" +from typing import Optional, List, Dict, Any +from .doublets import DoubletsBackend, Link +from .exceptions import DeepClientError + +# Try to import GraphQL client, but don't fail if not available +try: + from .client import DeepClient + _GRAPHQL_AVAILABLE = True +except ImportError: + DeepClient = None + _GRAPHQL_AVAILABLE = False + + +class GraphQLBackend(DoubletsBackend): + """ + GraphQL backend implementation for Doublets operations. + + This backend uses the existing DeepClient to communicate with + a GraphQL server that provides Doublets operations. + """ + + def __init__(self, graphql_url: str, headers: Optional[Dict[str, Any]] = None): + """ + Initialize GraphQL backend. + + Args: + graphql_url: URL of the GraphQL server + headers: Optional HTTP headers (e.g., authorization) + """ + if not _GRAPHQL_AVAILABLE: + raise ImportError( + "GraphQL backend requires 'gql' and 'aiohttp' packages. " + "Install with: pip install gql aiohttp" + ) + self._client = DeepClient(graphql_url, headers) + + def create(self, source: Optional[int] = None, target: Optional[int] = None) -> Link: + """Create a new link via GraphQL.""" + # If no source/target specified, create self-referencing link + if source is None and target is None: + # Create a new link first, then update it to self-reference + response = self._client.query(''' + mutation { + insert_links_one(object: {}) { + id + from_id + to_id + } + } + ''') + link_data = response['insert_links_one'] + link_id = link_data['id'] + + # Update to self-reference + return self.update(link_id, link_id, link_id) + + else: + # Create link with specified source/target + from_id = source if source is not None else 0 + to_id = target if target is not None else 0 + + response = self._client.query(f''' + mutation {{ + insert_links_one(object: {{from_id: {from_id}, to_id: {to_id}}}) {{ + id + from_id + to_id + }} + }} + ''') + link_data = response['insert_links_one'] + return Link( + id=link_data['id'], + source=link_data['from_id'], + target=link_data['to_id'] + ) + + def get(self, link_id: int) -> Optional[Link]: + """Get a link by its ID via GraphQL.""" + try: + response = self._client.select(link_id, 'from_id', 'to_id') + links = response.get('links', []) + + if not links: + return None + + link_data = links[0] + return Link( + id=link_data['id'], + source=link_data['from_id'], + target=link_data['to_id'] + ) + except Exception: + return None + + def update(self, link_id: int, source: int, target: int) -> Link: + """Update an existing link via GraphQL.""" + response = self._client.update( + {'id': {'_eq': link_id}}, + {'from_id': source, 'to_id': target}, + 'id', 'from_id', 'to_id' + ) + + if not response.get('update_links', {}).get('returning'): + raise DeepClientError(f'Failed to update link {link_id}') + + link_data = response['update_links']['returning'][0] + return Link( + id=link_data['id'], + source=link_data['from_id'], + target=link_data['to_id'] + ) + + def delete(self, link_id: int) -> bool: + """Delete a link by its ID via GraphQL.""" + try: + response = self._client.delete( + {'id': {'_eq': link_id}}, + 'id' + ) + return bool(response.get('delete_links', {}).get('returning')) + except Exception: + return False + + def search(self, source: Optional[int] = None, target: Optional[int] = None, + limit: Optional[int] = None, offset: Optional[int] = None) -> List[Link]: + """Search for links by source and/or target via GraphQL.""" + where_conditions = [] + + if source is not None: + where_conditions.append(f'from_id: {{_eq: {source}}}') + if target is not None: + where_conditions.append(f'to_id: {{_eq: {target}}}') + + where_clause = '' + if where_conditions: + where_clause = f'where: {{{", ".join(where_conditions)}}}' + + options = [] + if where_clause: + options.append(where_clause) + if limit is not None: + options.append(f'limit: {limit}') + if offset is not None: + options.append(f'offset: {offset}') + + options_str = ', '.join(options) + + response = self._client.select_with_options( + options_str, + 'id', 'from_id', 'to_id' + ) + + links = [] + for link_data in response.get('links', []): + links.append(Link( + id=link_data['id'], + source=link_data['from_id'], + target=link_data['to_id'] + )) + + return links + + def count(self, source: Optional[int] = None, target: Optional[int] = None) -> int: + """Count links matching the criteria via GraphQL.""" + where_conditions = [] + + if source is not None: + where_conditions.append(f'from_id: {{_eq: {source}}}') + if target is not None: + where_conditions.append(f'to_id: {{_eq: {target}}}') + + where_clause = '' + if where_conditions: + where_clause = f'where: {{{", ".join(where_conditions)}}}' + + # Use aggregate count query + query = f''' + query {{ + links_aggregate({where_clause}) {{ + aggregate {{ + count + }} + }} + }} + ''' + + response = self._client.query(query) + return response['links_aggregate']['aggregate']['count'] + + +class MockBackend(DoubletsBackend): + """ + Mock backend implementation for testing and development. + + This backend stores links in memory and is useful for testing + without requiring a GraphQL server. + """ + + def __init__(self): + """Initialize mock backend with empty storage.""" + self._links: Dict[int, Link] = {} + self._next_id = 1 + + def create(self, source: Optional[int] = None, target: Optional[int] = None) -> Link: + """Create a new link in memory.""" + link_id = self._next_id + self._next_id += 1 + + # Default to self-reference if not specified + if source is None: + source = link_id + if target is None: + target = link_id + + link = Link(id=link_id, source=source, target=target) + self._links[link_id] = link + return link + + def get(self, link_id: int) -> Optional[Link]: + """Get a link by its ID from memory.""" + return self._links.get(link_id) + + def update(self, link_id: int, source: int, target: int) -> Link: + """Update an existing link in memory.""" + if link_id not in self._links: + raise DeepClientError(f'Link {link_id} does not exist') + + link = Link(id=link_id, source=source, target=target) + self._links[link_id] = link + return link + + def delete(self, link_id: int) -> bool: + """Delete a link by its ID from memory.""" + if link_id in self._links: + del self._links[link_id] + return True + return False + + def search(self, source: Optional[int] = None, target: Optional[int] = None, + limit: Optional[int] = None, offset: Optional[int] = None) -> List[Link]: + """Search for links by source and/or target in memory.""" + results = [] + + for link in self._links.values(): + if source is not None and link.source != source: + continue + if target is not None and link.target != target: + continue + results.append(link) + + # Sort by ID for consistent results + results.sort(key=lambda x: x.id) + + # Apply offset and limit + if offset is not None: + results = results[offset:] + if limit is not None: + results = results[:limit] + + return results + + def count(self, source: Optional[int] = None, target: Optional[int] = None) -> int: + """Count links matching the criteria in memory.""" + count = 0 + + for link in self._links.values(): + if source is not None and link.source != source: + continue + if target is not None and link.target != target: + continue + count += 1 + + return count \ No newline at end of file diff --git a/python/deepclient/client.py b/python/deepclient/client.py index 9c921e92..4455af3c 100644 --- a/python/deepclient/client.py +++ b/python/deepclient/client.py @@ -7,7 +7,7 @@ from gql import Client, gql from gql.transport.aiohttp import AIOHTTPTransport -from python.deepclient.exceptions import DeepClientError +from .exceptions import DeepClientError class DeepClient: diff --git a/python/deepclient/doublets.py b/python/deepclient/doublets.py new file mode 100644 index 00000000..434d2968 --- /dev/null +++ b/python/deepclient/doublets.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- +""" +Python Doublets Adapter - Native Python interface for Doublets operations. + +This module provides a Pythonic interface for Doublets CRUD operations +while supporting pluggable backends (GraphQL, native C++ library, etc.). +""" +from abc import ABC, abstractmethod +from typing import Optional, List, Dict, Any, Union, Iterator +from dataclasses import dataclass + + +@dataclass +class Link: + """Represents a Doublets link with id, source (from_id), and target (to_id).""" + id: int + source: int + target: int + + def __repr__(self) -> str: + return f"Link(id={self.id}, source={self.source}, target={self.target})" + + +class DoubletsBackend(ABC): + """Abstract base class for Doublets storage backends.""" + + @abstractmethod + def create(self, source: Optional[int] = None, target: Optional[int] = None) -> Link: + """Create a new link.""" + pass + + @abstractmethod + def get(self, link_id: int) -> Optional[Link]: + """Get a link by its ID.""" + pass + + @abstractmethod + def update(self, link_id: int, source: int, target: int) -> Link: + """Update an existing link.""" + pass + + @abstractmethod + def delete(self, link_id: int) -> bool: + """Delete a link by its ID.""" + pass + + @abstractmethod + def search(self, source: Optional[int] = None, target: Optional[int] = None, + limit: Optional[int] = None, offset: Optional[int] = None) -> List[Link]: + """Search for links by source and/or target.""" + pass + + @abstractmethod + def count(self, source: Optional[int] = None, target: Optional[int] = None) -> int: + """Count links matching the criteria.""" + pass + + +class Doublets: + """ + Main Doublets interface providing native Python-style CRUD operations. + + This class provides a high-level, Pythonic interface for working with + Doublets while abstracting away the underlying storage backend. + """ + + def __init__(self, backend: DoubletsBackend): + """ + Initialize Doublets with a specific backend. + + Args: + backend: Storage backend implementation (GraphQL, native, etc.) + """ + self._backend = backend + + def create(self, source: Optional[int] = None, target: Optional[int] = None) -> Link: + """ + Create a new link. + + Args: + source: Source link ID (optional, defaults to self-reference) + target: Target link ID (optional, defaults to self-reference) + + Returns: + The created link + + Examples: + >>> doublets = Doublets(backend) + >>> link = doublets.create() # Self-referencing link + >>> link = doublets.create(source=1, target=2) # Link from 1 to 2 + """ + return self._backend.create(source, target) + + def get(self, link_id: int) -> Optional[Link]: + """ + Get a link by its ID. + + Args: + link_id: The ID of the link to retrieve + + Returns: + The link if found, None otherwise + + Examples: + >>> link = doublets.get(42) + >>> if link: + ... print(f"Link: {link.source} -> {link.target}") + """ + return self._backend.get(link_id) + + def update(self, link_id: int, source: int, target: int) -> Link: + """ + Update an existing link. + + Args: + link_id: ID of the link to update + source: New source link ID + target: New target link ID + + Returns: + The updated link + + Examples: + >>> updated = doublets.update(42, source=1, target=3) + """ + return self._backend.update(link_id, source, target) + + def delete(self, link_id: int) -> bool: + """ + Delete a link by its ID. + + Args: + link_id: ID of the link to delete + + Returns: + True if the link was deleted, False otherwise + + Examples: + >>> success = doublets.delete(42) + """ + return self._backend.delete(link_id) + + def search(self, source: Optional[int] = None, target: Optional[int] = None, + limit: Optional[int] = None, offset: Optional[int] = None) -> List[Link]: + """ + Search for links by source and/or target. + + Args: + source: Filter by source link ID (optional) + target: Filter by target link ID (optional) + limit: Maximum number of results (optional) + offset: Number of results to skip (optional) + + Returns: + List of matching links + + Examples: + >>> links = doublets.search(source=1) # All links from 1 + >>> links = doublets.search(target=2) # All links to 2 + >>> links = doublets.search(source=1, target=2) # Links from 1 to 2 + >>> links = doublets.search(limit=10) # First 10 links + """ + return self._backend.search(source, target, limit, offset) + + def count(self, source: Optional[int] = None, target: Optional[int] = None) -> int: + """ + Count links matching the criteria. + + Args: + source: Filter by source link ID (optional) + target: Filter by target link ID (optional) + + Returns: + Number of matching links + + Examples: + >>> total = doublets.count() # Total number of links + >>> from_one = doublets.count(source=1) # Links from 1 + """ + return self._backend.count(source, target) + + def each(self, callback: callable, source: Optional[int] = None, + target: Optional[int] = None) -> None: + """ + Iterate over links and call a function for each one. + + Args: + callback: Function to call for each link + source: Filter by source link ID (optional) + target: Filter by target link ID (optional) + + Examples: + >>> doublets.each(lambda link: print(f"Link: {link}")) + >>> doublets.each(lambda link: print(link.id), source=1) + """ + for link in self.search(source=source, target=target): + callback(link) + + def __iter__(self) -> Iterator[Link]: + """ + Allow iteration over all links. + + Examples: + >>> for link in doublets: + ... print(link) + """ + return iter(self.search()) + + def __len__(self) -> int: + """ + Get the total number of links. + + Examples: + >>> total_links = len(doublets) + """ + return self.count() + + def __contains__(self, link_id: int) -> bool: + """ + Check if a link exists. + + Examples: + >>> if 42 in doublets: + ... print("Link 42 exists") + """ + return self.get(link_id) is not None \ No newline at end of file diff --git a/python/examples/basic_usage.py b/python/examples/basic_usage.py new file mode 100644 index 00000000..ca986481 --- /dev/null +++ b/python/examples/basic_usage.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Basic usage examples for the Python Doublets Adapter. + +This script demonstrates how to use the high-level Doublets interface +with different backends. +""" +from deepclient import Doublets, MockBackend, GraphQLBackend + + +def mock_backend_example(): + """Example using the MockBackend for testing and development.""" + print("=== Mock Backend Example ===") + + # Create a Doublets instance with mock backend + doublets = Doublets(MockBackend()) + + # Create some links + print("Creating links...") + link1 = doublets.create() # Self-referencing link + link2 = doublets.create(source=1, target=2) + link3 = doublets.create(source=link1.id, target=link2.id) + + print(f"Created link1: {link1}") + print(f"Created link2: {link2}") + print(f"Created link3: {link3}") + + # Search for links + print(f"\nTotal links: {len(doublets)}") + print("All links:") + for link in doublets: + print(f" {link}") + + # Search by source + print(f"\nLinks from {link1.id}:") + for link in doublets.search(source=link1.id): + print(f" {link}") + + # Update a link + print(f"\nUpdating link {link2.id}...") + updated = doublets.update(link2.id, source=10, target=20) + print(f"Updated: {updated}") + + # Delete a link + print(f"\nDeleting link {link1.id}...") + success = doublets.delete(link1.id) + print(f"Deleted: {success}") + print(f"Remaining links: {len(doublets)}") + + +def graphql_backend_example(): + """Example using the GraphQLBackend (requires running server).""" + print("\n=== GraphQL Backend Example ===") + + try: + # Create a Doublets instance with GraphQL backend + # Note: This requires a running GraphQL server + doublets = Doublets(GraphQLBackend( + 'http://localhost:60341/v1/graphql', + headers={'Authorization': 'Bearer your-token-here'} # If needed + )) + + print("Connected to GraphQL server") + + # Get total link count + total = doublets.count() + print(f"Total links in database: {total}") + + # Create a new link + new_link = doublets.create(source=1, target=2) + print(f"Created new link: {new_link}") + + # Search for recent links + recent_links = doublets.search(limit=5) + print(f"Recent links:") + for link in recent_links: + print(f" {link}") + + except Exception as e: + print(f"GraphQL backend example failed: {e}") + print("Make sure the GraphQL server is running at localhost:60341") + + +def pythonic_patterns_example(): + """Example demonstrating Pythonic usage patterns.""" + print("\n=== Pythonic Patterns Example ===") + + doublets = Doublets(MockBackend()) + + # Create some test data + for i in range(5): + doublets.create(source=i, target=i+1) + + # Pythonic iteration + print("Using for loop:") + for link in doublets: + print(f" {link}") + + # Using list comprehension + print("\nLinks with source < 3:") + small_source_links = [link for link in doublets if link.source < 3] + for link in small_source_links: + print(f" {link}") + + # Using the 'in' operator + print(f"\nChecking if link exists...") + first_link = next(iter(doublets)) + print(f"Link {first_link.id} exists: {first_link.id in doublets}") + print(f"Link 999 exists: {999 in doublets}") + + # Using len() + print(f"\nTotal links: {len(doublets)}") + + # Using each() method with lambda + print("\nUsing each() method:") + doublets.each(lambda link: print(f" Processing {link}")) + + # Filtering and counting + from_zero_count = doublets.count(source=0) + print(f"\nLinks from source 0: {from_zero_count}") + + +if __name__ == '__main__': + # Run all examples + mock_backend_example() + graphql_backend_example() + pythonic_patterns_example() + + print("\n=== Examples completed ===") + print("See the source code for more usage patterns!") \ No newline at end of file diff --git a/python/examples/custom_backend.py b/python/examples/custom_backend.py new file mode 100644 index 00000000..38eba96d --- /dev/null +++ b/python/examples/custom_backend.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Custom backend example for the Python Doublets Adapter. + +This script demonstrates how to create a custom backend implementation +that could be used for native C++ integration or other storage systems. +""" +from typing import Optional, List, Dict, Any +from deepclient import Doublets, DoubletsBackend, Link, DeepClientError + + +class FileBackend(DoubletsBackend): + """ + Example custom backend that stores links in a simple file format. + + This demonstrates how to implement a custom backend that could + later be replaced with a native C++ library implementation. + """ + + def __init__(self, filename: str): + """Initialize file backend with a storage file.""" + self.filename = filename + self._next_id = 1 + self._load_from_file() + + def _load_from_file(self): + """Load links from file.""" + self._links: Dict[int, Link] = {} + try: + with open(self.filename, 'r') as f: + for line in f: + line = line.strip() + if line: + parts = line.split(',') + if len(parts) == 3: + link_id, source, target = map(int, parts) + self._links[link_id] = Link(link_id, source, target) + self._next_id = max(self._next_id, link_id + 1) + except FileNotFoundError: + # File doesn't exist yet, start fresh + pass + + def _save_to_file(self): + """Save links to file.""" + with open(self.filename, 'w') as f: + for link in self._links.values(): + f.write(f"{link.id},{link.source},{link.target}\n") + + def create(self, source: Optional[int] = None, target: Optional[int] = None) -> Link: + """Create a new link in file.""" + link_id = self._next_id + self._next_id += 1 + + if source is None: + source = link_id + if target is None: + target = link_id + + link = Link(id=link_id, source=source, target=target) + self._links[link_id] = link + self._save_to_file() + return link + + def get(self, link_id: int) -> Optional[Link]: + """Get a link by its ID from file.""" + return self._links.get(link_id) + + def update(self, link_id: int, source: int, target: int) -> Link: + """Update an existing link in file.""" + if link_id not in self._links: + raise DeepClientError(f'Link {link_id} does not exist') + + link = Link(id=link_id, source=source, target=target) + self._links[link_id] = link + self._save_to_file() + return link + + def delete(self, link_id: int) -> bool: + """Delete a link by its ID from file.""" + if link_id in self._links: + del self._links[link_id] + self._save_to_file() + return True + return False + + def search(self, source: Optional[int] = None, target: Optional[int] = None, + limit: Optional[int] = None, offset: Optional[int] = None) -> List[Link]: + """Search for links by source and/or target in file.""" + results = [] + + for link in self._links.values(): + if source is not None and link.source != source: + continue + if target is not None and link.target != target: + continue + results.append(link) + + # Sort by ID for consistent results + results.sort(key=lambda x: x.id) + + # Apply offset and limit + if offset is not None: + results = results[offset:] + if limit is not None: + results = results[:limit] + + return results + + def count(self, source: Optional[int] = None, target: Optional[int] = None) -> int: + """Count links matching the criteria in file.""" + count = 0 + + for link in self._links.values(): + if source is not None and link.source != source: + continue + if target is not None and link.target != target: + continue + count += 1 + + return count + + +class NativeBackendPlaceholder(DoubletsBackend): + """ + Placeholder for future native C++ backend. + + This demonstrates the interface that would be used when + the native C++ library is ready. + """ + + def __init__(self, library_path: str): + """Initialize with path to native library.""" + self.library_path = library_path + # In the future, this would load the C++ library using ctypes or pyo3 + raise NotImplementedError( + "Native C++ backend not yet implemented. " + "This is a placeholder for future development." + ) + + def create(self, source: Optional[int] = None, target: Optional[int] = None) -> Link: + """Would call native C++ create function.""" + pass + + def get(self, link_id: int) -> Optional[Link]: + """Would call native C++ get function.""" + pass + + def update(self, link_id: int, source: int, target: int) -> Link: + """Would call native C++ update function.""" + pass + + def delete(self, link_id: int) -> bool: + """Would call native C++ delete function.""" + pass + + def search(self, source: Optional[int] = None, target: Optional[int] = None, + limit: Optional[int] = None, offset: Optional[int] = None) -> List[Link]: + """Would call native C++ search function.""" + pass + + def count(self, source: Optional[int] = None, target: Optional[int] = None) -> int: + """Would call native C++ count function.""" + pass + + +def file_backend_example(): + """Example using the custom FileBackend.""" + print("=== File Backend Example ===") + + # Create a Doublets instance with file backend + doublets = Doublets(FileBackend('/tmp/doublets_test.db')) + + print("Creating links...") + link1 = doublets.create(source=1, target=2) + link2 = doublets.create(source=2, target=3) + link3 = doublets.create(source=link1.id, target=link2.id) + + print(f"Created: {link1}") + print(f"Created: {link2}") + print(f"Created: {link3}") + + print(f"\nTotal links: {len(doublets)}") + + # Create another instance to test persistence + print("\nCreating new instance to test persistence...") + doublets2 = Doublets(FileBackend('/tmp/doublets_test.db')) + print(f"Loaded {len(doublets2)} links from file") + + for link in doublets2: + print(f" {link}") + + # Clean up + import os + try: + os.remove('/tmp/doublets_test.db') + print("\nCleaned up test file") + except FileNotFoundError: + pass + + +def backend_switching_example(): + """Example demonstrating how to switch between backends.""" + print("\n=== Backend Switching Example ===") + + # Start with mock backend + from deepclient import MockBackend + mock_backend = MockBackend() + doublets = Doublets(mock_backend) + + # Create some data + for i in range(3): + doublets.create(source=i, target=i+1) + + print(f"Mock backend has {len(doublets)} links") + + # Switch to file backend + file_backend = FileBackend('/tmp/doublets_switch_test.db') + doublets._backend = file_backend + + # Migrate data (simple example) + print("Migrating data to file backend...") + for link in mock_backend.search(): + file_backend.create(source=link.source, target=link.target) + + print(f"File backend now has {len(doublets)} links") + + # This demonstrates how the same Doublets interface can work + # with different storage backends + for link in doublets: + print(f" {link}") + + # Clean up + import os + try: + os.remove('/tmp/doublets_switch_test.db') + print("\nCleaned up test file") + except FileNotFoundError: + pass + + +if __name__ == '__main__': + file_backend_example() + backend_switching_example() + + print("\n=== Custom Backend Examples Completed ===") + print("This demonstrates the pluggable architecture that allows") + print("swapping GraphQL with native C++ library in the future.") \ No newline at end of file diff --git a/python/setup.py b/python/setup.py index df7f768c..376571b6 100644 --- a/python/setup.py +++ b/python/setup.py @@ -4,33 +4,51 @@ long_description = fh.read() setuptools.setup( - name="deepclient", - version="0.1.2", - author="Ethosa", - author_email="social.ethosa@gmail.com", - description="Data.Doublets.Gql", + name="doublets-gql", + version="0.2.0", + author="LinksPlatform Contributors", + author_email="konard@yandex.ru", + description="Python Doublets Adapter via GraphQL client - Native Python interface for Doublets operations", long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/linksplatform/Data.Doublets.Gql", packages=setuptools.find_packages(), license="LGPLv3", - keywords="Python Gql", + keywords="Doublets, GraphQL, Links, Associations, Data Structure, Database", classifiers=[ "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Topic :: Database", + "Topic :: Software Development :: Libraries :: Python Modules", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", + "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", "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", "Operating System :: OS Independent", ], project_urls={ - "Github": "https://github.com/linksplatform/Data.Doublets.Gql", - "Documentation": "https://github.com/linksplatform/Data.Doublets.Gql", + "Homepage": "https://github.com/linksplatform/Data.Doublets.Gql", + "Repository": "https://github.com/linksplatform/Data.Doublets.Gql", + "Issues": "https://github.com/linksplatform/Data.Doublets.Gql/issues", + "Documentation": "https://github.com/linksplatform/Data.Doublets.Gql/tree/main/python", }, - python_requires=">=3", + python_requires=">=3.8", install_requires=[ - 'gql', - ] + 'gql>=3.0.0', + 'aiohttp>=3.8.0', + ], + extras_require={ + 'dev': [ + 'pytest>=7.0.0', + 'pytest-asyncio>=0.21.0', + 'pytest-cov>=4.0.0', + ], + 'docs': [ + 'sphinx>=5.0.0', + 'sphinx-rtd-theme>=1.0.0', + ], + }, ) diff --git a/python/tests/test_client.py b/python/tests/test_client.py index bf8288cc..6844b045 100644 --- a/python/tests/test_client.py +++ b/python/tests/test_client.py @@ -4,7 +4,7 @@ from gql.transport.exceptions import TransportQueryError -from __init__ import DeepClient, DeepClientError +from deepclient import DeepClient, DeepClientError from config import GQL_URL, GQL_TOKEN diff --git a/python/tests/test_doublets.py b/python/tests/test_doublets.py new file mode 100644 index 00000000..cf88bd05 --- /dev/null +++ b/python/tests/test_doublets.py @@ -0,0 +1,393 @@ +# -*- coding: utf-8 -*- +""" +Comprehensive tests for the Doublets adapter. + +This module tests both the high-level Doublets interface +and the various backend implementations. +""" +import unittest +from unittest.mock import Mock, patch +from typing import Optional + +from deepclient import Doublets, Link, MockBackend, GraphQLBackend, DeepClientError + + +class TestLink(unittest.TestCase): + """Test the Link data structure.""" + + def test_link_creation(self): + """Test creating a Link instance.""" + link = Link(id=1, source=2, target=3) + self.assertEqual(link.id, 1) + self.assertEqual(link.source, 2) + self.assertEqual(link.target, 3) + + def test_link_repr(self): + """Test Link string representation.""" + link = Link(id=1, source=2, target=3) + self.assertEqual(str(link), "Link(id=1, source=2, target=3)") + + +class TestMockBackend(unittest.TestCase): + """Test the MockBackend implementation.""" + + def setUp(self): + """Set up test fixtures.""" + self.backend = MockBackend() + + def test_create_self_referencing_link(self): + """Test creating a self-referencing link.""" + link = self.backend.create() + self.assertEqual(link.id, 1) + self.assertEqual(link.source, 1) + self.assertEqual(link.target, 1) + + def test_create_link_with_source_target(self): + """Test creating a link with specified source and target.""" + link = self.backend.create(source=5, target=10) + self.assertEqual(link.id, 1) + self.assertEqual(link.source, 5) + self.assertEqual(link.target, 10) + + def test_get_existing_link(self): + """Test getting an existing link.""" + created = self.backend.create(source=2, target=3) + retrieved = self.backend.get(created.id) + + self.assertIsNotNone(retrieved) + self.assertEqual(retrieved.id, created.id) + self.assertEqual(retrieved.source, 2) + self.assertEqual(retrieved.target, 3) + + def test_get_nonexistent_link(self): + """Test getting a non-existent link.""" + result = self.backend.get(999) + self.assertIsNone(result) + + def test_update_link(self): + """Test updating an existing link.""" + created = self.backend.create(source=1, target=2) + updated = self.backend.update(created.id, source=3, target=4) + + self.assertEqual(updated.id, created.id) + self.assertEqual(updated.source, 3) + self.assertEqual(updated.target, 4) + + def test_update_nonexistent_link(self): + """Test updating a non-existent link.""" + with self.assertRaises(DeepClientError): + self.backend.update(999, source=1, target=2) + + def test_delete_existing_link(self): + """Test deleting an existing link.""" + created = self.backend.create() + result = self.backend.delete(created.id) + + self.assertTrue(result) + self.assertIsNone(self.backend.get(created.id)) + + def test_delete_nonexistent_link(self): + """Test deleting a non-existent link.""" + result = self.backend.delete(999) + self.assertFalse(result) + + def test_search_all_links(self): + """Test searching for all links.""" + link1 = self.backend.create(source=1, target=2) + link2 = self.backend.create(source=2, target=3) + + results = self.backend.search() + self.assertEqual(len(results), 2) + self.assertIn(link1, results) + self.assertIn(link2, results) + + def test_search_by_source(self): + """Test searching by source.""" + link1 = self.backend.create(source=1, target=2) + link2 = self.backend.create(source=1, target=3) + link3 = self.backend.create(source=2, target=4) + + results = self.backend.search(source=1) + self.assertEqual(len(results), 2) + self.assertIn(link1, results) + self.assertIn(link2, results) + self.assertNotIn(link3, results) + + def test_search_by_target(self): + """Test searching by target.""" + link1 = self.backend.create(source=1, target=2) + link2 = self.backend.create(source=3, target=2) + link3 = self.backend.create(source=4, target=5) + + results = self.backend.search(target=2) + self.assertEqual(len(results), 2) + self.assertIn(link1, results) + self.assertIn(link2, results) + self.assertNotIn(link3, results) + + def test_search_by_source_and_target(self): + """Test searching by both source and target.""" + link1 = self.backend.create(source=1, target=2) + link2 = self.backend.create(source=1, target=3) + link3 = self.backend.create(source=2, target=2) + + results = self.backend.search(source=1, target=2) + self.assertEqual(len(results), 1) + self.assertIn(link1, results) + self.assertNotIn(link2, results) + self.assertNotIn(link3, results) + + def test_search_with_limit(self): + """Test searching with limit.""" + for i in range(5): + self.backend.create(source=i, target=i+1) + + results = self.backend.search(limit=3) + self.assertEqual(len(results), 3) + + def test_search_with_offset(self): + """Test searching with offset.""" + links = [] + for i in range(5): + links.append(self.backend.create(source=i, target=i+1)) + + results = self.backend.search(offset=2) + self.assertEqual(len(results), 3) + # Results should be sorted by ID + self.assertEqual(results[0].id, links[2].id) + + def test_count_all_links(self): + """Test counting all links.""" + for i in range(3): + self.backend.create() + + count = self.backend.count() + self.assertEqual(count, 3) + + def test_count_by_source(self): + """Test counting by source.""" + self.backend.create(source=1, target=2) + self.backend.create(source=1, target=3) + self.backend.create(source=2, target=4) + + count = self.backend.count(source=1) + self.assertEqual(count, 2) + + def test_count_by_target(self): + """Test counting by target.""" + self.backend.create(source=1, target=2) + self.backend.create(source=3, target=2) + self.backend.create(source=4, target=5) + + count = self.backend.count(target=2) + self.assertEqual(count, 2) + + +class TestDoublets(unittest.TestCase): + """Test the high-level Doublets interface.""" + + def setUp(self): + """Set up test fixtures.""" + self.backend = MockBackend() + self.doublets = Doublets(self.backend) + + def test_create_link(self): + """Test creating a link through the Doublets interface.""" + link = self.doublets.create(source=1, target=2) + self.assertEqual(link.source, 1) + self.assertEqual(link.target, 2) + + def test_get_link(self): + """Test getting a link through the Doublets interface.""" + created = self.doublets.create(source=3, target=4) + retrieved = self.doublets.get(created.id) + + self.assertIsNotNone(retrieved) + self.assertEqual(retrieved.id, created.id) + + def test_update_link(self): + """Test updating a link through the Doublets interface.""" + created = self.doublets.create(source=1, target=2) + updated = self.doublets.update(created.id, source=5, target=6) + + self.assertEqual(updated.source, 5) + self.assertEqual(updated.target, 6) + + def test_delete_link(self): + """Test deleting a link through the Doublets interface.""" + created = self.doublets.create() + result = self.doublets.delete(created.id) + + self.assertTrue(result) + self.assertIsNone(self.doublets.get(created.id)) + + def test_search_links(self): + """Test searching for links through the Doublets interface.""" + link1 = self.doublets.create(source=1, target=2) + link2 = self.doublets.create(source=1, target=3) + + results = self.doublets.search(source=1) + self.assertEqual(len(results), 2) + + def test_count_links(self): + """Test counting links through the Doublets interface.""" + for i in range(3): + self.doublets.create() + + count = self.doublets.count() + self.assertEqual(count, 3) + + def test_each_method(self): + """Test the each method.""" + for i in range(3): + self.doublets.create(source=i, target=i+1) + + links = [] + self.doublets.each(lambda link: links.append(link)) + + self.assertEqual(len(links), 3) + + def test_iteration(self): + """Test iteration over all links.""" + created_links = [] + for i in range(3): + created_links.append(self.doublets.create(source=i, target=i+1)) + + iterated_links = list(self.doublets) + self.assertEqual(len(iterated_links), 3) + + for link in iterated_links: + self.assertIn(link, created_links) + + def test_len_method(self): + """Test the len() method.""" + for i in range(5): + self.doublets.create() + + self.assertEqual(len(self.doublets), 5) + + def test_contains_method(self): + """Test the 'in' operator.""" + link = self.doublets.create() + + self.assertIn(link.id, self.doublets) + self.assertNotIn(999, self.doublets) + + +class TestGraphQLBackend(unittest.TestCase): + """Test the GraphQLBackend implementation.""" + + def setUp(self): + """Set up test fixtures with mocked GraphQL client.""" + with patch('deepclient.backends.DeepClient') as mock_client_class: + self.mock_client = Mock() + mock_client_class.return_value = self.mock_client + self.backend = GraphQLBackend('http://test.com/graphql') + + def test_create_with_source_target(self): + """Test creating a link with source and target.""" + # Mock the GraphQL response + self.mock_client.query.return_value = { + 'insert_links_one': { + 'id': 1, + 'from_id': 2, + 'to_id': 3 + } + } + + link = self.backend.create(source=2, target=3) + + self.assertEqual(link.id, 1) + self.assertEqual(link.source, 2) + self.assertEqual(link.target, 3) + + def test_get_existing_link(self): + """Test getting an existing link.""" + # Mock the GraphQL response + self.mock_client.select.return_value = { + 'links': [{ + 'id': 1, + 'from_id': 2, + 'to_id': 3 + }] + } + + link = self.backend.get(1) + + self.assertIsNotNone(link) + self.assertEqual(link.id, 1) + self.assertEqual(link.source, 2) + self.assertEqual(link.target, 3) + + def test_get_nonexistent_link(self): + """Test getting a non-existent link.""" + # Mock empty response + self.mock_client.select.return_value = {'links': []} + + link = self.backend.get(999) + self.assertIsNone(link) + + def test_update_link(self): + """Test updating a link.""" + # Mock the GraphQL response + self.mock_client.update.return_value = { + 'update_links': { + 'returning': [{ + 'id': 1, + 'from_id': 5, + 'to_id': 6 + }] + } + } + + link = self.backend.update(1, source=5, target=6) + + self.assertEqual(link.id, 1) + self.assertEqual(link.source, 5) + self.assertEqual(link.target, 6) + + def test_delete_link(self): + """Test deleting a link.""" + # Mock successful deletion + self.mock_client.delete.return_value = { + 'delete_links': { + 'returning': [{'id': 1}] + } + } + + result = self.backend.delete(1) + self.assertTrue(result) + + def test_search_all_links(self): + """Test searching for all links.""" + # Mock the GraphQL response + self.mock_client.select_with_options.return_value = { + 'links': [ + {'id': 1, 'from_id': 2, 'to_id': 3}, + {'id': 4, 'from_id': 5, 'to_id': 6} + ] + } + + links = self.backend.search() + + self.assertEqual(len(links), 2) + self.assertEqual(links[0].id, 1) + self.assertEqual(links[1].id, 4) + + def test_count_links(self): + """Test counting links.""" + # Mock the GraphQL response + self.mock_client.query.return_value = { + 'links_aggregate': { + 'aggregate': { + 'count': 42 + } + } + } + + count = self.backend.count() + self.assertEqual(count, 42) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file From 0a868406d18ebef8ff40153db4e77eb2a9afa561 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 13 Sep 2025 06:55:04 +0300 Subject: [PATCH 3/3] Remove CLAUDE.md - Claude command completed --- CLAUDE.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 83534650..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,5 +0,0 @@ -Issue to solve: https://github.com/linksplatform/Data.Doublets.Gql/issues/15 -Your prepared branch: issue-15-d13701fb -Your prepared working directory: /tmp/gh-issue-solver-1757735110665 - -Proceed. \ No newline at end of file