diff --git a/.env.example b/.env.example index 41c50ae..8a92a5d 100644 --- a/.env.example +++ b/.env.example @@ -34,4 +34,4 @@ ENABLE_CACHING=false # 3. Go to Network tab # 4. Make any action that triggers a GraphQL request # 5. Right-click on the request → Copy as cURL -# 6. Extract the x-access-token, session cookie, and cf_clearance token from the cURL command \ No newline at end of file +# 6. Extract the x-access-token, session cookie, and cf_clearance token from the cURL command diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 809fe18..1eaa48c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v3 @@ -28,15 +28,15 @@ jobs: - name: Lint with flake8 run: | - flake8 stakeapi tests examples + flake8 --max-line-length=88 --extend-ignore=E203 stakeapi tests examples - name: Check formatting with black run: | - black --check stakeapi tests examples + black --check --line-length 88 stakeapi tests examples - name: Check import sorting with isort run: | - isort --check-only stakeapi tests examples + isort --check-only --profile black --line-length 88 stakeapi tests examples - name: Type check with mypy run: | @@ -64,7 +64,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.11" + python-version: "3.12" - name: Install build dependencies run: | diff --git a/.gitignore b/.gitignore index b7e804a..3856622 100644 --- a/.gitignore +++ b/.gitignore @@ -176,4 +176,4 @@ logs/ temp/ cache/ -cookie.txt \ No newline at end of file +cookie.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 86de7eb..cb0a4a7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -11,26 +11,27 @@ repos: - id: debug-statements - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 24.10.0 hooks: - id: black language_version: python3 - repo: https://github.com/pycqa/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort - args: ["--profile", "black"] + args: ["--profile", "black", "--line-length", "88"] - repo: https://github.com/pycqa/flake8 - rev: 6.0.0 + rev: 7.1.1 hooks: - id: flake8 args: [--max-line-length=88, --extend-ignore=E203] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.3.0 + rev: v1.13.0 hooks: - id: mypy additional_dependencies: [types-requests] + pass_filenames: false args: [--ignore-missing-imports] diff --git a/README.md b/README.md index 4a1616e..14c633b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ An unofficial Python API wrapper for stake.com - the online gambling platform. ## āš ļø Disclaimer This is an unofficial API wrapper and is not affiliated with, endorsed by, or connected to stake.com in any way. Use at your own risk and ensure compliance with all applicable laws and regulations in your jurisdiction. - + ## Features - šŸŽ° Access to casino games data diff --git a/curl-test.txt b/curl-test.txt index 9592f19..0dd5d67 100644 --- a/curl-test.txt +++ b/curl-test.txt @@ -18,4 +18,4 @@ curl ^"https://stake.com/_api/graphql^" ^ -H ^"x-language: fr^" ^ -H ^"x-operation-name: CurrencyConfiguration^" ^ -H ^"x-operation-type: query^" ^ - --data-raw ^"^{^\^"query^\^":^\^"query CurrencyConfiguration(^$isAcp: Boolean^!) ^{^\^\n currencyConfiguration(isAcp: ^$isAcp) ^{^\^\n baseRates ^{^\^\n currency^\^\n baseRate^\^\n ^}^\^\n launchedFiatCurrencies^\^\n displayFiatCurrencies^\^\n ^}^\^\n^}^\^",^\^"variables^\^":^{^\^"isAcp^\^":false^}^}^" \ No newline at end of file + --data-raw ^"^{^\^"query^\^":^\^"query CurrencyConfiguration(^$isAcp: Boolean^!) ^{^\^\n currencyConfiguration(isAcp: ^$isAcp) ^{^\^\n baseRates ^{^\^\n currency^\^\n baseRate^\^\n ^}^\^\n launchedFiatCurrencies^\^\n displayFiatCurrencies^\^\n ^}^\^\n^}^\^",^\^"variables^\^":^{^\^"isAcp^\^":false^}^}^" diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 05cc827..c99ebcb 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -27,7 +27,7 @@ Each step is covered in detail in the pages below. Most developers are up and ru ## Prerequisites -- Python 3.8 or higher +- Python 3.10 or higher - A [Stake.com account](https://stake.com/?c=WY7953wQ) - Basic knowledge of Python and async/await diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 2b87e2b..b6c6c2a 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -121,7 +121,7 @@ StakeAPI version: 0.1.0 | Requirement | Minimum | |:------------|:--------| -| Python | 3.8+ | +| Python | 3.10+ | | OS | Windows, macOS, Linux | | Memory | 64 MB | | Network | Internet connection required | diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 13d0145..f7a0504 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -17,7 +17,7 @@ Go from zero to your first API call in under 60 seconds. Before continuing, make sure you have: -- [x] Python 3.8+ installed +- [x] Python 3.10+ installed - [x] StakeAPI installed (`pip install stakeapi`) - [x] A [Stake.com account](https://stake.com/?c=WY7953wQ) with an access token @@ -34,20 +34,20 @@ from stakeapi import StakeAPI async def main(): # Replace with your actual access token async with StakeAPI(access_token="your_access_token_here") as client: - + # 1. Get your balance balance = await client.get_user_balance() print("šŸ’° Your Balance:") for currency, amount in balance["available"].items(): if amount > 0: print(f" {currency.upper()}: {amount}") - + # 2. Browse casino games games = await client.get_casino_games(category="slots") print(f"\nšŸŽ° Found {len(games)} slot games!") for game in games[:5]: print(f" - {game.name} by {game.provider}") - + # 3. Check sports events events = await client.get_sports_events(sport="football") print(f"\n⚽ Found {len(events)} football events!") @@ -80,7 +80,7 @@ async def main(): if not token: print("Set STAKE_ACCESS_TOKEN environment variable!") return - + async with StakeAPI(access_token=token) as client: balance = await client.get_user_balance() print(balance) @@ -126,7 +126,7 @@ async def main(): client.get_casino_games(), client.get_sports_events(), ) - + print(f"Balance: {balance}") print(f"Games: {len(games)}") print(f"Events: {len(events)}") diff --git a/docs/guides/advanced-usage.md b/docs/guides/advanced-usage.md index 2c2d61b..e965b31 100644 --- a/docs/guides/advanced-usage.md +++ b/docs/guides/advanced-usage.md @@ -27,10 +27,10 @@ from stakeapi import StakeAPI class StakeAnalytics: """Comprehensive analytics engine for Stake.com.""" - + def __init__(self, access_token: str): self.access_token = access_token - + async def full_report(self): async with StakeAPI(access_token=self.access_token) as client: # Fetch all data concurrently @@ -39,33 +39,33 @@ class StakeAnalytics: client.get_bet_history(limit=100), client.get_casino_games(), ) - + self._print_balance_report(balance) self._print_betting_report(bets) self._print_game_report(games) - + def _print_balance_report(self, balance): print("\nšŸ’° BALANCE REPORT") print("=" * 50) - + for category in ["available", "vault"]: non_zero = {k: v for k, v in balance[category].items() if v > 0} if non_zero: print(f"\n {category.title()}:") for currency, amount in sorted(non_zero.items()): print(f" {currency.upper():8s} {amount:.8f}") - + def _print_betting_report(self, bets): if not bets: return - + total = len(bets) won = sum(1 for b in bets if b.status == "won") lost = sum(1 for b in bets if b.status == "lost") - + total_wagered = sum(float(b.amount) for b in bets) total_won = sum(float(b.potential_payout) for b in bets if b.status == "won") - + print("\nšŸ“Š BETTING PERFORMANCE") print("=" * 50) print(f" Total Bets: {total}") @@ -74,27 +74,27 @@ class StakeAnalytics: print(f" Total Wagered: {total_wagered:.6f}") print(f" Total Won: {total_won:.6f}") print(f" Net P&L: {total_won - total_wagered:+.6f}") - + if total_wagered > 0: roi = (total_won - total_wagered) / total_wagered * 100 print(f" ROI: {roi:+.2f}%") - + def _print_game_report(self, games): print("\nšŸŽ° GAME CATALOG") print("=" * 50) print(f" Total Games: {len(games)}") - + categories = defaultdict(int) providers = defaultdict(int) - + for game in games: categories[game.category] += 1 providers[game.provider] += 1 - + print(f"\n Categories ({len(categories)}):") for cat, count in sorted(categories.items(), key=lambda x: x[1], reverse=True): print(f" {cat:20s} {count:4d}") - + print(f"\n Top 10 Providers:") for prov, count in sorted(providers.items(), key=lambda x: x[1], reverse=True)[:10]: print(f" {prov:25s} {count:4d}") @@ -115,12 +115,12 @@ Deep-dive into game providers to find the best options: async def analyze_providers(): async with StakeAPI(access_token="your_token") as client: games = await client.get_casino_games() - + providers = defaultdict(lambda: { "count": 0, "categories": set(), "rtps": [], "min_bets": [], "max_bets": [] }) - + for game in games: p = providers[game.provider] p["count"] += 1 @@ -129,15 +129,15 @@ async def analyze_providers(): p["rtps"].append(game.rtp) p["min_bets"].append(float(game.min_bet)) p["max_bets"].append(float(game.max_bet)) - + print("šŸ¢ PROVIDER DEEP DIVE") print("=" * 70) - + for name, data in sorted(providers.items(), key=lambda x: x[1]["count"], reverse=True)[:15]: avg_rtp = sum(data["rtps"]) / len(data["rtps"]) if data["rtps"] else 0 avg_min = sum(data["min_bets"]) / len(data["min_bets"]) avg_max = sum(data["max_bets"]) / len(data["max_bets"]) - + print(f"\n šŸ¢ {name}") print(f" Games: {data['count']}") print(f" Categories: {', '.join(sorted(data['categories']))}") @@ -156,29 +156,29 @@ Automatically find sports events with the best odds: async def find_value_bets(): async with StakeAPI(access_token="your_token") as client: events = await client.get_sports_events() - + value_bets = [] - + for event in events: if not event.odds or len(event.odds) < 2: continue - + # Calculate bookmaker margin total_implied = sum(1/v for v in event.odds.values() if v > 0) margin = (total_implied - 1) * 100 - + if margin < 5.0: # Less than 5% margin = good value value_bets.append({ "event": event, "margin": margin, "sport": event.sport, }) - + value_bets.sort(key=lambda x: x["margin"]) - + print("šŸ’Ž VALUE BETS (Lowest Margins)") print("=" * 60) - + for item in value_bets[:20]: e = item["event"] print(f"\n {e.sport.upper()} | {e.league}") @@ -201,34 +201,34 @@ from datetime import datetime async def monitor_loop(access_token: str, interval: int = 60): """Continuously monitor balance and generate alerts.""" - + previous_balance = {} - + while True: try: async with StakeAPI(access_token=access_token) as client: balance = await client.get_user_balance() timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - + for currency, amount in balance["available"].items(): if amount <= 0: continue - + prev = previous_balance.get(currency, amount) change = amount - prev - + if change > 0: print(f"[{timestamp}] šŸ“ˆ {currency.upper()}: +{change:.8f} " f"(now: {amount:.8f})") elif change < 0: print(f"[{timestamp}] šŸ“‰ {currency.upper()}: {change:.8f} " f"(now: {amount:.8f})") - + previous_balance = balance["available"] - + except Exception as e: print(f"[{timestamp}] āŒ Error: {e}") - + await asyncio.sleep(interval) asyncio.run(monitor_loop("your_token", interval=30)) @@ -245,14 +245,14 @@ from io import StringIO async def export_bets_to_csv(): async with StakeAPI(access_token="your_token") as client: bets = await client.get_bet_history(limit=100) - + with open("bet_history.csv", "w", newline="") as f: writer = csv.writer(f) writer.writerow([ "ID", "Type", "Amount", "Payout", "Odds", "Status", "Placed At", "Settled At" ]) - + for bet in bets: writer.writerow([ bet.id, @@ -264,7 +264,7 @@ async def export_bets_to_csv(): bet.placed_at.isoformat(), bet.settled_at.isoformat() if bet.settled_at else "", ]) - + print(f"āœ… Exported {len(bets)} bets to bet_history.csv") asyncio.run(export_bets_to_csv()) diff --git a/docs/guides/betting.md b/docs/guides/betting.md index 971aaba..70c1fa4 100644 --- a/docs/guides/betting.md +++ b/docs/guides/betting.md @@ -36,7 +36,7 @@ async def place_a_bet(): "currency": "btc", "bet_type": "single" }) - + print(f"āœ… Bet placed!") print(f" Bet ID: {bet.id}") print(f" Amount: {bet.amount}") @@ -54,10 +54,10 @@ Retrieve your recent bets with full details: async def view_bet_history(): async with StakeAPI(access_token="your_token") as client: bets = await client.get_bet_history(limit=50) - + print(f"šŸ“‹ BET HISTORY ({len(bets)} bets)") print("=" * 70) - + for bet in bets: status_icon = { "won": "🟢", @@ -65,7 +65,7 @@ async def view_bet_history(): "pending": "🟔", "cancelled": "⚪" }.get(bet.status, "ā“") - + print(f"\n{status_icon} Bet #{bet.id}") print(f" Amount: {bet.amount}") print(f" Payout: {bet.potential_payout}") @@ -92,9 +92,9 @@ async with StakeAPI(access_token="your_token") as client: variables={"first": 20}, operation_name="BetHistory" ) - + bets = data.get("user", {}).get("bets", {}).get("edges", []) - + for edge in bets: bet = edge["node"] print(f"Game: {bet['game']['name']}") @@ -115,24 +115,24 @@ from decimal import Decimal async def betting_analytics(): async with StakeAPI(access_token="your_token") as client: bets = await client.get_bet_history(limit=100) - + if not bets: print("No bet history found") return - + # Basic stats total = len(bets) won = [b for b in bets if b.status == "won"] lost = [b for b in bets if b.status == "lost"] pending = [b for b in bets if b.status == "pending"] - + total_wagered = sum(b.amount for b in bets) total_won = sum(b.potential_payout for b in won) net_profit = total_won - total_wagered - + win_rate = len(won) / total * 100 if total > 0 else 0 roi = float(net_profit / total_wagered * 100) if total_wagered > 0 else 0 - + print("šŸ“Š BETTING PERFORMANCE") print("=" * 50) print(f" Total Bets: {total}") @@ -143,20 +143,20 @@ async def betting_analytics(): print(f" Total Won: {total_won}") print(f" Net Profit: {net_profit}") print(f" ROI: {roi:+.2f}%") - + # Biggest win if won: biggest = max(won, key=lambda b: b.potential_payout) print(f"\nšŸ† Biggest Win:") print(f" Amount: {biggest.amount} → Payout: {biggest.potential_payout}") print(f" Odds: {biggest.odds}") - + # Streaks current_streak = 0 best_win_streak = 0 worst_loss_streak = 0 temp_streak = 0 - + for bet in sorted(bets, key=lambda b: b.placed_at): if bet.status == "won": if temp_streak > 0: @@ -170,7 +170,7 @@ async def betting_analytics(): else: temp_streak = -1 worst_loss_streak = max(worst_loss_streak, abs(temp_streak)) - + print(f"\nšŸ“ˆ Streaks:") print(f" Best Win Streak: {best_win_streak}") print(f" Worst Loss Streak: {worst_loss_streak}") @@ -203,29 +203,29 @@ from datetime import datetime, timedelta async def daily_pnl(): async with StakeAPI(access_token="your_token") as client: bets = await client.get_bet_history(limit=100) - + # Group by day daily = {} for bet in bets: day = bet.placed_at.strftime("%Y-%m-%d") if day not in daily: daily[day] = {"wagered": Decimal(0), "won": Decimal(0), "count": 0} - + daily[day]["wagered"] += bet.amount daily[day]["count"] += 1 - + if bet.status == "won": daily[day]["won"] += bet.potential_payout - + print("šŸ“… DAILY PROFIT/LOSS") print("=" * 60) - + running_total = Decimal(0) for day in sorted(daily.keys()): d = daily[day] pnl = d["won"] - d["wagered"] running_total += pnl - + icon = "🟢" if pnl >= 0 else "šŸ”“" print(f" {day} {icon} {float(pnl):+10.4f} " f"(Bets: {d['count']}, Running: {float(running_total):+.4f})") diff --git a/docs/guides/casino-games.md b/docs/guides/casino-games.md index 3560cfb..3b37938 100644 --- a/docs/guides/casino-games.md +++ b/docs/guides/casino-games.md @@ -29,7 +29,7 @@ async def main(): async with StakeAPI(access_token="your_token") as client: games = await client.get_casino_games() print(f"Total games available: {len(games)}") - + for game in games[:10]: print(f"šŸŽ° {game.name}") print(f" Provider: {game.provider}") @@ -51,7 +51,7 @@ async with StakeAPI(access_token="your_token") as client: # Get only slot games slots = await client.get_casino_games(category="slots") print(f"Slot games: {len(slots)}") - + # Get table games table_games = await client.get_casino_games(category="table-games") print(f"Table games: {len(table_games)}") @@ -74,7 +74,7 @@ Get detailed information about a specific game: ```python async with StakeAPI(access_token="your_token") as client: game = await client.get_game_details("game_id_here") - + print(f"Name: {game.name}") print(f"Provider: {game.provider}") print(f"Category: {game.category}") @@ -98,19 +98,19 @@ from stakeapi import StakeAPI async def analyze_providers(): async with StakeAPI(access_token="your_token") as client: games = await client.get_casino_games() - + providers = defaultdict(lambda: {"count": 0, "rtps": [], "categories": set()}) - + for game in games: p = providers[game.provider] p["count"] += 1 p["categories"].add(game.category) if game.rtp: p["rtps"].append(game.rtp) - + print("šŸ“Š Provider Analysis") print("=" * 60) - + for name, data in sorted(providers.items(), key=lambda x: x[1]["count"], reverse=True): avg_rtp = sum(data["rtps"]) / len(data["rtps"]) if data["rtps"] else 0 print(f"\nšŸ¢ {name}") @@ -130,14 +130,14 @@ Smart players look for games with the highest Return to Player percentage: async def find_best_rtp_games(): async with StakeAPI(access_token="your_token") as client: games = await client.get_casino_games() - + # Filter games with RTP data and sort by RTP games_with_rtp = [g for g in games if g.rtp is not None] games_with_rtp.sort(key=lambda g: g.rtp, reverse=True) - + print("šŸŽÆ Top 20 Games by RTP") print("=" * 50) - + for i, game in enumerate(games_with_rtp[:20], 1): print(f"{i:2d}. {game.name}") print(f" Provider: {game.provider} | RTP: {game.rtp}%") @@ -153,14 +153,14 @@ Build a custom search function to find games by name: async def search_games(query: str): async with StakeAPI(access_token="your_token") as client: all_games = await client.get_casino_games() - + # Case-insensitive search matches = [g for g in all_games if query.lower() in g.name.lower()] - + print(f"šŸ” Search results for '{query}': {len(matches)} games") for game in matches: print(f" - {game.name} ({game.provider}) — RTP: {game.rtp or 'N/A'}%") - + return matches asyncio.run(search_games("sweet bonanza")) @@ -182,7 +182,7 @@ async with StakeAPI(access_token="your_token") as client: }, operation_name="CasinoGames" ) - + for edge in data.get("casinoGames", {}).get("edges", []): game = edge["node"] print(f"{game['name']} — {game['provider']['name']}") @@ -222,30 +222,30 @@ from stakeapi import StakeAPI async def casino_dashboard(): async with StakeAPI(access_token="your_token") as client: games = await client.get_casino_games() - + # Category breakdown categories = {} for game in games: categories[game.category] = categories.get(game.category, 0) + 1 - + print("šŸŽ° CASINO DASHBOARD") print("=" * 50) print(f"\nTotal Games: {len(games)}") - + print("\nšŸ“‚ Games by Category:") for cat, count in sorted(categories.items(), key=lambda x: x[1], reverse=True): bar = "ā–ˆ" * (count // 5) print(f" {cat:20s} {count:4d} {bar}") - + # Provider leaderboard providers = {} for game in games: providers[game.provider] = providers.get(game.provider, 0) + 1 - + print("\nšŸ¢ Top 10 Providers:") for provider, count in sorted(providers.items(), key=lambda x: x[1], reverse=True)[:10]: print(f" {provider:25s} {count:4d} games") - + # RTP statistics rtps = [g.rtp for g in games if g.rtp] if rtps: diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index 00b0e67..87d9491 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -47,17 +47,17 @@ async with StakeAPI(access_token="your_token") as client: try: balance = await client.get_user_balance() print(balance) - + except AuthenticationError: print("Your access token is invalid or expired.") print("Get a new token from stake.com") - + except RateLimitError: print("Too many requests. Slow down!") - + except NetworkError: print("Network error. Check your internet connection.") - + except StakeAPIError as e: print(f"API error: {e}") ``` @@ -91,7 +91,7 @@ async def request_with_retry(client, max_retries=3): wait_time = 2 ** attempt # Exponential backoff print(f"Rate limited. Waiting {wait_time}s...") await asyncio.sleep(wait_time) - + raise Exception("Max retries exceeded") ``` @@ -153,34 +153,34 @@ logger = logging.getLogger("stakeapi") async def robust_api_call(client, method, *args, max_retries=3, **kwargs): """Make an API call with automatic retry and error handling.""" - + for attempt in range(1, max_retries + 1): try: result = await method(*args, **kwargs) return result - + except AuthenticationError: logger.error("Authentication failed. Token may be expired.") raise # Don't retry auth errors - + except RateLimitError: wait = 2 ** attempt logger.warning(f"Rate limited (attempt {attempt}/{max_retries}). " f"Retrying in {wait}s...") await asyncio.sleep(wait) - + except NetworkError: wait = attempt * 2 logger.warning(f"Network error (attempt {attempt}/{max_retries}). " f"Retrying in {wait}s...") await asyncio.sleep(wait) - + except StakeAPIError as e: logger.error(f"API error: {e}") if attempt == max_retries: raise await asyncio.sleep(1) - + raise StakeAPIError(f"Failed after {max_retries} attempts") # Usage: @@ -200,20 +200,20 @@ async def safe_operation(client): balance = await client.get_user_balance() games = await client.get_casino_games() return {"balance": balance, "games": games} - + except AuthenticationError: logger.error("AUTH_ERROR: Token invalid or expired") return None - + except RateLimitError: logger.warning("RATE_LIMIT: Too many requests") return None - + except StakeAPIError as e: logger.error(f"API_ERROR: {e}") logger.debug(traceback.format_exc()) return None - + except Exception as e: logger.critical(f"UNEXPECTED_ERROR: {e}") logger.debug(traceback.format_exc()) diff --git a/docs/guides/graphql-queries.md b/docs/guides/graphql-queries.md index 61f56c8..d702c1a 100644 --- a/docs/guides/graphql-queries.md +++ b/docs/guides/graphql-queries.md @@ -42,12 +42,12 @@ async def main(): } } """ - + data = await client._graphql_request( query=query, operation_name="UserBalances" ) - + print(data) asyncio.run(main()) @@ -67,7 +67,7 @@ async with StakeAPI(access_token="your_token") as client: query=GraphQLQueries.USER_BALANCES, operation_name="UserBalances" ) - + for balance in data["user"]["balances"]["available"]: print(f"{balance['currency']}: {balance['amount']}") ``` @@ -194,28 +194,28 @@ async def get_all_casino_games(client): """Fetch all casino games with pagination.""" all_games = [] cursor = None - + while True: variables = {"first": 100} if cursor: variables["after"] = cursor - + data = await client._graphql_request( query=GraphQLQueries.CASINO_GAMES, variables=variables, operation_name="CasinoGames" ) - + edges = data["casinoGames"]["edges"] all_games.extend(edge["node"] for edge in edges) - + page_info = data["casinoGames"]["pageInfo"] if not page_info["hasNextPage"]: break - + cursor = page_info["endCursor"] print(f"Fetched {len(all_games)} games so far...") - + return all_games ``` diff --git a/docs/guides/performance.md b/docs/guides/performance.md index 3d08d09..4b3e0a4 100644 --- a/docs/guides/performance.md +++ b/docs/guides/performance.md @@ -33,14 +33,14 @@ async def fast_dashboard(): # balance = await client.get_user_balance() # games = await client.get_casino_games() # events = await client.get_sports_events() - + # āœ… FAST — Concurrent (1 round trip) balance, games, events = await asyncio.gather( client.get_user_balance(), client.get_casino_games(), client.get_sports_events(), ) - + print(f"Balance: {balance}") print(f"Games: {len(games)}") print(f"Events: {len(events)}") @@ -114,19 +114,19 @@ class CachedStakeAPI: self.client = client self._cache = {} self._cache_ttl = {} - + async def get_cached(self, key, fetcher, ttl=60): """Get cached result or fetch fresh data.""" now = time.time() - + if key in self._cache and now < self._cache_ttl.get(key, 0): return self._cache[key] - + result = await fetcher() self._cache[key] = result self._cache_ttl[key] = now + ttl return result - + async def get_games_cached(self, category=None): """Casino games change infrequently — cache for 5 minutes.""" key = f"games:{category}" @@ -135,7 +135,7 @@ class CachedStakeAPI: lambda: self.client.get_casino_games(category=category), ttl=300 ) - + async def get_balance_cached(self): """Balance changes often — cache for 10 seconds.""" return await self.get_cached( @@ -175,11 +175,11 @@ Don't fire thousands of requests at once — use semaphores: ```python async def batch_fetch(client, ids, max_concurrent=10): semaphore = asyncio.Semaphore(max_concurrent) - + async def fetch_one(id): async with semaphore: return await client.get_game_details(id) - + return await asyncio.gather(*[fetch_one(id) for id in ids]) ``` diff --git a/docs/guides/rate-limiting.md b/docs/guides/rate-limiting.md index acd0dde..96d6f23 100644 --- a/docs/guides/rate-limiting.md +++ b/docs/guides/rate-limiting.md @@ -52,7 +52,7 @@ async def fetch_with_backoff(client, max_retries=5): wait = 2 ** attempt # 1, 2, 4, 8, 16 seconds print(f"Rate limited! Waiting {wait}s (attempt {attempt + 1}/{max_retries})") await asyncio.sleep(wait) - + raise Exception("Exceeded maximum retries") ``` @@ -66,20 +66,20 @@ import time class RateLimiter: """Simple token bucket rate limiter.""" - + def __init__(self, requests_per_second: int = 10): self.rate = requests_per_second self.tokens = requests_per_second self.last_refill = time.monotonic() self._lock = asyncio.Lock() - + async def acquire(self): async with self._lock: now = time.monotonic() elapsed = now - self.last_refill self.tokens = min(self.rate, self.tokens + elapsed * self.rate) self.last_refill = now - + if self.tokens < 1: wait_time = (1 - self.tokens) / self.rate await asyncio.sleep(wait_time) @@ -108,7 +108,7 @@ async def batch_fetch(client, game_ids: list, concurrency: int = 5): """Fetch multiple games with controlled concurrency.""" semaphore = asyncio.Semaphore(concurrency) results = [] - + async def fetch_one(game_id): async with semaphore: try: @@ -118,7 +118,7 @@ async def batch_fetch(client, game_ids: list, concurrency: int = 5): await asyncio.sleep(2) game = await client.get_game_details(game_id) results.append(game) - + await asyncio.gather(*[fetch_one(gid) for gid in game_ids]) return results ``` diff --git a/docs/guides/sports-betting.md b/docs/guides/sports-betting.md index f40b2ac..2b355f1 100644 --- a/docs/guides/sports-betting.md +++ b/docs/guides/sports-betting.md @@ -30,7 +30,7 @@ async def main(): # Get all upcoming events events = await client.get_sports_events() print(f"Total events: {len(events)}") - + for event in events[:10]: print(f"\n⚽ {event.home_team} vs {event.away_team}") print(f" Sport: {event.sport}") @@ -38,7 +38,7 @@ async def main(): print(f" Start: {event.start_time}") print(f" Status: {event.status}") print(f" Live: {'šŸ”“ LIVE' if event.live else 'ā³ Upcoming'}") - + if event.odds: print(f" Odds:") for market, odds in event.odds.items(): @@ -56,11 +56,11 @@ async with StakeAPI(access_token="your_token") as client: # Football/Soccer football = await client.get_sports_events(sport="football") print(f"Football events: {len(football)}") - + # Basketball basketball = await client.get_sports_events(sport="basketball") print(f"Basketball events: {len(basketball)}") - + # Tennis tennis = await client.get_sports_events(sport="tennis") print(f"Tennis events: {len(tennis)}") @@ -99,14 +99,14 @@ async with StakeAPI(access_token="your_token") as client: }, operation_name="SportsEvents" ) - + for edge in data.get("sportsEvents", {}).get("edges", []): event = edge["node"] competitors = [c["name"] for c in event.get("competitors", [])] print(f"{' vs '.join(competitors)}") print(f" League: {event['league']['name']}") print(f" Start: {event['startTime']}") - + # Show markets and odds for market in event.get("markets", []): print(f" Market: {market['name']}") @@ -122,27 +122,27 @@ Build tools to analyze odds and find value: async def analyze_odds(): async with StakeAPI(access_token="your_token") as client: events = await client.get_sports_events(sport="football") - + print("šŸ“Š ODDS ANALYSIS") print("=" * 60) - + for event in events: if not event.odds: continue - + home_odds = event.odds.get("home") away_odds = event.odds.get("away") draw_odds = event.odds.get("draw") - + if home_odds and away_odds: # Calculate implied probabilities home_prob = (1 / home_odds) * 100 away_prob = (1 / away_odds) * 100 draw_prob = (1 / draw_odds) * 100 if draw_odds else 0 - + total_prob = home_prob + away_prob + draw_prob margin = total_prob - 100 # Bookmaker margin - + print(f"\n{event.home_team} vs {event.away_team}") print(f" Home: {home_odds:.2f} ({home_prob:.1f}%)") print(f" Away: {away_odds:.2f} ({away_prob:.1f}%)") @@ -161,12 +161,12 @@ Filter for currently live events: async def get_live_events(): async with StakeAPI(access_token="your_token") as client: all_events = await client.get_sports_events() - + live_events = [e for e in all_events if e.live] - + print(f"šŸ”“ LIVE EVENTS ({len(live_events)})") print("=" * 50) - + for event in live_events: print(f"\n {event.sport.upper()} | {event.league}") print(f" {event.home_team} vs {event.away_team}") @@ -185,27 +185,27 @@ Find events where the bookmaker margin is lowest (best value for bettors): async def find_value_bets(): async with StakeAPI(access_token="your_token") as client: events = await client.get_sports_events() - + value_events = [] - + for event in events: if not event.odds or "home" not in event.odds or "away" not in event.odds: continue - + total_implied = sum(1/v for v in event.odds.values() if v > 0) margin = (total_implied - 1) * 100 - + value_events.append({ "event": event, "margin": margin }) - + # Sort by lowest margin (best value) value_events.sort(key=lambda x: x["margin"]) - + print("šŸ’Ž BEST VALUE BETS (Lowest Margins)") print("=" * 60) - + for item in value_events[:15]: event = item["event"] print(f"\n {event.home_team} vs {event.away_team}") @@ -244,24 +244,24 @@ from stakeapi import StakeAPI async def sports_dashboard(): async with StakeAPI(access_token="your_token") as client: events = await client.get_sports_events() - + print("šŸˆ SPORTS DASHBOARD") print("=" * 60) print(f"Total Events: {len(events)}") - + # Events by sport sports = Counter(e.sport for e in events) print("\nšŸ“Š Events by Sport:") for sport, count in sports.most_common(): bar = "ā–ˆ" * (count // 2) print(f" {sport:20s} {count:4d} {bar}") - + # Live vs upcoming live = sum(1 for e in events if e.live) upcoming = len(events) - live print(f"\nšŸ”“ Live: {live}") print(f"ā³ Upcoming: {upcoming}") - + # Top leagues leagues = Counter(e.league for e in events) print("\nšŸ† Top 10 Leagues:") diff --git a/docs/guides/user-account.md b/docs/guides/user-account.md index eff5639..8f97fac 100644 --- a/docs/guides/user-account.md +++ b/docs/guides/user-account.md @@ -28,7 +28,7 @@ from stakeapi import StakeAPI async def main(): async with StakeAPI(access_token="your_token") as client: user = await client.get_user_profile() - + print(f"šŸ‘¤ Username: {user.username}") print(f"šŸ“§ Email: {user.email or 'Not set'}") print(f"āœ… Verified: {user.verified}") @@ -47,10 +47,10 @@ This is probably the most commonly used API call. Get your balance across all cr async def check_balance(): async with StakeAPI(access_token="your_token") as client: balance = await client.get_user_balance() - + print("šŸ’° ACCOUNT BALANCE") print("=" * 40) - + # Available balance (ready to use) print("\nšŸ“Š Available:") total_available = 0 @@ -58,10 +58,10 @@ async def check_balance(): if amount > 0: print(f" {currency.upper():8s} {amount:.8f}") total_available += 1 - + if total_available == 0: print(" No available balance") - + # Vault balance (locked/saved) print("\nšŸ¦ Vault:") total_vault = 0 @@ -69,7 +69,7 @@ async def check_balance(): if amount > 0: print(f" {currency.upper():8s} {amount:.8f}") total_vault += 1 - + if total_vault == 0: print(" No vault balance") @@ -88,20 +88,20 @@ from stakeapi import StakeAPI async def monitor_balance(interval_seconds: int = 60): """Monitor balance changes in real-time.""" previous_balances = {} - + async with StakeAPI(access_token="your_token") as client: while True: balance = await client.get_user_balance() current = balance["available"] timestamp = datetime.now().strftime("%H:%M:%S") - + for currency, amount in current.items(): if amount <= 0: continue - + prev = previous_balances.get(currency, amount) change = amount - prev - + if change != 0: direction = "šŸ“ˆ" if change > 0 else "šŸ“‰" print(f"[{timestamp}] {direction} {currency.upper()}: " @@ -109,7 +109,7 @@ async def monitor_balance(interval_seconds: int = 60): f"({change:+.8f})") else: print(f"[{timestamp}] āž– {currency.upper()}: {amount:.8f} (no change)") - + previous_balances = current await asyncio.sleep(interval_seconds) @@ -129,7 +129,7 @@ async with StakeAPI(access_token="your_token") as client: query=GraphQLQueries.USER_PROFILE, operation_name="UserProfile" ) - + user = data.get("user", {}) print(f"ID: {user.get('id')}") print(f"Name: {user.get('name')}") @@ -190,7 +190,7 @@ async def full_account_summary(): client.get_user_profile(), client.get_user_balance() ) - + print(f"ā•”{'═' * 48}ā•—") print(f"ā•‘ ACCOUNT SUMMARY ā•‘") print(f"ā• {'═' * 48}ā•£") @@ -198,18 +198,18 @@ async def full_account_summary(): print(f"ā•‘ Verified: {'āœ… Yes' if user.verified else 'āŒ No':38s} ā•‘") print(f"ā•‘ Currency: {user.currency:38s} ā•‘") print(f"ā• {'═' * 48}ā•£") - + available = {k: v for k, v in balance["available"].items() if v > 0} vault = {k: v for k, v in balance["vault"].items() if v > 0} - + print(f"ā•‘ Available Balances: {len(available):27d} ā•‘") for cur, amt in available.items(): print(f"ā•‘ {cur.upper():6s} {amt:>38.8f} ā•‘") - + print(f"ā•‘ Vault Balances: {len(vault):31d} ā•‘") for cur, amt in vault.items(): print(f"ā•‘ {cur.upper():6s} {amt:>38.8f} ā•‘") - + print(f"ā•š{'═' * 48}ā•") asyncio.run(full_account_summary()) diff --git a/docs/guides/websockets.md b/docs/guides/websockets.md index b4cb845..de0f8f4 100644 --- a/docs/guides/websockets.md +++ b/docs/guides/websockets.md @@ -36,22 +36,22 @@ import json async def connect_to_stake(): uri = "wss://stake.com/_api/websocket" - + headers = { "x-access-token": "your_token_here", "Origin": "https://stake.com", } - + async with websockets.connect(uri, extra_headers=headers) as ws: print("āœ… Connected to Stake.com WebSocket") - + # Subscribe to balance updates subscribe_msg = { "type": "subscribe", "channel": "user:balances" } await ws.send(json.dumps(subscribe_msg)) - + # Listen for messages async for message in ws: data = json.loads(message) @@ -72,7 +72,7 @@ async def watch_balance(ws): "type": "subscribe", "channel": "user:balances" })) - + async for message in ws: data = json.loads(message) if data.get("channel") == "user:balances": @@ -89,7 +89,7 @@ async def watch_game_results(ws, game_slug: str): "type": "subscribe", "channel": f"game:{game_slug}" })) - + async for message in ws: data = json.loads(message) print(f"šŸŽ° Game result: {data}") @@ -105,7 +105,7 @@ async def watch_live_sports(ws, event_id: str): "type": "subscribe", "channel": f"sports:event:{event_id}" })) - + async for message in ws: data = json.loads(message) print(f"⚽ Score update: {data}") @@ -131,7 +131,7 @@ class StakeWebSocket: self.subscriptions = [] self.reconnect_delay = 1 self.max_reconnect_delay = 60 - + async def connect(self): while True: try: @@ -139,9 +139,9 @@ class StakeWebSocket: "x-access-token": self.access_token, "Origin": "https://stake.com", } - + async with websockets.connect( - self.uri, + self.uri, extra_headers=headers, ping_interval=30, ping_timeout=10 @@ -149,26 +149,26 @@ class StakeWebSocket: self.ws = ws self.reconnect_delay = 1 # Reset on successful connect logger.info("Connected to Stake.com WebSocket") - + # Re-subscribe to channels for channel in self.subscriptions: await self._subscribe(channel) - + await self._listen() - + except websockets.exceptions.ConnectionClosed: logger.warning("WebSocket connection closed") except Exception as e: logger.error(f"WebSocket error: {e}") - + # Exponential backoff logger.info(f"Reconnecting in {self.reconnect_delay}s...") await asyncio.sleep(self.reconnect_delay) self.reconnect_delay = min( - self.reconnect_delay * 2, + self.reconnect_delay * 2, self.max_reconnect_delay ) - + async def _subscribe(self, channel: str): if self.ws: await self.ws.send(json.dumps({ @@ -176,17 +176,17 @@ class StakeWebSocket: "channel": channel })) logger.info(f"Subscribed to {channel}") - + async def subscribe(self, channel: str): if channel not in self.subscriptions: self.subscriptions.append(channel) await self._subscribe(channel) - + async def _listen(self): async for message in self.ws: data = json.loads(message) await self.on_message(data) - + async def on_message(self, data: dict): """Override this method to handle messages.""" print(f"Message: {data}") @@ -195,7 +195,7 @@ class StakeWebSocket: class MyHandler(StakeWebSocket): async def on_message(self, data): channel = data.get("channel", "") - + if "balances" in channel: print(f"šŸ’° Balance changed: {data['payload']}") elif "game:" in channel: @@ -216,12 +216,12 @@ asyncio.run(main()) ```python async def live_balance_tracker(): """Combine REST for initial state and WebSocket for live updates.""" - + async with StakeAPI(access_token="your_token") as client: # Get initial balance via REST balance = await client.get_user_balance() print("Initial balance:", balance) - + # Then switch to WebSocket for live updates ws_client = StakeWebSocket(access_token="your_token") await ws_client.subscribe("user:balances") diff --git a/docs/index.md b/docs/index.md index 0f43aaf..b80e2ba 100644 --- a/docs/index.md +++ b/docs/index.md @@ -102,13 +102,13 @@ asyncio.run(main()) ## Supported Python Versions -StakeAPI supports Python 3.8 and above: +StakeAPI supports Python 3.10 and above: -- Python 3.8 -- Python 3.9 - Python 3.10 - Python 3.11 - Python 3.12 +- Python 3.13 +- Python 3.14 ## Community & Support diff --git a/docs/resources/examples.md b/docs/resources/examples.md index 2fd681d..d0e4668 100644 --- a/docs/resources/examples.md +++ b/docs/resources/examples.md @@ -26,7 +26,7 @@ from stakeapi import StakeAPI async def main(): async with StakeAPI(access_token="your_token") as client: balance = await client.get_user_balance() - + print("Available:") for currency, amount in balance["available"].items(): if amount > 0: @@ -63,7 +63,7 @@ async def main(): # Get slot games slots = await client.get_casino_games(category="slots") print(f"Found {len(slots)} slot games\n") - + for game in slots[:10]: rtp_str = f"RTP: {game.rtp}%" if game.rtp else "RTP: N/A" print(f" {game.name} ({game.provider}) — {rtp_str}") @@ -80,7 +80,7 @@ from stakeapi import StakeAPI async def main(): async with StakeAPI(access_token="your_token") as client: events = await client.get_sports_events(sport="football") - + for event in events[:10]: status = "šŸ”“ LIVE" if event.live else "ā³ Upcoming" print(f" {event.home_team} vs {event.away_team} [{status}]") @@ -126,7 +126,7 @@ async def main(): client.get_sports_events(), client.get_bet_history(limit=20), ) - + print(f"Balance currencies: {len(balance['available'])}") print(f"Casino games: {len(games)}") print(f"Sports events: {len(events)}") @@ -145,14 +145,14 @@ from stakeapi import StakeAPI async def main(): async with StakeAPI(access_token="your_token") as client: games = await client.get_casino_games() - + providers = Counter(g.provider for g in games) categories = Counter(g.category for g in games) - + print("Top Providers:") for provider, count in providers.most_common(10): print(f" {provider}: {count} games") - + print("\nCategories:") for category, count in categories.most_common(): print(f" {category}: {count} games") @@ -170,17 +170,17 @@ from stakeapi import StakeAPI async def main(): async with StakeAPI(access_token="your_token") as client: bets = await client.get_bet_history(limit=100) - + if not bets: print("No bets found") return - + won = [b for b in bets if b.status == "won"] lost = [b for b in bets if b.status == "lost"] - + total_wagered = sum(float(b.amount) for b in bets) total_won = sum(float(b.potential_payout) for b in won) - + print("PERFORMANCE REPORT") print("=" * 40) print(f"Total Bets: {len(bets)}") @@ -203,29 +203,29 @@ from stakeapi import StakeAPI async def monitor(token: str, check_interval: int = 30): prev = {} - + while True: try: async with StakeAPI(access_token=token) as client: balance = await client.get_user_balance() now = datetime.now().strftime("%H:%M:%S") - + for cur, amt in balance["available"].items(): if amt <= 0: continue - + old = prev.get(cur, amt) diff = amt - old - + if diff > 0: print(f"[{now}] šŸ“ˆ {cur.upper()}: +{diff:.8f}") elif diff < 0: print(f"[{now}] šŸ“‰ {cur.upper()}: {diff:.8f}") - + prev = {k: v for k, v in balance["available"].items() if v > 0} except Exception as e: print(f"Error: {e}") - + await asyncio.sleep(check_interval) asyncio.run(monitor("your_token")) @@ -241,18 +241,18 @@ from stakeapi import StakeAPI async def export_bets(): async with StakeAPI(access_token="your_token") as client: bets = await client.get_bet_history(limit=100) - + with open("bets.csv", "w", newline="") as f: w = csv.writer(f) w.writerow(["ID", "Type", "Amount", "Payout", "Status", "Date"]) - + for b in bets: w.writerow([ b.id, b.bet_type, float(b.amount), float(b.potential_payout), b.status, b.placed_at.isoformat() ]) - + print(f"Exported {len(bets)} bets to bets.csv") asyncio.run(export_bets()) @@ -280,7 +280,7 @@ async def main(): } } """ - + data = await client._graphql_request(query=query) print(data) diff --git a/docs/resources/faq.md b/docs/resources/faq.md index e5b7b6e..0df09ec 100644 --- a/docs/resources/faq.md +++ b/docs/resources/faq.md @@ -31,7 +31,7 @@ Yes! StakeAPI is open source under the MIT license. You can use it for personal ### What Python versions are supported? -Python 3.8 and above (3.8, 3.9, 3.10, 3.11, 3.12). +Python 3.10 and above (3.10, 3.11, 3.12, 3.13, 3.14). --- diff --git a/examples/advanced_usage.py b/examples/advanced_usage.py index 05e9153..add202d 100644 --- a/examples/advanced_usage.py +++ b/examples/advanced_usage.py @@ -1,3 +1,4 @@ +# flake8: noqa """ Advanced examples for StakeAPI. @@ -6,26 +7,25 @@ """ import asyncio -import json import os -from datetime import datetime, timedelta -from typing import List, Dict, Any +from typing import Any, Dict, List + from stakeapi import StakeAPI -from stakeapi.models import Game, SportEvent from stakeapi.exceptions import StakeAPIError +from stakeapi.models import SportEvent class StakeAnalytics: """Advanced analytics using StakeAPI.""" - + def __init__(self, api_key: str): self.api_key = api_key - + async def analyze_games_by_provider(self) -> Dict[str, Dict[str, Any]]: """Analyze games grouped by provider.""" async with StakeAPI(api_key=self.api_key) as client: games = await client.get_casino_games() - + provider_stats = {} for game in games: provider = game.provider @@ -35,18 +35,18 @@ async def analyze_games_by_provider(self) -> Dict[str, Dict[str, Any]]: "categories": set(), "avg_rtp": [], "min_bet_range": [], - "max_bet_range": [] + "max_bet_range": [], } - + stats = provider_stats[provider] stats["game_count"] += 1 stats["categories"].add(game.category) - + if game.rtp: stats["avg_rtp"].append(game.rtp) stats["min_bet_range"].append(float(game.min_bet)) stats["max_bet_range"].append(float(game.max_bet)) - + # Calculate averages for provider, stats in provider_stats.items(): stats["categories"] = list(stats["categories"]) @@ -54,24 +54,28 @@ async def analyze_games_by_provider(self) -> Dict[str, Dict[str, Any]]: stats["avg_rtp"] = sum(stats["avg_rtp"]) / len(stats["avg_rtp"]) else: stats["avg_rtp"] = None - - stats["min_bet_avg"] = sum(stats["min_bet_range"]) / len(stats["min_bet_range"]) - stats["max_bet_avg"] = sum(stats["max_bet_range"]) / len(stats["max_bet_range"]) - + + stats["min_bet_avg"] = sum(stats["min_bet_range"]) / len( + stats["min_bet_range"] + ) + stats["max_bet_avg"] = sum(stats["max_bet_range"]) / len( + stats["max_bet_range"] + ) + # Clean up temporary lists del stats["min_bet_range"] del stats["max_bet_range"] - + return provider_stats - + async def find_best_odds_events(self, sport: str = None) -> List[SportEvent]: """Find sports events with the best odds.""" async with StakeAPI(api_key=self.api_key) as client: events = await client.get_sports_events(sport=sport) - + # Filter events with odds and sort by potential value events_with_odds = [e for e in events if e.odds] - + # Calculate implied probability and find value bets valuable_events = [] for event in events_with_odds: @@ -79,32 +83,36 @@ async def find_best_odds_events(self, sport: str = None) -> List[SportEvent]: home_prob = 1 / event.odds["home"] away_prob = 1 / event.odds["away"] total_prob = home_prob + away_prob - + # Look for events where bookmaker margin is low if total_prob < 1.05: # Less than 5% margin valuable_events.append(event) - + return valuable_events - + async def get_user_performance_stats(self) -> Dict[str, Any]: """Analyze user betting performance.""" async with StakeAPI(api_key=self.api_key) as client: bets = await client.get_bet_history(limit=100) - + if not bets: return {"message": "No betting history found"} - + total_bets = len(bets) won_bets = [b for b in bets if b.status == "won"] lost_bets = [b for b in bets if b.status == "lost"] - + total_wagered = sum(bet.amount for bet in bets) total_won = sum(bet.potential_payout for bet in won_bets) total_lost = sum(bet.amount for bet in lost_bets) - + win_rate = len(won_bets) / total_bets * 100 if total_bets > 0 else 0 - roi = ((total_won - total_wagered) / total_wagered * 100) if total_wagered > 0 else 0 - + roi = ( + ((total_won - total_wagered) / total_wagered * 100) + if total_wagered > 0 + else 0 + ) + # Find most profitable game/event game_profits = {} for bet in bets: @@ -112,16 +120,22 @@ async def get_user_performance_stats(self) -> Dict[str, Any]: if game_id: if game_id not in game_profits: game_profits[game_id] = {"profit": 0, "bets": 0} - + if bet.status == "won": - game_profits[game_id]["profit"] += float(bet.potential_payout - bet.amount) + game_profits[game_id]["profit"] += float( + bet.potential_payout - bet.amount + ) elif bet.status == "lost": game_profits[game_id]["profit"] -= float(bet.amount) - + game_profits[game_id]["bets"] += 1 - - most_profitable = max(game_profits.items(), key=lambda x: x[1]["profit"]) if game_profits else None - + + most_profitable = ( + max(game_profits.items(), key=lambda x: x[1]["profit"]) + if game_profits + else None + ) + return { "total_bets": total_bets, "win_rate": round(win_rate, 2), @@ -130,7 +144,9 @@ async def get_user_performance_stats(self) -> Dict[str, Any]: "net_profit": float(total_won - total_wagered), "roi_percentage": round(roi, 2), "most_profitable_game": most_profitable[0] if most_profitable else None, - "most_profitable_profit": most_profitable[1]["profit"] if most_profitable else 0 + "most_profitable_profit": ( + most_profitable[1]["profit"] if most_profitable else 0 + ), } @@ -140,18 +156,20 @@ async def batch_game_analysis(): if not api_key: print("Please set STAKE_API_KEY environment variable") return - + analytics = StakeAnalytics(api_key) - + print("Analyzing games by provider...") provider_stats = await analytics.analyze_games_by_provider() - + print("\n=== Provider Analysis ===") - for provider, stats in sorted(provider_stats.items(), key=lambda x: x[1]["game_count"], reverse=True)[:10]: + for provider, stats in sorted( + provider_stats.items(), key=lambda x: x[1]["game_count"], reverse=True + )[:10]: print(f"\n{provider}:") print(f" Games: {stats['game_count']}") print(f" Categories: {', '.join(stats['categories'])}") - if stats['avg_rtp']: + if stats["avg_rtp"]: print(f" Average RTP: {stats['avg_rtp']:.2f}%") print(f" Avg Min Bet: ${stats['min_bet_avg']:.2f}") print(f" Avg Max Bet: ${stats['max_bet_avg']:.2f}") @@ -163,21 +181,21 @@ async def live_odds_monitoring(): if not api_key: print("Please set STAKE_API_KEY environment variable") return - + analytics = StakeAnalytics(api_key) - + print("Finding events with best odds...") valuable_events = await analytics.find_best_odds_events(sport="football") - + print(f"\n=== Value Betting Opportunities ===") print(f"Found {len(valuable_events)} events with low bookmaker margins:") - + for event in valuable_events[:5]: print(f"\n{event.home_team} vs {event.away_team}") print(f"League: {event.league}") print(f"Start: {event.start_time}") print(f"Odds - Home: {event.odds.get('home')}, Away: {event.odds.get('away')}") - + # Calculate implied probabilities home_prob = (1 / event.odds["home"]) * 100 away_prob = (1 / event.odds["away"]) * 100 @@ -191,25 +209,25 @@ async def performance_dashboard(): if not api_key: print("Please set STAKE_API_KEY environment variable") return - + analytics = StakeAnalytics(api_key) - + print("Analyzing your betting performance...") stats = await analytics.get_user_performance_stats() - + print("\n=== Your Performance Dashboard ===") if "message" in stats: print(stats["message"]) return - + print(f"Total Bets: {stats['total_bets']}") print(f"Win Rate: {stats['win_rate']}%") print(f"Total Wagered: ${stats['total_wagered']:.2f}") print(f"Total Won: ${stats['total_won']:.2f}") print(f"Net Profit: ${stats['net_profit']:.2f}") print(f"ROI: {stats['roi_percentage']}%") - - if stats['most_profitable_game']: + + if stats["most_profitable_game"]: print(f"\nMost Profitable Game: {stats['most_profitable_game']}") print(f"Profit from this game: ${stats['most_profitable_profit']:.2f}") @@ -220,7 +238,7 @@ async def error_handling_example(): if not api_key: print("Please set STAKE_API_KEY environment variable") return - + # Custom retry logic async def retry_request(func, max_retries=3, delay=1): """Retry function with exponential backoff.""" @@ -231,14 +249,14 @@ async def retry_request(func, max_retries=3, delay=1): if attempt == max_retries - 1: raise print(f"Attempt {attempt + 1} failed: {e}") - await asyncio.sleep(delay * (2 ** attempt)) - + await asyncio.sleep(delay * (2**attempt)) + async with StakeAPI(api_key=api_key) as client: try: # Retry getting user profile user = await retry_request(client.get_user_profile) print(f"Successfully got profile for {user.username}") - + except StakeAPIError as e: print(f"Failed after retries: {e}") @@ -246,26 +264,27 @@ async def retry_request(func, max_retries=3, delay=1): async def main(): """Run advanced examples.""" print("=== Advanced StakeAPI Examples ===\n") - + examples = [ ("Batch Game Analysis", batch_game_analysis), ("Live Odds Monitoring", live_odds_monitoring), ("Performance Dashboard", performance_dashboard), ("Error Handling Example", error_handling_example), ] - + for name, func in examples: print(f"\n{'='*20} {name} {'='*20}") try: await func() except Exception as e: print(f"Error in {name}: {e}") - - print("\n" + "="*60) + + print("\n" + "=" * 60) if __name__ == "__main__": import logging + logging.basicConfig(level=logging.INFO) - + asyncio.run(main()) diff --git a/examples/balance.py b/examples/balance.py index c5d7d03..5c63e84 100644 --- a/examples/balance.py +++ b/examples/balance.py @@ -1,17 +1,20 @@ import asyncio +import os + +import dotenv + from stakeapi import ( - StakeAPI, AuthenticationError, - PermissionDeniedError, NetworkError, + PermissionDeniedError, RateLimitError, + StakeAPI, StakeAPIError, ) -import dotenv -import os dotenv.load_dotenv() + async def main(): # Replace with your actual access token access_token = os.getenv("STAKE_ACCESS_TOKEN") diff --git a/examples/balance_example.py b/examples/balance_example.py index 894a28f..880cfce 100644 --- a/examples/balance_example.py +++ b/examples/balance_example.py @@ -1,3 +1,4 @@ +# flake8: noqa """ Example showing how to extract credentials from curl and use StakeAPI. @@ -6,17 +7,17 @@ """ import asyncio -import os + from stakeapi import StakeAPI from stakeapi.auth import AuthManager -from stakeapi.exceptions import StakeAPIError, AuthenticationError +from stakeapi.exceptions import AuthenticationError, StakeAPIError def extract_credentials_from_curl(): """ Extract credentials from the provided curl command. """ - curl_command = ''' + curl_command = """ curl "https://stake.com/_api/graphql" \ -H "accept: application/graphql+json, application/json" \ -H "accept-language: en-US,en;q=0.9,es;q=0.8,fr;q=0.7" \ @@ -35,14 +36,14 @@ def extract_credentials_from_curl(): -H "x-access-token: " \ -H "x-language: en" \ --data-raw '{"query":"query UserBalances {\n user {\n id\n balances {\n available {\n amount\n currency\n __typename\n }\n vault {\n amount\n currency\n __typename\n }\n __typename\n }\n __typename\n }\n}\n","operationName":"UserBalances"}' - ''' - + """ + # Extract access token access_token = AuthManager.extract_access_token_from_curl(curl_command) - + # Extract session cookie session_cookie = AuthManager.extract_session_from_curl(curl_command) - + return access_token, session_cookie @@ -52,30 +53,32 @@ async def get_balance_example(): """ # You can either extract from curl or set manually access_token, session_cookie = extract_credentials_from_curl() - + # Or set them manually if you have them # access_token = "your_access_token_here" # session_cookie = "your_session_cookie_here" - + if not access_token: print("āŒ Could not extract access token from curl command") print("Please update the curl command with your actual tokens") return - + print("āœ… Extracted credentials successfully") print(f"Access Token: {access_token[:20]}...") if session_cookie: print(f"Session Cookie: {session_cookie[:20]}...") - + # Create client with extracted credentials - async with StakeAPI(access_token=access_token, session_cookie=session_cookie) as client: + async with StakeAPI( + access_token=access_token, session_cookie=session_cookie + ) as client: try: print("\nšŸ”„ Fetching user balance...") balance = await client.get_user_balance() - + print("\nšŸ’° Account Balance:") print("=" * 40) - + # Display available balances if balance["available"]: print("\nšŸ“Š Available Balances:") @@ -84,7 +87,7 @@ async def get_balance_example(): print(f" {currency.upper()}: {amount}") else: print("\nšŸ“Š Available Balances: None") - + # Display vault balances if balance["vault"]: print("\nšŸ¦ Vault Balances:") @@ -93,7 +96,7 @@ async def get_balance_example(): print(f" {currency.upper()}: {amount}") else: print("\nšŸ¦ Vault Balances: None") - + except AuthenticationError: print("āŒ Authentication failed. Your tokens may have expired.") print("Please get new tokens from stake.com and update the curl command.") @@ -109,9 +112,9 @@ async def main(): """ print("šŸŽ° StakeAPI Balance Example") print("=" * 50) - + await get_balance_example() - + print("\n" + "=" * 50) print("šŸ“ How to get your tokens:") print("1. Go to stake.com and log in") diff --git a/examples/basic_usage.py b/examples/basic_usage.py index 0e49d19..767a84e 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -1,3 +1,4 @@ +# flake8: noqa """ Basic usage examples for StakeAPI. @@ -21,10 +22,10 @@ from stakeapi import StakeAPI from stakeapi.exceptions import ( - StakeAPIError, AuthenticationError, - PermissionDeniedError, NetworkError, + PermissionDeniedError, + StakeAPIError, ) dotenv.load_dotenv() @@ -59,7 +60,9 @@ async def basic_usage_example(): """Demonstrate basic StakeAPI usage.""" if not has_credentials(): - print("No credentials found — create cookie.txt or set STAKE_ACCESS_TOKEN in .env") + print( + "No credentials found — create cookie.txt or set STAKE_ACCESS_TOKEN in .env" + ) return # Create client using context manager (recommended) @@ -70,7 +73,9 @@ async def basic_usage_example(): balance = await client.get_user_balance() for currency, amount in balance["available"].items(): if amount > 0: - print(f" {currency.upper()}: {amount} (vault: {balance['vault'].get(currency, 0)})") + print( + f" {currency.upper()}: {amount} (vault: {balance['vault'].get(currency, 0)})" + ) if not any(amount > 0 for amount in balance["available"].values()): print(" (all balances are 0)") @@ -86,7 +91,9 @@ async def basic_usage_example(): bets = await client.get_bet_history(limit=10) if bets: for bet in bets: - print(f"- {bet.game_id}: {bet.amount} → {bet.potential_payout} ({bet.status})") + print( + f"- {bet.game_id}: {bet.amount} → {bet.potential_payout} ({bet.status})" + ) else: print(" (no bets found)") @@ -104,7 +111,9 @@ async def live_data_example(): """Demonstrate public live data: house bets feed and currency rates.""" if not has_credentials(): - print("No credentials found — create cookie.txt or set STAKE_ACCESS_TOKEN in .env") + print( + "No credentials found — create cookie.txt or set STAKE_ACCESS_TOKEN in .env" + ) return async with make_client() as client: @@ -113,15 +122,19 @@ async def live_data_example(): print("Getting live house bets feed...") feed = await client.get_all_house_bets(limit=5) for bet in feed: - print(f"- {bet.game_name}: {bet.amount} {(bet.currency or '').upper()} " - f"→ {bet.potential_payout} ({bet.status})") + print( + f"- {bet.game_name}: {bet.amount} {(bet.currency or '').upper()} " + f"→ {bet.potential_payout} ({bet.status})" + ) # Currency exchange rates (vs USD) print("\nGetting currency rates...") rates = await client.get_currency_rates() for currency in ("btc", "eth", "sol", "ltc"): if currency in rates["base_rates"]: - print(f" {currency.upper()}: ${rates['base_rates'][currency]:,.2f}") + print( + f" {currency.upper()}: ${rates['base_rates'][currency]:,.2f}" + ) except StakeAPIError as e: print(f"Error getting live data: {e}") @@ -131,7 +144,9 @@ async def betting_example(): """Demonstrate betting operations (use with caution!).""" if not has_credentials(): - print("No credentials found — create cookie.txt or set STAKE_ACCESS_TOKEN in .env") + print( + "No credentials found — create cookie.txt or set STAKE_ACCESS_TOKEN in .env" + ) return async with make_client() as client: @@ -154,7 +169,9 @@ async def betting_example(): usd_value = amount * rate if rate else 0 print(f"Balance: {held[currency]} {currency.upper()}") - print(f"Demo stake would be: {amount:.8f} {currency.upper()} (~${usd_value:.4f})") + print( + f"Demo stake would be: {amount:.8f} {currency.upper()} (~${usd_value:.4f})" + ) # Example bet data (modify according to actual API requirements) bet_data = { @@ -184,12 +201,12 @@ async def main(): print("1. Basic Usage Example") await basic_usage_example() - print("\n" + "="*50 + "\n") + print("\n" + "=" * 50 + "\n") print("2. Live Data Example") await live_data_example() - print("\n" + "="*50 + "\n") + print("\n" + "=" * 50 + "\n") print("3. Betting Example") await betting_example() @@ -198,6 +215,7 @@ async def main(): if __name__ == "__main__": # Set up logging import logging + logging.basicConfig(level=logging.INFO) # Run examples diff --git a/pyproject.toml b/pyproject.toml index 80a4b6b..c279081 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "stakeapi" version = "0.1.0" description = "Unofficial Python API wrapper for stake.com" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.10" license = {text = "MIT"} authors = [ {name = "Vigo Walker", email = "vigopaul05@gmail.com"}, @@ -18,11 +18,11 @@ 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", + "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Internet :: WWW/HTTP", ] @@ -62,7 +62,7 @@ include = ["stakeapi*"] [tool.black] line-length = 88 -target-version = ['py38'] +target-version = ['py310'] include = '\.pyi?$' extend-exclude = ''' /( @@ -85,7 +85,9 @@ line_length = 88 known_first_party = ["stakeapi"] [tool.mypy] -python_version = "3.8" +files = ["stakeapi"] +exclude = ['^tests/', '^examples/'] +python_version = "3.10" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true diff --git a/setup_dev.ps1 b/setup_dev.ps1 index b294078..6b361aa 100644 --- a/setup_dev.ps1 +++ b/setup_dev.ps1 @@ -9,14 +9,15 @@ try { Write-Host "Found Python: $pythonVersion" -ForegroundColor Yellow } catch { Write-Host "Error: Python is not installed or not in PATH" -ForegroundColor Red - Write-Host "Please install Python 3.8 or later from https://python.org" -ForegroundColor Red + Write-Host "Please install Python 3.10 or later from https://python.org" -ForegroundColor Red exit 1 } # Check Python version $versionNumber = python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" -$requiredVersion = 3.8 -if ([decimal]$versionNumber -lt $requiredVersion) { +$requiredVersion = [version]"3.10" +$currentVersion = [version]$versionNumber +if ($currentVersion -lt $requiredVersion) { Write-Host "Error: Python $versionNumber found, but Python $requiredVersion or later is required" -ForegroundColor Red exit 1 } diff --git a/stakeapi/__init__.py b/stakeapi/__init__.py index b992175..43224ef 100644 --- a/stakeapi/__init__.py +++ b/stakeapi/__init__.py @@ -7,27 +7,27 @@ Example usage: import asyncio from stakeapi import StakeAPI - + async def main(): async with StakeAPI(access_token="your_token") as client: balance = await client.get_user_balance() print(balance) - + asyncio.run(main()) """ +from ._version import __version__ +from .auth import AuthManager from .client import StakeAPI from .exceptions import ( - StakeAPIError, AuthenticationError, - RateLimitError, - ValidationError, - NetworkError, GraphQLError, + NetworkError, PermissionDeniedError, + RateLimitError, + StakeAPIError, + ValidationError, ) -from .auth import AuthManager -from ._version import __version__ __all__ = [ "StakeAPI", diff --git a/stakeapi/auth.py b/stakeapi/auth.py index 93a0f5e..7896b1e 100644 --- a/stakeapi/auth.py +++ b/stakeapi/auth.py @@ -1,20 +1,18 @@ """Authentication manager for StakeAPI.""" -import hashlib -import hmac import time from typing import Dict, Optional -import base64 -import json class AuthManager: """Handles authentication for StakeAPI.""" - - def __init__(self, access_token: Optional[str] = None, session_cookie: Optional[str] = None): + + def __init__( + self, access_token: Optional[str] = None, session_cookie: Optional[str] = None + ): """ Initialize authentication manager. - + Args: access_token: Access token from stake.com (x-access-token) session_cookie: Session cookie for authentication @@ -22,39 +20,41 @@ def __init__(self, access_token: Optional[str] = None, session_cookie: Optional[ self.access_token = access_token self.session_cookie = session_cookie self._token_expires_at: Optional[float] = None - + async def get_auth_headers(self) -> Dict[str, str]: """ Get authentication headers for requests. - + Returns: Dictionary of authentication headers """ headers = {} - + if self.access_token: headers["X-Access-Token"] = self.access_token - + return headers - + def get_cookies(self) -> Dict[str, str]: """ Get authentication cookies. - + Returns: Dictionary of cookies """ cookies = {} - + if self.session_cookie: cookies["session"] = self.session_cookie - + return cookies - - def set_access_token(self, access_token: str, expires_in: Optional[int] = None): + + def set_access_token( + self, access_token: str, expires_in: Optional[int] = None + ) -> None: """ Set access token. - + Args: access_token: Access token expires_in: Token expiration time in seconds @@ -62,35 +62,35 @@ def set_access_token(self, access_token: str, expires_in: Optional[int] = None): self.access_token = access_token if expires_in: self._token_expires_at = time.time() + expires_in - - def set_session_cookie(self, session_cookie: str): + + def set_session_cookie(self, session_cookie: str) -> None: """ Set session cookie. - + Args: session_cookie: Session cookie value """ self.session_cookie = session_cookie - + def is_token_expired(self) -> bool: """ Check if the current token is expired. - + Returns: True if token is expired or about to expire """ if not self._token_expires_at: return False # No expiration set, assume valid - + # Consider token expired 5 minutes before actual expiration return time.time() >= (self._token_expires_at - 300) - - def clear_tokens(self): + + def clear_tokens(self) -> None: """Clear stored authentication tokens.""" self.access_token = None self.session_cookie = None self._token_expires_at = None - + @staticmethod def parse_cookie_string(cookie_string: str) -> Dict[str, str]: """ @@ -109,9 +109,11 @@ def parse_cookie_string(cookie_string: str) -> Dict[str, str]: Dictionary mapping cookie names to values """ # Collapse to a single line — files often contain stray newlines - cookie_string = " ".join(line.strip() for line in cookie_string.splitlines()).strip() + cookie_string = " ".join( + line.strip() for line in cookie_string.splitlines() + ).strip() if cookie_string.lower().startswith("cookie:"): - cookie_string = cookie_string[len("cookie:"):].strip() + cookie_string = cookie_string[len("cookie:") :].strip() cookies = {} for part in cookie_string.split(";"): @@ -145,7 +147,7 @@ def load_cookie_file(path: str) -> str: cleaned = " ".join(line.strip() for line in raw.splitlines()).strip() if cleaned.lower().startswith("cookie:"): - cleaned = cleaned[len("cookie:"):].strip() + cleaned = cleaned[len("cookie:") :].strip() if not cleaned: raise ValueError(f"Cookie file {path!r} is empty") return cleaned @@ -154,42 +156,42 @@ def load_cookie_file(path: str) -> str: def extract_access_token_from_curl(curl_command: str) -> Optional[str]: """ Extract access token from curl command. - + Args: curl_command: Curl command string - + Returns: Extracted access token or None """ import re - + # Look for x-access-token header pattern = r'-H\s+["\']x-access-token:\s*([^"\']+)["\']' match = re.search(pattern, curl_command, re.IGNORECASE) - + if match: return match.group(1).strip() - + return None - + @staticmethod def extract_session_from_curl(curl_command: str) -> Optional[str]: """ Extract session cookie from curl command. - + Args: curl_command: Curl command string - + Returns: Extracted session cookie or None """ import re - + # Look for session cookie in -b parameter - pattern = r'session=([^;]+)' + pattern = r"session=([^;]+)" match = re.search(pattern, curl_command) - + if match: return match.group(1).strip() - + return None diff --git a/stakeapi/client.py b/stakeapi/client.py index b62f4a4..eea9e36 100644 --- a/stakeapi/client.py +++ b/stakeapi/client.py @@ -1,31 +1,37 @@ """Main client for StakeAPI.""" import asyncio -from typing import Optional, Dict, Any, List -import aiohttp import json +from datetime import datetime +from decimal import Decimal +from types import TracebackType +from typing import Any, Dict, List, Optional from urllib.parse import urljoin +import aiohttp + +from .auth import AuthManager +from .endpoints import GraphQLQueries from .exceptions import ( - StakeAPIError, AuthenticationError, - RateLimitError, - NetworkError, GraphQLError, + NetworkError, PermissionDeniedError, + RateLimitError, + StakeAPIError, ValidationError, ) -from .models import User, Game, SportEvent, Bet -from .endpoints import Endpoints, GraphQLQueries -from .auth import AuthManager +from .models import Bet, Game, SportEvent, User -def _parse_datetime(value): - """Parse the RFC 1123 dates the API returns (e.g. 'Sat, 11 Jul 2026 07:41:10 GMT').""" +def _parse_datetime(value: Optional[str]) -> Optional[datetime | str]: + """Parse the RFC 1123 dates the API returns (e.g. 'Sat, 11 Jul 2026 + 07:41:10 GMT').""" if not value: return None try: from email.utils import parsedate_to_datetime + return parsedate_to_datetime(value) except (TypeError, ValueError): return value # let pydantic try ISO formats @@ -59,13 +65,13 @@ def _bet_from_entry(entry: Dict[str, Any]) -> Optional[Bet]: game_id=game_id, game_name=game.get("name"), bet_type=bet.get("__typename", "CasinoBet"), - amount=str(bet.get("amount") or 0), + amount=Decimal(str(bet.get("amount") or 0)), currency=bet.get("currency"), - potential_payout=str(payout), + potential_payout=Decimal(str(payout)), odds=bet.get("payoutMultiplier"), status=status, placed_at=_parse_datetime(bet.get("createdAt") or bet.get("updatedAt")), - settled_at=_parse_datetime(bet.get("updatedAt")) if not active else None, + settled_at=(_parse_datetime(bet.get("updatedAt")) if not active else None), ) except Exception: return None @@ -73,7 +79,7 @@ def _bet_from_entry(entry: Dict[str, Any]) -> Optional[Bet]: class StakeAPI: """Main client for interacting with stake.com API.""" - + def __init__( self, access_token: Optional[str] = None, @@ -94,8 +100,10 @@ def __init__( If omitted but cookies are provided, the 'session' cookie value is used as the access token (they are the same value on stake). session_cookie: Session cookie for authentication - cf_clearance: Cloudflare clearance cookie (required to bypass Cloudflare protection) - user_agent: Your browser's User-Agent (must match the one used to obtain cf_clearance) + cf_clearance: Cloudflare clearance cookie (required to bypass + Cloudflare protection) + user_agent: Your browser's User-Agent (must match the one used to + obtain cf_clearance) base_url: Base URL for the API. Use this to point at a regional mirror, e.g. "https://stake1017.com" or "https://stake.bet". Your cookies (session, cf_clearance) must come from the SAME @@ -114,10 +122,13 @@ def __init__( ValidationError: If base_url is not a valid http(s) URL, or the cookie file is missing/empty/unreadable. """ - if not isinstance(base_url, str) or not base_url.startswith(("http://", "https://")): + if not isinstance(base_url, str) or not base_url.startswith( + ("http://", "https://") + ): raise ValidationError( - f"Invalid base_url: {base_url!r}. It must start with http:// or https://, " - "e.g. 'https://stake.com' or a mirror like 'https://stake1017.com'." + f"Invalid base_url: {base_url!r}. It must start with " + "http:// or https://, e.g. 'https://stake.com' or a mirror " + "like 'https://stake1017.com'." ) # Load cookies from file/string if provided @@ -156,22 +167,30 @@ def __init__( self.base_url = base_url.rstrip("/") self.timeout = timeout self.rate_limit = rate_limit - + self._session: Optional[aiohttp.ClientSession] = None self._auth_manager = AuthManager(access_token) - - async def __aenter__(self): + + async def __aenter__(self) -> "StakeAPI": """Async context manager entry.""" await self._create_session() return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): + + async def __aexit__( + self, + exc_type: Optional[type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: """Async context manager exit.""" await self.close() - - async def _create_session(self): + + async def _create_session(self) -> None: """Create aiohttp session with proper headers.""" - ua = self.user_agent or "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36" + ua = self.user_agent or ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36" + ) headers = { "User-Agent": ua, "Accept": "application/graphql+json, application/json", @@ -186,7 +205,7 @@ async def _create_session(self): "Sec-Fetch-Site": "same-origin", "X-Language": "en", } - + if self.access_token: headers["X-Access-Token"] = self.access_token @@ -209,58 +228,65 @@ async def _create_session(self): timeout=timeout, cookies=cookies or None, ) - - async def close(self): + + async def close(self) -> None: """Close the session.""" if self._session: await self._session.close() - + async def _request( self, method: str, endpoint: str, - params: Optional[Dict] = None, - data: Optional[Dict] = None, + params: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, - ) -> Dict[Any, Any]: + ) -> Dict[str, Any]: """ Make an authenticated request to the API. - + Args: method: HTTP method endpoint: API endpoint params: Query parameters data: Request body data - + Returns: Response data as dictionary - + Raises: StakeAPIError: For API errors AuthenticationError: For authentication errors RateLimitError: For rate limit errors NetworkError: For connection failures, timeouts, and non-JSON responses """ - if not self._session: + if self._session is None: await self._create_session() + session = self._session + if session is None: + raise NetworkError("Client session could not be created") + url = urljoin(self.base_url + "/", endpoint.lstrip("/")) try: - async with self._session.request( + async with session.request( method, url, params=params, json=data, headers=headers ) as response: if response.status == 403: raise StakeAPIError( - f"403 Forbidden — Cloudflare is blocking the request to {self.base_url}. " - "Make sure you provide a valid 'cf_clearance' cookie obtained from " - f"the SAME domain ({self.base_url}). " - f"To get it: open {self.base_url} in your browser → DevTools (F12) → " - "Application tab → Cookies → copy the 'cf_clearance' value. " - "Then pass it as: StakeAPI(access_token=..., cf_clearance='...')" + f"403 Forbidden — Cloudflare is blocking the request to " + f"{self.base_url}. Make sure you provide a valid " + "'cf_clearance' cookie obtained from the SAME domain " + f"({self.base_url}). To get it: open {self.base_url} in " + "your browser → DevTools (F12) → Application tab → " + "Cookies → copy the 'cf_clearance' value. Then pass it " + "as: StakeAPI(access_token=..., cf_clearance='...')" ) elif response.status == 401: - raise AuthenticationError("Invalid access token or unauthorized access") + raise AuthenticationError( + "Invalid access token or unauthorized access" + ) elif response.status == 429: retry_after = response.headers.get("Retry-After") message = "Rate limit exceeded" @@ -279,56 +305,60 @@ async def _request( except (json.JSONDecodeError, aiohttp.ContentTypeError, ValueError): body_preview = (await response.text())[:200] raise NetworkError( - f"Expected JSON but got a non-JSON response (status {response.status}) " - f"from {url}. This usually means a Cloudflare challenge page or a " - f"redirect to a login page. Response starts with: {body_preview!r}" + f"Expected JSON but got a non-JSON response " + f"(status {response.status}) from {url}. This usually " + "means a Cloudflare challenge page or a redirect to a " + f"login page. Response starts with: {body_preview!r}" ) if response.status >= 400: - raise StakeAPIError(f"API error: {response.status} - {response_data}") + raise StakeAPIError( + f"API error: {response.status} - {response_data}" + ) - return response_data + return response_data # type: ignore except StakeAPIError: raise except asyncio.TimeoutError: raise NetworkError( - f"Request to {url} timed out after {self.timeout}s. " - "Check your connection, or the domain may be blocked/unreachable " + f"Request to {url} timed out after {self.timeout}s. Check " + "your connection, or the domain may be blocked/unreachable " "from your network — try another mirror via base_url." ) except aiohttp.ClientConnectorError as e: raise NetworkError( - f"Could not connect to {self.base_url}: {e}. " - "The domain may be blocked in your region or does not exist — " - "you can pass a different mirror, e.g. StakeAPI(base_url='https://stake1017.com')." + f"Could not connect to {self.base_url}: {e}. The domain may " + "be blocked in your region or does not exist — you can pass " + "a different mirror, e.g. " + "StakeAPI(base_url='https://stake1017.com')." ) except aiohttp.ClientError as e: raise NetworkError(f"Request failed: {e}") - + async def _graphql_request( self, query: str, variables: Optional[Dict[str, Any]] = None, - operation_name: Optional[str] = None - ) -> Dict[Any, Any]: + operation_name: Optional[str] = None, + ) -> Dict[str, Any]: """ Make a GraphQL request to the stake.com API. - + Args: query: GraphQL query string variables: Query variables operation_name: Operation name - + Returns: GraphQL response data - + Raises: GraphQLError: When the API returns GraphQL-level errors PermissionDeniedError: When the API rejects the request as unauthorized AuthenticationError: For authentication errors """ - payload = { + payload: Dict[str, Any] = { "query": query, } @@ -339,11 +369,13 @@ async def _graphql_request( payload["operationName"] = operation_name # The site's own client sends these on every GraphQL call - extra_headers = None + extra_headers: Optional[Dict[str, str]] = None if operation_name: extra_headers = { "X-Operation-Name": operation_name, - "X-Operation-Type": "mutation" if query.lstrip().startswith("mutation") else "query", + "X-Operation-Type": ( + "mutation" if query.lstrip().startswith("mutation") else "query" + ), } response = await self._request( @@ -352,7 +384,8 @@ async def _graphql_request( if not isinstance(response, dict): raise GraphQLError( - f"Unexpected GraphQL response type: {type(response).__name__} - {response!r}" + "Unexpected GraphQL response type: " + f"{type(response).__name__} - {response!r}" ) # Check for GraphQL errors @@ -365,7 +398,12 @@ async def _graphql_request( ] joined = ", ".join(error_messages) - permission_markers = ("not allowed", "unauthorized", "permission", "forbidden") + permission_markers = ( + "not allowed", + "unauthorized", + "permission", + "forbidden", + ) if any( marker in msg.lower() for msg in error_messages + error_types @@ -373,11 +411,13 @@ async def _graphql_request( ): raise PermissionDeniedError( f"GraphQL permission error: {joined}. This usually means: " - "1) your access token is invalid or expired — get a fresh one from " - f"{self.base_url} (DevTools → any GraphQL request → 'x-access-token' header); " - "2) your session/cf_clearance cookies were obtained from a different domain " - f"than base_url ({self.base_url}) — token and cookies must all come from the " - "same mirror; or 3) your account lacks permission for this operation.", + "1) your access token is invalid or expired — get a fresh " + f"one from {self.base_url} (DevTools → any GraphQL request " + "→ 'x-access-token' header); 2) your session/cf_clearance " + "cookies were obtained from a different domain than " + f"base_url ({self.base_url}) — token and cookies must all " + "come from the same mirror; or 3) your account lacks " + "permission for this operation.", errors=errors, ) @@ -390,8 +430,8 @@ async def _graphql_request( f"raw response: {response!r}" ) - return data - + return data # type: ignore + # Casino Methods async def get_casino_games(self, category: Optional[str] = None) -> List[Game]: """ @@ -404,7 +444,8 @@ async def get_casino_games(self, category: Optional[str] = None) -> List[Game]: raise StakeAPIError( "get_casino_games is not supported yet: stake.com has no REST API " "and the GraphQL query for game lists has not been mapped. " - "Working methods: get_user_balance, get_user_profile, get_bet_history, get_all_house_bets, get_currency_rates." + "Working methods: get_user_balance, get_user_profile, " + "get_bet_history, get_all_house_bets, get_currency_rates." ) async def get_game_details(self, game_id: str) -> Game: @@ -417,7 +458,8 @@ async def get_game_details(self, game_id: str) -> Game: raise StakeAPIError( "get_game_details is not supported yet: stake.com has no REST API " "and the GraphQL query for game details has not been mapped. " - "Working methods: get_user_balance, get_user_profile, get_bet_history, get_all_house_bets, get_currency_rates." + "Working methods: get_user_balance, get_user_profile, " + "get_bet_history, get_all_house_bets, get_currency_rates." ) # Sports Methods @@ -431,7 +473,8 @@ async def get_sports_events(self, sport: Optional[str] = None) -> List[SportEven raise StakeAPIError( "get_sports_events is not supported yet: stake.com has no REST API " "and the GraphQL query for sports events has not been mapped. " - "Working methods: get_user_balance, get_user_profile, get_bet_history, get_all_house_bets, get_currency_rates." + "Working methods: get_user_balance, get_user_profile, " + "get_bet_history, get_all_house_bets, get_currency_rates." ) # User Methods @@ -458,11 +501,11 @@ async def get_user_profile(self) -> User: verified=bool(user.get("hasEmailVerified")), created_at=_parse_datetime(user.get("createdAt")), ) - + async def get_user_balance(self) -> Dict[str, Dict[str, float]]: """ Get user account balance using GraphQL. - + Returns: Balance information by currency with available and vault amounts Format: { @@ -491,15 +534,12 @@ async def get_user_balance(self) -> Dict[str, Dict[str, float]]: } } """ - + data = await self._graphql_request(query, operation_name="UserBalances") - + # Process the response to create a more convenient format - result = { - "available": {}, - "vault": {} - } - + result: Dict[str, Dict[str, float]] = {"available": {}, "vault": {}} + if "user" in data and data["user"] and "balances" in data["user"]: for entry in data["user"]["balances"]: if "available" in entry: @@ -510,9 +550,9 @@ async def get_user_balance(self) -> Dict[str, Dict[str, float]]: currency = entry["vault"].get("currency", "").lower() amount = float(entry["vault"].get("amount", 0)) result["vault"][currency] = amount - + return result - + # Betting Methods async def place_bet(self, bet_data: Dict[str, Any]) -> Bet: """ @@ -523,9 +563,10 @@ async def place_bet(self, bet_data: Dict[str, Any]) -> Bet: mutations for placing bets have not been mapped. """ raise StakeAPIError( - "place_bet is not supported yet: stake.com has no REST API and the " - "GraphQL mutations for placing bets have not been mapped. " - "Working methods: get_user_balance, get_user_profile, get_bet_history, get_all_house_bets, get_currency_rates." + "place_bet is not supported yet: stake.com has no REST API and " + "the GraphQL mutations for placing bets have not been mapped. " + "Working methods: get_user_balance, get_user_profile, " + "get_bet_history, get_all_house_bets, get_currency_rates." ) async def get_bet_history(self, limit: int = 50, offset: int = 0) -> List[Bet]: diff --git a/stakeapi/endpoints.py b/stakeapi/endpoints.py index e2695e1..28854a6 100644 --- a/stakeapi/endpoints.py +++ b/stakeapi/endpoints.py @@ -3,46 +3,46 @@ class Endpoints: """API endpoint constants.""" - + # GraphQL endpoint GRAPHQL = "/_api/graphql" - + # Legacy REST endpoints (if any still exist) API_BASE = "/api/v1" - + # Authentication AUTH_LOGIN = f"{API_BASE}/auth/login" AUTH_LOGOUT = f"{API_BASE}/auth/logout" AUTH_REFRESH = f"{API_BASE}/auth/refresh" - + # User endpoints USER_PROFILE = f"{API_BASE}/user/profile" USER_BALANCE = f"{API_BASE}/user/balance" USER_STATISTICS = f"{API_BASE}/user/statistics" USER_TRANSACTIONS = f"{API_BASE}/user/transactions" - + # Casino endpoints CASINO_GAMES = f"{API_BASE}/casino/games" CASINO_GAME_DETAILS = f"{API_BASE}/casino/games/{{game_id}}" CASINO_PROVIDERS = f"{API_BASE}/casino/providers" CASINO_CATEGORIES = f"{API_BASE}/casino/categories" - + # Sports endpoints SPORTS_EVENTS = f"{API_BASE}/sports/events" SPORTS_EVENT_DETAILS = f"{API_BASE}/sports/events/{{event_id}}" SPORTS_LEAGUES = f"{API_BASE}/sports/leagues" SPORTS_ODDS = f"{API_BASE}/sports/odds" - + # Betting endpoints PLACE_BET = f"{API_BASE}/bets/place" BET_HISTORY = f"{API_BASE}/bets/history" BET_DETAILS = f"{API_BASE}/bets/{{bet_id}}" CANCEL_BET = f"{API_BASE}/bets/{{bet_id}}/cancel" - + # Live endpoints LIVE_GAMES = f"{API_BASE}/live/games" LIVE_EVENTS = f"{API_BASE}/live/events" - + # Promotions PROMOTIONS = f"{API_BASE}/promotions" PROMOTION_DETAILS = f"{API_BASE}/promotions/{{promo_id}}" @@ -50,7 +50,7 @@ class Endpoints: class GraphQLQueries: """GraphQL query constants for stake.com API.""" - + USER_BALANCES = """ query UserBalances { user { @@ -72,7 +72,7 @@ class GraphQLQueries: } } """ - + # Validated against the live API (fields confirmed to exist on type User) USER_PROFILE = """ query UserProfile { @@ -86,7 +86,7 @@ class GraphQLQueries: } } """ - + # UNVERIFIED DRAFT — this query shape does not match the live schema; # kept only as a starting point for future work CASINO_GAMES = """ @@ -120,7 +120,7 @@ class GraphQLQueries: } } """ - + # Validated against the live API — public realtime feed of house bets # across all bet types (captured from the site's own AllHouseBets query) ALL_HOUSE_BETS = """ @@ -295,7 +295,7 @@ class GraphQLQueries: } } """ - + # Validated against the live API — bet history is user.houseBetList; # 'game' on the outer Bet is an object, while CasinoBet.game is an enum BET_HISTORY = """ diff --git a/stakeapi/exceptions.py b/stakeapi/exceptions.py index 01afb5d..570760d 100644 --- a/stakeapi/exceptions.py +++ b/stakeapi/exceptions.py @@ -1,23 +1,29 @@ """Custom exceptions for StakeAPI.""" +from typing import Any, Optional + class StakeAPIError(Exception): """Base exception for StakeAPI errors.""" + pass class AuthenticationError(StakeAPIError): """Raised when authentication fails.""" + pass class RateLimitError(StakeAPIError): """Raised when rate limit is exceeded.""" + pass class ValidationError(StakeAPIError): """Raised when input validation fails.""" + pass @@ -28,9 +34,9 @@ class GraphQLError(StakeAPIError): errors: The raw list of error objects returned by the API. """ - def __init__(self, message: str, errors: list = None): + def __init__(self, message: str, errors: Optional[list[Any]] = None): super().__init__(message) - self.errors = errors or [] + self.errors: list[Any] = errors or [] class PermissionDeniedError(GraphQLError): @@ -39,19 +45,23 @@ class PermissionDeniedError(GraphQLError): Usually means the access token / session cookie is invalid, expired, or belongs to a different stake domain (mirror) than the one being used. """ + pass class NetworkError(StakeAPIError): """Raised when network requests fail.""" + pass class GameNotFoundError(StakeAPIError): """Raised when a requested game is not found.""" + pass class InsufficientFundsError(StakeAPIError): """Raised when user has insufficient funds for an operation.""" + pass diff --git a/stakeapi/models.py b/stakeapi/models.py index 1aaba4a..17733ab 100644 --- a/stakeapi/models.py +++ b/stakeapi/models.py @@ -1,14 +1,15 @@ """Data models for StakeAPI.""" -from typing import Dict, Any, Optional, List from datetime import datetime -from pydantic import BaseModel, Field from decimal import Decimal +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field class User(BaseModel): """User model.""" - + id: str username: str email: Optional[str] = None @@ -16,7 +17,7 @@ class User(BaseModel): created_at: datetime country: Optional[str] = None currency: str = "USD" - + @classmethod def from_dict(cls, data: Dict[str, Any]) -> "User": """Create User from dictionary.""" @@ -25,7 +26,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "User": class Game(BaseModel): """Casino game model.""" - + id: str name: str category: str @@ -37,7 +38,7 @@ class Game(BaseModel): volatility: Optional[str] = None features: List[str] = Field(default_factory=list) thumbnail_url: Optional[str] = None - + @classmethod def from_dict(cls, data: Dict[str, Any]) -> "Game": """Create Game from dictionary.""" @@ -46,7 +47,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "Game": class SportEvent(BaseModel): """Sports event model.""" - + id: str sport: str league: str @@ -56,7 +57,7 @@ class SportEvent(BaseModel): status: str odds: Dict[str, float] = Field(default_factory=dict) live: bool = False - + @classmethod def from_dict(cls, data: Dict[str, Any]) -> "SportEvent": """Create SportEvent from dictionary.""" @@ -65,7 +66,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SportEvent": class Bet(BaseModel): """Bet model.""" - + id: str user_id: str game_id: Optional[str] = None @@ -79,7 +80,7 @@ class Bet(BaseModel): status: str # pending, won, lost, cancelled placed_at: datetime settled_at: Optional[datetime] = None - + @classmethod def from_dict(cls, data: Dict[str, Any]) -> "Bet": """Create Bet from dictionary.""" @@ -88,7 +89,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "Bet": class Transaction(BaseModel): """Transaction model.""" - + id: str user_id: str type: str # deposit, withdrawal, bet, win @@ -97,7 +98,7 @@ class Transaction(BaseModel): status: str timestamp: datetime description: Optional[str] = None - + @classmethod def from_dict(cls, data: Dict[str, Any]) -> "Transaction": """Create Transaction from dictionary.""" @@ -106,7 +107,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "Transaction": class Statistics(BaseModel): """User statistics model.""" - + total_bets: int = 0 total_wagered: Decimal = Field(default=Decimal("0")) total_won: Decimal = Field(default=Decimal("0")) @@ -114,7 +115,7 @@ class Statistics(BaseModel): win_rate: float = 0.0 biggest_win: Decimal = Field(default=Decimal("0")) favorite_game: Optional[str] = None - + @classmethod def from_dict(cls, data: Dict[str, Any]) -> "Statistics": """Create Statistics from dictionary.""" diff --git a/stakeapi/utils.py b/stakeapi/utils.py index f19eb98..392a061 100644 --- a/stakeapi/utils.py +++ b/stakeapi/utils.py @@ -1,42 +1,42 @@ """Utility functions for StakeAPI.""" import re -from typing import Any, Dict, Optional from datetime import datetime, timezone from decimal import Decimal, InvalidOperation +from typing import Any, Optional def validate_api_key(api_key: str) -> bool: """ Validate API key format. - + Args: api_key: The API key to validate - + Returns: True if valid format """ if not api_key or not isinstance(api_key, str): return False - + # Basic format validation (adjust based on actual format) - pattern = r'^[a-zA-Z0-9]{32,64}$' + pattern = r"^[a-zA-Z0-9]{32,64}$" return bool(re.match(pattern, api_key)) def safe_decimal(value: Any) -> Optional[Decimal]: """ Safely convert value to Decimal. - + Args: value: Value to convert - + Returns: Decimal value or None if conversion fails """ if value is None: return None - + try: return Decimal(str(value)) except (InvalidOperation, ValueError, TypeError): @@ -46,19 +46,19 @@ def safe_decimal(value: Any) -> Optional[Decimal]: def parse_datetime(date_string: str) -> Optional[datetime]: """ Parse datetime string to datetime object. - + Args: date_string: ISO format datetime string - + Returns: Datetime object or None if parsing fails """ if not date_string: return None - + try: # Try parsing ISO format with timezone - return datetime.fromisoformat(date_string.replace('Z', '+00:00')) + return datetime.fromisoformat(date_string.replace("Z", "+00:00")) except ValueError: try: # Try parsing without timezone @@ -71,11 +71,11 @@ def parse_datetime(date_string: str) -> Optional[datetime]: def format_currency(amount: Decimal, currency: str = "USD") -> str: """ Format currency amount for display. - + Args: amount: Amount to format currency: Currency code - + Returns: Formatted currency string """ @@ -92,29 +92,29 @@ def format_currency(amount: Decimal, currency: str = "USD") -> str: def calculate_win_rate(wins: int, total_bets: int) -> float: """ Calculate win rate percentage. - + Args: wins: Number of wins total_bets: Total number of bets - + Returns: Win rate as percentage (0-100) """ if total_bets == 0: return 0.0 - + return (wins / total_bets) * 100 def validate_bet_amount(amount: Decimal, min_bet: Decimal, max_bet: Decimal) -> bool: """ Validate bet amount is within limits. - + Args: amount: Bet amount min_bet: Minimum bet amount max_bet: Maximum bet amount - + Returns: True if amount is valid """ @@ -124,18 +124,18 @@ def validate_bet_amount(amount: Decimal, min_bet: Decimal, max_bet: Decimal) -> def sanitize_game_name(name: str) -> str: """ Sanitize game name for safe usage. - + Args: name: Game name to sanitize - + Returns: Sanitized game name """ if not name: return "" - + # Remove special characters and normalize spaces - sanitized = re.sub(r'[^\w\s-]', '', name) - sanitized = re.sub(r'\s+', ' ', sanitized).strip() - + sanitized = re.sub(r"[^\w\s-]", "", name) + sanitized = re.sub(r"\s+", " ", sanitized).strip() + return sanitized diff --git a/tests/conftest.py b/tests/conftest.py index cdd2253..00dc278 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,9 @@ """Test configuration and fixtures.""" +from unittest.mock import AsyncMock, Mock + import pytest -import asyncio -from unittest.mock import Mock, AsyncMock + from stakeapi import StakeAPI @@ -38,7 +39,7 @@ def sample_user_data(): "verified": True, "created_at": "2025-01-01T00:00:00Z", "country": "US", - "currency": "USD" + "currency": "USD", } @@ -56,7 +57,7 @@ def sample_game_data(): "rtp": 96.5, "volatility": "medium", "features": ["free_spins", "wilds"], - "thumbnail_url": "https://example.com/thumb.jpg" + "thumbnail_url": "https://example.com/thumb.jpg", } @@ -72,5 +73,5 @@ def sample_sport_event_data(): "start_time": "2025-01-15T15:00:00Z", "status": "upcoming", "odds": {"home": 2.5, "away": 3.2, "draw": 3.0}, - "live": False + "live": False, } diff --git a/tests/test_client.py b/tests/test_client.py index a314274..0b83ea9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,15 +1,17 @@ """Tests for StakeAPI client.""" +from unittest.mock import patch + import pytest -from unittest.mock import AsyncMock, patch + from stakeapi import StakeAPI from stakeapi.exceptions import AuthenticationError, RateLimitError -from stakeapi.models import User, Game, SportEvent +from stakeapi.models import Game, SportEvent, User class TestStakeAPI: """Test cases for StakeAPI client.""" - + def test_init(self, api_key): """Test client initialization.""" client = StakeAPI(api_key=api_key) @@ -17,96 +19,95 @@ def test_init(self, api_key): assert client.base_url == "https://stake.com" assert client.timeout == 30 assert client.rate_limit == 10 - + def test_init_with_custom_params(self): """Test client initialization with custom parameters.""" client = StakeAPI( - api_key="test", - base_url="https://custom.com", - timeout=60, - rate_limit=5 + api_key="test", base_url="https://custom.com", timeout=60, rate_limit=5 ) assert client.base_url == "https://custom.com" assert client.timeout == 60 assert client.rate_limit == 5 - + @pytest.mark.asyncio async def test_context_manager(self, api_key): """Test client as async context manager.""" async with StakeAPI(api_key=api_key) as client: assert client._session is not None - + @pytest.mark.asyncio async def test_authentication_error(self, stake_client): """Test authentication error handling.""" - with patch.object(stake_client, '_request') as mock_request: + with patch.object(stake_client, "_request") as mock_request: mock_request.side_effect = AuthenticationError("Invalid API key") - + with pytest.raises(AuthenticationError): await stake_client.get_user_profile() - + @pytest.mark.asyncio async def test_rate_limit_error(self, stake_client): """Test rate limit error handling.""" - with patch.object(stake_client, '_request') as mock_request: + with patch.object(stake_client, "_request") as mock_request: mock_request.side_effect = RateLimitError("Rate limit exceeded") - + with pytest.raises(RateLimitError): await stake_client.get_casino_games() - + @pytest.mark.asyncio async def test_get_casino_games(self, stake_client, sample_game_data): """Test getting casino games.""" mock_response = {"games": [sample_game_data]} - - with patch.object(stake_client, '_request', return_value=mock_response): + + with patch.object(stake_client, "_request", return_value=mock_response): games = await stake_client.get_casino_games() - + assert len(games) == 1 assert isinstance(games[0], Game) assert games[0].name == "Test Slot" - + @pytest.mark.asyncio async def test_get_casino_games_with_category(self, stake_client, sample_game_data): """Test getting casino games with category filter.""" mock_response = {"games": [sample_game_data]} - - with patch.object(stake_client, '_request', return_value=mock_response) as mock_request: + + with patch.object( + stake_client, "_request", return_value=mock_response + ) as mock_request: await stake_client.get_casino_games(category="slots") - + # Verify the request was made with correct parameters mock_request.assert_called_once() args, kwargs = mock_request.call_args - assert kwargs['params'] == {"category": "slots"} - + assert kwargs["params"] == {"category": "slots"} + @pytest.mark.asyncio async def test_get_user_profile(self, stake_client, sample_user_data): """Test getting user profile.""" - with patch.object(stake_client, '_request', return_value=sample_user_data): + with patch.object(stake_client, "_request", return_value=sample_user_data): user = await stake_client.get_user_profile() - + assert isinstance(user, User) assert user.username == "testuser" assert user.verified is True - + @pytest.mark.asyncio async def test_get_user_balance(self, stake_client): """Test getting user balance.""" mock_response = {"balances": {"USD": 100.50, "BTC": 0.001}} - - with patch.object(stake_client, '_request', return_value=mock_response): + + with patch.object(stake_client, "_request", return_value=mock_response): balance = await stake_client.get_user_balance() - + assert balance == {"USD": 100.50, "BTC": 0.001} - + @pytest.mark.asyncio async def test_get_sports_events(self, stake_client, sample_sport_event_data): """Test getting sports events.""" mock_response = {"events": [sample_sport_event_data]} - - with patch.object(stake_client, '_request', return_value=mock_response): + + with patch.object(stake_client, "_request", return_value=mock_response): events = await stake_client.get_sports_events() - + assert len(events) == 1 assert isinstance(events[0], SportEvent) assert events[0].home_team == "Team A" diff --git a/tests/test_models.py b/tests/test_models.py index 2d8fa7c..baf3767 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,24 +1,23 @@ """Tests for data models.""" -import pytest -from datetime import datetime from decimal import Decimal -from stakeapi.models import User, Game, SportEvent, Bet, Transaction, Statistics + +from stakeapi.models import Bet, Game, SportEvent, Statistics, User class TestUserModel: """Test cases for User model.""" - + def test_user_creation(self, sample_user_data): """Test user creation from data.""" user = User.from_dict(sample_user_data) - + assert user.id == "user123" assert user.username == "testuser" assert user.email == "test@example.com" assert user.verified is True assert user.currency == "USD" - + def test_user_minimal_data(self): """Test user creation with minimal data.""" data = { @@ -26,9 +25,9 @@ def test_user_minimal_data(self): "username": "minimal_user", "verified": False, "created_at": "2025-01-01T00:00:00Z", - "currency": "EUR" + "currency": "EUR", } - + user = User.from_dict(data) assert user.id == "user456" assert user.email is None @@ -37,11 +36,11 @@ def test_user_minimal_data(self): class TestGameModel: """Test cases for Game model.""" - + def test_game_creation(self, sample_game_data): """Test game creation from data.""" game = Game.from_dict(sample_game_data) - + assert game.id == "game123" assert game.name == "Test Slot" assert game.category == "slots" @@ -50,16 +49,16 @@ def test_game_creation(self, sample_game_data): assert game.max_bet == Decimal("100.00") assert game.rtp == 96.5 assert "free_spins" in game.features - + def test_game_minimal_data(self): """Test game creation with minimal data.""" data = { "id": "game456", "name": "Simple Game", "category": "table", - "provider": "Simple Provider" + "provider": "Simple Provider", } - + game = Game.from_dict(data) assert game.id == "game456" assert game.min_bet == Decimal("0.01") # default value @@ -68,11 +67,11 @@ def test_game_minimal_data(self): class TestSportEventModel: """Test cases for SportEvent model.""" - + def test_sport_event_creation(self, sample_sport_event_data): """Test sport event creation from data.""" event = SportEvent.from_dict(sample_sport_event_data) - + assert event.id == "event123" assert event.sport == "football" assert event.home_team == "Team A" @@ -83,7 +82,7 @@ def test_sport_event_creation(self, sample_sport_event_data): class TestBetModel: """Test cases for Bet model.""" - + def test_bet_creation(self): """Test bet creation from data.""" data = { @@ -95,9 +94,9 @@ def test_bet_creation(self): "potential_payout": "20.00", "odds": 2.0, "status": "pending", - "placed_at": "2025-01-01T12:00:00Z" + "placed_at": "2025-01-01T12:00:00Z", } - + bet = Bet.from_dict(data) assert bet.id == "bet123" assert bet.amount == Decimal("10.00") @@ -107,7 +106,7 @@ def test_bet_creation(self): class TestStatisticsModel: """Test cases for Statistics model.""" - + def test_statistics_creation(self): """Test statistics creation.""" data = { @@ -117,15 +116,15 @@ def test_statistics_creation(self): "total_lost": "50.00", "win_rate": 85.5, "biggest_win": "500.00", - "favorite_game": "Mega Slots" + "favorite_game": "Mega Slots", } - + stats = Statistics.from_dict(data) assert stats.total_bets == 100 assert stats.total_wagered == Decimal("1000.00") assert stats.win_rate == 85.5 assert stats.favorite_game == "Mega Slots" - + def test_statistics_defaults(self): """Test statistics with default values.""" stats = Statistics() diff --git a/tests/test_utils.py b/tests/test_utils.py index c2bd56d..af760df 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,37 +1,36 @@ """Tests for utility functions.""" -import pytest -from datetime import datetime, timezone from decimal import Decimal + from stakeapi.utils import ( - validate_api_key, - safe_decimal, - parse_datetime, - format_currency, calculate_win_rate, + format_currency, + parse_datetime, + safe_decimal, + sanitize_game_name, + validate_api_key, validate_bet_amount, - sanitize_game_name ) class TestValidateApiKey: """Test cases for API key validation.""" - + def test_valid_api_key(self): """Test valid API key.""" valid_key = "a" * 32 # 32 character string assert validate_api_key(valid_key) is True - + def test_invalid_api_key_too_short(self): """Test invalid API key - too short.""" short_key = "a" * 16 assert validate_api_key(short_key) is False - + def test_invalid_api_key_special_chars(self): """Test invalid API key with special characters.""" invalid_key = "a" * 30 + "!@" assert validate_api_key(invalid_key) is False - + def test_empty_api_key(self): """Test empty API key.""" assert validate_api_key("") is False @@ -40,17 +39,17 @@ def test_empty_api_key(self): class TestSafeDecimal: """Test cases for safe decimal conversion.""" - + def test_valid_decimal_string(self): """Test valid decimal string.""" result = safe_decimal("10.50") assert result == Decimal("10.50") - + def test_valid_decimal_number(self): """Test valid decimal number.""" result = safe_decimal(10.50) assert result == Decimal("10.50") - + def test_invalid_decimal(self): """Test invalid decimal value.""" assert safe_decimal("invalid") is None @@ -60,25 +59,25 @@ def test_invalid_decimal(self): class TestParseDatetime: """Test cases for datetime parsing.""" - + def test_iso_format_with_z(self): """Test ISO format with Z timezone.""" date_str = "2025-01-01T12:00:00Z" result = parse_datetime(date_str) - + assert result is not None assert result.year == 2025 assert result.month == 1 assert result.day == 1 - + def test_iso_format_with_timezone(self): """Test ISO format with timezone offset.""" date_str = "2025-01-01T12:00:00+00:00" result = parse_datetime(date_str) - + assert result is not None assert result.tzinfo is not None - + def test_invalid_datetime(self): """Test invalid datetime string.""" assert parse_datetime("invalid-date") is None @@ -88,19 +87,19 @@ def test_invalid_datetime(self): class TestFormatCurrency: """Test cases for currency formatting.""" - + def test_usd_formatting(self): """Test USD formatting.""" amount = Decimal("123.45") result = format_currency(amount, "USD") assert result == "$123.45" - + def test_eur_formatting(self): """Test EUR formatting.""" amount = Decimal("100.00") result = format_currency(amount, "EUR") assert result == "€100.00" - + def test_unknown_currency(self): """Test unknown currency formatting.""" amount = Decimal("50.75") @@ -110,17 +109,17 @@ def test_unknown_currency(self): class TestCalculateWinRate: """Test cases for win rate calculation.""" - + def test_normal_win_rate(self): """Test normal win rate calculation.""" win_rate = calculate_win_rate(80, 100) assert win_rate == 80.0 - + def test_zero_bets(self): """Test win rate with zero bets.""" win_rate = calculate_win_rate(0, 0) assert win_rate == 0.0 - + def test_partial_win_rate(self): """Test partial win rate.""" win_rate = calculate_win_rate(33, 100) @@ -129,47 +128,47 @@ def test_partial_win_rate(self): class TestValidateBetAmount: """Test cases for bet amount validation.""" - + def test_valid_bet_amount(self): """Test valid bet amount.""" amount = Decimal("10.00") min_bet = Decimal("1.00") max_bet = Decimal("100.00") - + assert validate_bet_amount(amount, min_bet, max_bet) is True - + def test_bet_amount_too_low(self): """Test bet amount below minimum.""" amount = Decimal("0.50") min_bet = Decimal("1.00") max_bet = Decimal("100.00") - + assert validate_bet_amount(amount, min_bet, max_bet) is False - + def test_bet_amount_too_high(self): """Test bet amount above maximum.""" amount = Decimal("150.00") min_bet = Decimal("1.00") max_bet = Decimal("100.00") - + assert validate_bet_amount(amount, min_bet, max_bet) is False class TestSanitizeGameName: """Test cases for game name sanitization.""" - + def test_normal_game_name(self): """Test normal game name.""" name = "Mega Slots Deluxe" result = sanitize_game_name(name) assert result == "Mega Slots Deluxe" - + def test_game_name_with_special_chars(self): """Test game name with special characters.""" name = "Super Game! @#$% Edition" result = sanitize_game_name(name) assert result == "Super Game Edition" - + def test_empty_game_name(self): """Test empty game name.""" assert sanitize_game_name("") == ""