From 881baaa1ed0b09c1b0cc4c7d90ac0c168d436b25 Mon Sep 17 00:00:00 2001 From: CF Date: Thu, 9 Oct 2025 12:35:28 -0700 Subject: [PATCH] import all --- .gitignore | 25 +++ LICENSE | 373 +++++++++++++++++++++++++++++++++++++ README.md | 11 ++ TODO.md | 4 + edderkop.code-workspace | 11 ++ edderkop.py | 14 ++ pyproject.toml | 35 ++++ setup.py | 19 ++ src/abrickowlapi.py | 120 ++++++++++++ src/abricksetapi.py | 137 ++++++++++++++ src/asquare.py | 135 ++++++++++++++ src/banner.py | 20 ++ src/bricksetapi.py | 60 ++++++ src/db_manager.py | 221 ++++++++++++++++++++++ src/ebricksetapi.py | 116 ++++++++++++ src/get_sq_env.py | 16 ++ src/libsquare.py | 66 +++++++ src/scripts/edderkop.sql | 7 + src/server.py | 113 +++++++++++ src/test.py | 6 + src/test_client.py | 39 ++++ src/test_server.py | 121 ++++++++++++ src/throttler_example.py | 15 ++ tests/test_abricksetapi.py | 62 ++++++ tests/test_asquare.py | 33 ++++ 25 files changed, 1779 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 TODO.md create mode 100644 edderkop.code-workspace create mode 100644 edderkop.py create mode 100644 pyproject.toml create mode 100644 setup.py create mode 100644 src/abrickowlapi.py create mode 100644 src/abricksetapi.py create mode 100644 src/asquare.py create mode 100644 src/banner.py create mode 100644 src/bricksetapi.py create mode 100644 src/db_manager.py create mode 100644 src/ebricksetapi.py create mode 100644 src/get_sq_env.py create mode 100644 src/libsquare.py create mode 100644 src/scripts/edderkop.sql create mode 100644 src/server.py create mode 100644 src/test.py create mode 100644 src/test_client.py create mode 100644 src/test_server.py create mode 100644 src/throttler_example.py create mode 100644 tests/test_abricksetapi.py create mode 100644 tests/test_asquare.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f82f1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +logs/ +.env +__pycache__/ +*.pyc +.vscode/ +.idea/ +.DS_Store +*.db +*.sqlite3 +*.log +poetry.lock +bin/build/ +.zip +snippet.py +doc/square-src/square-python-sdk-43.0.2.20250820/ + +doc/square-src/ + +doc/pw-inventory/pw-inventory.zip + +doc/pw-inventory/SQUARE_inventory_forautomation_2025-06-18-0139.csv + +doc/pw-inventory/square_inventory_export_2025-06-18-0137.xlsx + +square-src.code-workspace diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..43bbdb7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md new file mode 100644 index 0000000..53bf07e --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Edderkop + +environment: poetry +code style: black + +built with + +- aiohttp +- squareup +- apscheduler +- aiosqlite \ No newline at end of file diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..d9bbabf --- /dev/null +++ b/TODO.md @@ -0,0 +1,4 @@ +- glue shit together +- test sq +- test inserting brickowl into sq +- test kopling \ No newline at end of file diff --git a/edderkop.code-workspace b/edderkop.code-workspace new file mode 100644 index 0000000..e6d0824 --- /dev/null +++ b/edderkop.code-workspace @@ -0,0 +1,11 @@ +{ + "folders": [ + { + "path": "." + }, + { + "path": "../kopling" + } + ], + "settings": {} +} \ No newline at end of file diff --git a/edderkop.py b/edderkop.py new file mode 100644 index 0000000..caaba51 --- /dev/null +++ b/edderkop.py @@ -0,0 +1,14 @@ +import src.test_server + + +class edderkop: + def __init__(self): + self.name = "Edderkop" + self.version = "0.0.1" + self.description = """ + Edderkop is an application designed to interact with the Brickset API and directly with Square. + It allows users to search for items, manage collections, and perform various operations related to bricksets. + """ + + def run(self): + print(f"{self.name} v{self.version} is running.") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ccfc4c8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "edderkop" +version = "0.1.0" +description = "" +authors = [ + {name = "CF", email = "chris-forst@pm.me"} +] +license = {text = "MIT"} +readme = "README.md" +requires-python = ">=3.13,<3.14" +dependencies = [ + "aiohttp (>=3.12.13,<4.0.0)", + "dotenv (>=0.9.9,<0.10.0)", + "pyinstaller (>=6.14.1,<7.0.0)", + "squareup(>=43.0.0.20250618,<44.0.0.0)", + "asyncio-throttle (>=1.0.2,<2.0.0)", + "requests (>=2.32.4,<3.0.0)", + "pytest (>=8.4.2,<9.0.0)", + "apscheduler (>=3.11.0,<4.0.0)", + "aiosqlite (>=0.21.0,<0.22.0)", +] + + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +package-mode = false + +[tool.bricksetapi] +version = "0.1.0" + +[tool.abricksetapi] +version = "0.1.0" \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..1f0f559 --- /dev/null +++ b/setup.py @@ -0,0 +1,19 @@ +from setuptools import setup + +setup( + name='Edderkop', + version='0.1', + description='Bricket + Square integration application', + author='CF', + author_email='chris-forst@pm.me', + packages=['src'], + package_dir={'': 'src'}, + install_requires=[ + # List your project dependencies here + ], + entry_points={ + 'console_scripts': [ + 'main=main:main', # Adjust this line based on your main function + ], + }, +) \ No newline at end of file diff --git a/src/abrickowlapi.py b/src/abrickowlapi.py new file mode 100644 index 0000000..49ca97d --- /dev/null +++ b/src/abrickowlapi.py @@ -0,0 +1,120 @@ +import aiohttp +import json +import dotenv +import os +import importlib.metadata + +dotenv.load_dotenv() + +api_key = os.getenv("BRICKOWL") +abrickowl_version = importlib.metadata.version("abrickowlapi") + + +class ABrickowlAPI: + + def __init__(self, value): + self.user_agent = f"abrickowlapi/{abrickowl_version}" + self.headers = {"content-type": "application/x-www-form-urlencoded"} + self.base_url = "https://api.brickowl.com/v1/" + self.session = aiohttp.ClientSession() + return self + + def set_valid(self, set_id: int): + """ + Verify if a LEGO set ID is valid. + """ + if len(set_id) == 5: + return True + else: + return False + + @classmethod + async def catalog_list(self): + """ + Fetch the list of LEGO sets from the Brickowl API. + """ + async with self.session.get( + f"{self.base_url}catalog/list/view?key={api_key}", + headers=self.headers, + user_agent=self.user_agent, + ) as response: + if response.status == 200: + return await response.json() + else: + raise Exception(f"Error fetching list, {response.status}") + + @classmethod + async def catalog_lookup(self, set_id: int): + """ + Fetch details from the brickowl API for a specific LEGO set. + + set_id: The LEGO set ID to look up. + + returns JSON response containing set details. + """ + async with self.session.get( + f"{self.base_url}catalog/lookup/{set_id}view?key={api_key}", + headers=self.headers, + user_agent=self.user_agent, + ) as response: + if response.status == 200: + return await response.json() + else: + raise Exception(f"Error fetching set {set_id}, {response.status}") + + @classmethod + async def catalog_availability(self, set_id: int): + """ + Fetch availability information for a specific LEGO set. + """ + async with self.session.get( + f"{self.base_url}catalog/availability/{set_id}view?key={api_key}", + headers=self.headers, + user_agent=self.user_agent, + ) as response: + if response.status == 200: + return await response.json() + else: + raise Exception( + f"Error fetching fetching availability for {set_id}, {response.status}" + ) + + @classmethod + async def catalog_id_lookup(self, set_id: int): + async with self.session.get( + f"{self.base_url}catalog/id_lookup/{set_id}view?key={api_key}", + headers=self.headers, + user_agent=self.user_agent, + ) as response: + if response.status == 200: + return await response.json() + else: + raise Exception(f"ID lookup failed for set {set_id}, {response.status}") + + @classmethod + async def catalog_bulk(self): + async with self.session.get( + f"{self.base_url}catalog/bulk/view?key={api_key}", + headers=self.headers, + user_agent=self.user_agent, + ) as response: + if response.status == 200: + return await response.json() + else: + raise Exception(f"Error fetching bulk, {response.status}") + + @classmethod + async def catalog_search(self, query: str): + async with self.session.get( + f"{self.base_url}catalog/search/{query}view?key={api_key}", + headers=self.headers, + user_agent=self.user_agent, + ) as response: + if response.status == 200: + return await response.json() + else: + raise Exception(f"Search term {set_id} failed, {response.status}") + + +# Usage: +# obj = await ABrickowlAPI.catalog_lookup(value) diff --git a/src/abricksetapi.py b/src/abricksetapi.py new file mode 100644 index 0000000..5c4897e --- /dev/null +++ b/src/abricksetapi.py @@ -0,0 +1,137 @@ +""" +abricksetapi.py - Asynchronous Brickset API Client +This module provides asynchronous interactions with the Brickset API. +""" + +import asyncio +import os +import aiohttp +import json +import dotenv +from typing import Dict, Any +from asyncio_throttle.throttler import Throttler + +dotenv.load_dotenv() + + +class AsyncBricksetAPI: + """Asynchronous client for the Brickset API""" + + def __init__(self, debug: bool = False): + self.username = os.getenv("BRICKSET_USERNAME") + self.password = os.getenv("BRICKSET_PASS") + self.api_key = os.getenv("BRICKSET_API_KEY") + self.debug = debug + self.headers = {"UserAgent": "Edderkop/0.1"} + self.throttler = Throttler(rate_limit=500, period=60) + self.user_hash = None + + async def login(self) -> Dict[str, Any]: + """Asynchronously authenticate with Brickset API""" + url = "https://brickset.com/api/v3.asmx/login" + params = { + "username": self.username, + "password": self.password, + "apiKey": self.api_key, + } + + async with aiohttp.ClientSession() as session: + async with session.get( + url, params=params, headers=self.headers # type: ignore + ) as response: + if self.debug: + print(f"[@DEBUG] Login response status: {response.status}") + + data = await response.read() + result = json.loads(data.decode("utf-8")) + self.user_hash = result.get("hash") + return result + + async def get_set(self, set_number: str) -> Dict[str, Any]: + """ + Fetch LEGO set information asynchronously + + Args: + set_number (str): LEGO set number + + Returns: + Dict containing set information + """ + if not self.user_hash: + await self.login() + + url = "https://brickset.com/api/v3.asmx/getSets" + params = { + "apiKey": self.api_key, + "userHash": self.user_hash, + "params": json.dumps({"setNumber": f"{set_number}-1"}), + } + + async with aiohttp.ClientSession() as session: + async with session.get( + url, params=params, headers=self.headers + ) as response: + if self.debug: + print(f"[@DEBUG] Get set response status: {response.status}") + + if response.status == 200: + data = await response.read() + result = json.loads(data.decode("utf-8")) + + if not result.get("sets"): + raise ValueError(f"No data found for set {set_number}") + + set_data = result["sets"][0] + our_set = { + "set_num": set_data["number"], + "set_name": set_data["name"], + "set_theme": set_data["theme"], + "set_image": set_data["image"]["thumbnailURL"], + "set_barcode": set_data["barcode"]["UPC"], + "set_retailprice": round( + set_data["LEGOCom"]["US"]["retailPrice"] + ), + } + + # Calculate adjusted price (10% markup) + retail_price = our_set["set_retailprice"] + our_set["set_adjustredprice"] = retail_price + ( + (10 / 100) * retail_price + ) + + # Set PW inventory name with theme + our_set["pw_name"] = ( + f"{our_set['set_num']} - {our_set['set_theme'].upper()} {our_set['set_name']}" + ) + + # Flag for manual price review if needed + if our_set["set_retailprice"] >= 110: + our_set["requires_manual_price"] = True + + return our_set + + elif response.status == 500: + raise Exception( + "Internal Server Error: Please check API key and parameters." + ) + else: + raise Exception(f"Unexpected response: {response.status}") + + async def throttled_get_set(self, set_number: str) -> Dict[str, Any]: + """Get set information with rate limiting""" + async with self.throttler: + return await self.get_set(set_number) + + +async def main(): + """Example usage""" + client = AsyncBricksetAPI(debug=True) + try: + set_info = await client.throttled_get_set("10349") + print(json.dumps(set_info, indent=2)) + except Exception as e: + print(f"Error: {e}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/asquare.py b/src/asquare.py new file mode 100644 index 0000000..c172348 --- /dev/null +++ b/src/asquare.py @@ -0,0 +1,135 @@ +import os +import dotenv +import json +import asyncio +import uuid +import re + +import get_sq_env + + +def get_set_number(text: str) -> int: + # Remove all letters, keeping only digits and spaces + numbers = re.findall(r"\d{5}", text) + if not numbers: + raise ValueError("No five-digit number found in string") + return int(numbers[0]) + + +SQUARE_TOKEN = get_sq_env.SQUARE_TOKEN +LEGO_CAT_ID = get_sq_env.LEGO_CAT_ID +ENVIRONMENT = get_sq_env.environment + + +from square import AsyncSquare +from square.environment import SquareEnvironment + +client = AsyncSquare(token=SQUARE_TOKEN, environment=ENVIRONMENT) # type: ignore + + +async def get_itm(sku: str): + """ + Asynchronously retrieves item details from Square Catalog using a SKU. + + Args: + sku (str): The SKU or text filter to search for the item. + + Returns: + dict: A dictionary containing the item's name and ID. + + Raises: + IndexError: If no items are found matching the SKU. + Exception: If there is an error communicating with the Square API. + """ + result = await client.catalog.search_items(text_filter=sku, limit=1) + result_json = json.loads(result.model_dump_json()) + name = result_json["items"][0]["item_data"]["name"] + itm_id = result_json["items"][0]["id"] + return {"name": name, "id": itm_id} + + +async def update_itm(lego_set: dict): + """ + Asynchronously updates an item in the Square catalog based on the provided LEGO set information. + Args: + lego_set (dict): A dictionary containing LEGO set details. Expected keys include: + - "sets": List of set dictionaries, each with "name", "number", "barcode" (with "UPC"), and "image" (with "thumbnailURL"). + - "pw_name": The name associated with the LEGO set in another system. + - "set_adjustredprice": The adjusted price for the set. + Returns: + bool: True if the update operation was successful. + Raises: + KeyError: If expected keys are missing from the input dictionary. + Exception: For errors during Square API interaction. + """ + + set_name = lego_set["sets"][0]["name"] + set_number = lego_set["sets"][0]["number"] + set_upc = lego_set["sets"][0]["barcode"]["UPC"] + set_image = lego_set["sets"][0]["image"]["thumbnailURL"] + pw_name = lego_set["pw_name"] + set_adjustedprice = lego_set["set_adjustredprice"] + + result = await client.catalog.search_items(text_filter=set_number, limit=1) + itm_id = json.loads(result.model_dump_json())["items"][0]["id"] + + # await sq.catalog.object.upsert() + return True + + +async def acat2id(cat_id: str): + """ + Return a list of dictionaries containing item IDs and names within the specified category ID. + + Args: + cat_id (str): The category ID to search for items. + + Returns: + list[dict]: A list of dictionaries, each containing: + - 'id': The item's ID + - 'name': The item's name + """ + result = await client.catalog.search( + object_types=["ITEM"], + query={ + "exact_query": { + "attribute_name": "category_id", + "attribute_value": cat_id, + } + }, + ) + result_json = json.loads(result.model_dump_json()) + items = [ + { + "id": obj["id"], + "name": obj.get("item_data", {}).get("name", "Unknown"), + "number": get_set_number(obj.get("item_data", {}).get("name", "")) + } + for obj in result_json.get("objects", []) + ] + + return items + + +async def test() -> None: + task = asyncio.create_task(acat2id(LEGO_CAT_ID)) # type: ignore + await asyncio.sleep(1) # Give the task some time to complete + result = await task + print("OBJECTS IN LEGO CATEGORY:") + for item in result: + print(f"ID: {item['id']}, Name: {item['name']}") + # eturn + # pass + + +""" + result = await sq.catalog.search_items(text_filter="71433", limit=1) + result_json = json.loads(result.model_dump_json()) + name = result_json["items"][0]["item_data"]["name"] + print(f"Item name: {name}")""" + + +if __name__ == "__main__": + print(f"@[DEBUG] ENV:\t{ENVIRONMENT}") + print(f"@[DEBUG] CATID:\t{LEGO_CAT_ID}") + asyncio.run(test()) diff --git a/src/banner.py b/src/banner.py new file mode 100644 index 0000000..c667344 --- /dev/null +++ b/src/banner.py @@ -0,0 +1,20 @@ +import subprocess + + +def generate(text, font="smblock", filter_effect=None, colors=None): + command = ["toilet", "-f", font] + if filter_effect: + command.extend(["-F", filter_effect]) + if colors: + # Toilet uses a specific format for color arguments, e.g., --gay or --metal + # For general colors, you might need to use specific filters or direct color codes. + # This example focuses on filters that inherently use colors. + pass # More complex color handling would be needed for arbitrary colors + + command.append(text) + + try: + result = subprocess.run(command, capture_output=True, text=True, check=True) + return result.stdout + except subprocess.CalledProcessError as e: + return f"Error executing toilet: {e}\n{e.stderr}" diff --git a/src/bricksetapi.py b/src/bricksetapi.py new file mode 100644 index 0000000..a8341b1 --- /dev/null +++ b/src/bricksetapi.py @@ -0,0 +1,60 @@ +import os +import requests +import json +import dotenv + +dotenv.load_dotenv() + +USERNAME = os.getenv("BRICKSET_USERNAME") +PASS = os.getenv("BRICKSET_PASS") +BRICKSET_API_KEY = os.getenv("BRICKSET_API_KEY") + +debug = False # Set to True to enable debug output + + +def login(username, password): + url = "https://brickset.com/api/v3.asmx/login" + headers = {"UserAgent": "Edderkop/0.1"} + params = { + "UserAgent": "Edderkop/0.1", + "username": username, + "password": password, + "apiKey": BRICKSET_API_KEY, + } + response = requests.get(url, params=params, headers=headers) + if debug: + print(f"[@DEBUG] Login response: {response.status_code} - {response.text}\n") + return response.json() + + +def get_set(set_number: int) -> dict: + login_response = login(USERNAME, PASS) + url = f"https://brickset.com/api/v3.asmx/getSets?apiKey={BRICKSET_API_KEY}&userHash={login_response["hash"]}¶ms={{'setNumber' : '{set_number}-1'}}" + response = requests.get(url) + if response.status_code == 200: + data = response.json() + our_set = { + "set_num": data["sets"][0]["number"], + "set_name": data["sets"][0]["name"], + "set_theme": data["sets"][0]["theme"], + "set_image": data["sets"][0]["image"]["thumbnailURL"], + "set_barcode": data["sets"][0]["barcode"]["UPC"], + "set_retailprice": round(data["sets"][0]["LEGOCom"]["US"]["retailPrice"]), + } + # add PW 10% b4 tax + our_set.update( + {"set_adjustredprice": our_set + ((10 / 100) * our_set["set_retailprice"])} + ) + our_set["pw_name"] = ( + f"{our_set['set_num']} - {our_set["set_theme"].upper()} {our_set['set_name']}" + ) + if our_set["set_retailprice"] >= 110: + our_set["requires_manual_price"] = True + return our_set + + elif response.status_code == 500: + raise Exception( + "Internal Server Error: Please check the API key and parameters." + ) + else: + raise Exception("Unexpected response from the API.") diff --git a/src/db_manager.py b/src/db_manager.py new file mode 100644 index 0000000..0c2f35e --- /dev/null +++ b/src/db_manager.py @@ -0,0 +1,221 @@ +import aiosqlite +import logging +from typing import Optional, List, Dict, Any +import json +import os + +logger = logging.getLogger(__name__) + +# Get the absolute path to edderkop.db in the project root +DEFAULT_DB_PATH = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "edderkop.db" +) + +if not os.path.exists(DEFAULT_DB_PATH): + raise FileNotFoundError(f"Database file not found at {DEFAULT_DB_PATH}") + print('Please run "src/scripts/EDDERKOP.SQL" to setup the database.') + os.exit(0) + + +class AsyncSchedulerDB: + """ + Asynchronous SQLite database manager for APScheduler. + Handles job storage, retrieval, and database operations using the main edderkop.db database. + """ + + def __init__(self, db_path: str = DEFAULT_DB_PATH): + """ + Initialize the database manager. + + Args: + db_path (str, optional): Path to the SQLite database file. + Defaults to edderkop.db in project root. + """ + self.db_path = db_path + self._initialized = False + logger.info(f"Using database at: {self.db_path}") + + async def initialize(self): + """Initialize the database schema if it doesn't exist.""" + if self._initialized: + return + + async with aiosqlite.connect(self.db_path) as db: + # Create apscheduler jobs table if it doesn't exist + await db.execute( + """ + CREATE TABLE IF NOT EXISTS apscheduler_jobs ( + id VARCHAR(191) NOT NULL, + next_run_time FLOAT, + job_state BLOB NOT NULL, + PRIMARY KEY (id) + ) + """ + ) + await db.commit() + + self._initialized = True + logger.info(f"Initialized scheduler database at {self.db_path}") + + async def get_all_jobs(self) -> List[Dict[str, Any]]: + """ + Retrieve all jobs from the database. + + Returns: + List[Dict[str, Any]]: List of job dictionaries + """ + await self.initialize() + async with aiosqlite.connect(self.db_path) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + "SELECT * FROM apscheduler_jobs ORDER BY next_run_time" + ) as cursor: + rows = await cursor.fetchall() + return [ + { + "id": row["id"], + "next_run_time": row["next_run_time"], + "job_state": json.loads(row["job_state"]), + } + for row in rows + ] + + async def get_job(self, job_id: str) -> Optional[Dict[str, Any]]: + """ + Retrieve a specific job by ID. + + Args: + job_id (str): The ID of the job to retrieve + + Returns: + Optional[Dict[str, Any]]: Job dictionary if found, None otherwise + """ + await self.initialize() + async with aiosqlite.connect(self.db_path) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + "SELECT * FROM apscheduler_jobs WHERE id = ?", (job_id,) + ) as cursor: + row = await cursor.fetchone() + if row: + return { + "id": row["id"], + "next_run_time": row["next_run_time"], + "job_state": json.loads(row["job_state"]), + } + return None + + async def add_job( + self, job_id: str, next_run_time: float, job_state: Dict[str, Any] + ) -> bool: + """ + Add a new job to the database. + + Args: + job_id (str): Unique identifier for the job + next_run_time (float): Unix timestamp for next scheduled run + job_state (Dict[str, Any]): Job state dictionary + + Returns: + bool: True if successful, False otherwise + """ + await self.initialize() + try: + async with aiosqlite.connect(self.db_path) as db: + await db.execute( + "INSERT INTO apscheduler_jobs (id, next_run_time, job_state) VALUES (?, ?, ?)", + (job_id, next_run_time, json.dumps(job_state)), + ) + await db.commit() + return True + except aiosqlite.IntegrityError: + logger.error(f"Job with ID {job_id} already exists") + return False + except Exception as e: + logger.error(f"Error adding job: {e}") + return False + + async def update_job( + self, job_id: str, next_run_time: float, job_state: Dict[str, Any] + ) -> bool: + """ + Update an existing job in the database. + + Args: + job_id (str): ID of the job to update + next_run_time (float): New next run time + job_state (Dict[str, Any]): Updated job state + + Returns: + bool: True if successful, False if job not found + """ + await self.initialize() + async with aiosqlite.connect(self.db_path) as db: + cursor = await db.execute( + """UPDATE apscheduler_jobs + SET next_run_time = ?, job_state = ? + WHERE id = ?""", + (next_run_time, json.dumps(job_state), job_id), + ) + await db.commit() + return cursor.rowcount > 0 + + async def remove_job(self, job_id: str) -> bool: + """ + Remove a job from the database. + + Args: + job_id (str): ID of the job to remove + + Returns: + bool: True if job was removed, False if not found + """ + await self.initialize() + async with aiosqlite.connect(self.db_path) as db: + cursor = await db.execute( + "DELETE FROM apscheduler_jobs WHERE id = ?", (job_id,) + ) + await db.commit() + return cursor.rowcount > 0 + + async def remove_all_jobs(self) -> int: + """ + Remove all jobs from the database. + + Returns: + int: Number of jobs removed + """ + await self.initialize() + async with aiosqlite.connect(self.db_path) as db: + cursor = await db.execute("DELETE FROM apscheduler_jobs") + await db.commit() + return cursor.rowcount + + async def get_due_jobs(self, now: float) -> List[Dict[str, Any]]: + """ + Get all jobs that are due to run. + + Args: + now (float): Current timestamp + + Returns: + List[Dict[str, Any]]: List of due jobs + """ + await self.initialize() + async with aiosqlite.connect(self.db_path) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + """SELECT * FROM apscheduler_jobs + WHERE next_run_time <= ? + ORDER BY next_run_time""", + (now,), + ) as cursor: + rows = await cursor.fetchall() + return [ + { + "id": row["id"], + "next_run_time": row["next_run_time"], + "job_state": json.loads(row["job_state"]), + } + for row in rows + ] diff --git a/src/ebricksetapi.py b/src/ebricksetapi.py new file mode 100644 index 0000000..a894b62 --- /dev/null +++ b/src/ebricksetapi.py @@ -0,0 +1,116 @@ +import os +import requests +import json +import dotenv +from typing import Dict, Any + +dotenv.load_dotenv() + + +class BricksetAPI: + """Class for interacting with the Brickset API""" + + def __init__(self, debug: bool = False): + self.username = os.getenv("BRICKSET_USERNAME") + self.password = os.getenv("BRICKSET_PASS") + self.api_key = os.getenv("BRICKSET_API_KEY") + self.debug = debug + self.headers = {"UserAgent": "Edderkop/0.1"} + self.user_hash = None + + def login(self) -> Dict[str, Any]: + """Login to Brickset API and get user hash""" + url = "https://brickset.com/api/v3.asmx/login" + params = { + "username": self.username, + "password": self.password, + "apiKey": self.api_key, + } + + response = requests.get(url, params=params, headers=self.headers) + if self.debug: + print(f"[@DEBUG] Login response: {response.status_code}") + print(f"[@DEBUG] Response content: {response.text}") + + try: + data = response.json() + self.user_hash = data.get("hash") + if not self.user_hash: + raise ValueError("No hash received in login response") + return data + except json.JSONDecodeError as e: + print(f"[@ERROR] Failed to decode JSON response: {e}") + print(f"[@DEBUG] Raw response: {response.text}") + raise + + def get_set(self, set_number: str) -> Dict[str, Any]: + """Get LEGO set information by set number""" + if not self.user_hash: + self.login() + + url = "https://brickset.com/api/v3.asmx/getSets" + params = { + "apiKey": self.api_key, + "userHash": self.user_hash, + "params": json.dumps({"setNumber": f"{set_number}-1"}), + } + + response = requests.get(url, params=params, headers=self.headers) + + if self.debug: + print(f"[@DEBUG] Get set response: {response.status_code}") + print(f"[@DEBUG] Response content: {response.text}") + + if response.status_code == 200: + try: + data = response.json() + if not data.get("sets"): + raise ValueError(f"No set data found for {set_number}") + + set_data = data["sets"][0] + our_set = { + "set_num": set_data["number"], + "set_name": set_data["name"], + "set_image": set_data["image"]["thumbnailURL"], + "set_barcode": set_data["barcode"]["UPC"], + "set_retailprice": round(set_data["LEGOCom"]["US"]["retailPrice"]), + } + + # Calculate adjusted price (10% markup) + retail_price = our_set["set_retailprice"] + our_set["set_adjustredprice"] = retail_price + ( + (10 / 100) * retail_price + ) + # Set Playwell inventory name + if our_set["theme"] == "Duplo": + our_set["pw_name"] = ( + f"{our_set['set_num']} - DUPLO {our_set['set_name']}" + ) + else: + our_set["pw_name"] = f"{our_set['set_num']} - {our_set['set_name']}" + + return our_set + + except json.JSONDecodeError as e: + # print(f"[@ERROR] Failed to decode JSON response: {e}") + # print(f"[@DEBUG] Raw response: {response.text}") + raise + except KeyError as e: + # print(f"[@ERROR] Missing expected field in response: {e}") + # print(f"[@DEBUG] Response data: {data}") + raise + elif response.status_code == 500: + raise Exception( + "Internal Server Error: Please check API key and parameters." + ) + else: + raise Exception(f"Unexpected response: {response.status_code}") + + +def example(): + client = BricksetAPI(debug=True) + try: + set_info = client.get_set("10931") + print(set_info) + except Exception as e: + print(f"Error: {e}") diff --git a/src/get_sq_env.py b/src/get_sq_env.py new file mode 100644 index 0000000..6aa237e --- /dev/null +++ b/src/get_sq_env.py @@ -0,0 +1,16 @@ +import os +import dotenv +from square.environment import SquareEnvironment + +dotenv.load_dotenv() + +LEGO_CAT_ID = os.getenv("SQUARE_LEGO_CAT_ID") + +if os.getenv("SQUARE_ENV") == "production": + SQUARE_APP_ID = os.getenv("PROD_SQUARE_APP_ID") + SQUARE_TOKEN = os.getenv("PROD_SQUARE_TOKEN") + environment = SquareEnvironment.PRODUCTION +else: + SQUARE_APP_ID = os.getenv("SANDBOX_SQUARE_APP_ID") + SQUARE_TOKEN = os.getenv("SANDBOX_SQUARE_TOKEN") + environment = SquareEnvironment.SANDBOX diff --git a/src/libsquare.py b/src/libsquare.py new file mode 100644 index 0000000..e87bab6 --- /dev/null +++ b/src/libsquare.py @@ -0,0 +1,66 @@ +import json + +import get_sq_env + +SQUARE_TOKEN = get_sq_env.SQUARE_TOKEN +LEGO_CAT_ID = get_sq_env.LEGO_CAT_ID +ENVIRONMENT = get_sq_env.environment + +from square import Square +from square.environment import SquareEnvironment + + +def get_square_client(): + """Initialize and return a Square client.""" + if not SQUARE_TOKEN: + raise ValueError("SQUARE_TOKEN environment variable is not set.") + square = Square(token=SQUARE_TOKEN, environment=ENVIRONMENT) # type: ignore + return square + + +client = get_square_client() + + +def get_itm(item_id: str): + """Fetch an item from Square by its ID.""" + result = client.catalog.search_items(text_filter=item_id, limit=1) + result_json = json.loads(result.model_dump_json()) + ids = { + "item_id": result_json["items"][0]["id"], + "vari_id": result_json["items"][0]["item_data"]["variations"][0][ + "item_variation_data" + ]["item_id"], + } + item = { + "set_name": result_json["items"][0]["item_data"]["name"], + "set_price": result_json["items"][0]["item_data"]["variations"][0][ + "item_variation_data" + ]["price_money"], + "set_barcode": result_json["items"][0]["item_data"]["variations"][0][ + "item_variation_data" + ]["sku"], + } + return [ids, item] + + +def cat2id(cat_id: str): + """Return a list of item IDs within the specified category ID.""" + result = client.catalog.search( + object_types=["ITEM"], + query={ + "exact_query": { + "attribute_name": "category_id", + "attribute_value": cat_id, + } + }, + ) + result_json = json.loads(result.model_dump_json()) + item_ids = [obj["id"] for obj in result_json.get("objects", [])] + return item_ids + + +def test() -> None: + # all_lego_sets = cat2id(SQUARE_LEGO_CAT_ID) # type: ignore + # result = get_itm("71433") + # print(result) + pass diff --git a/src/scripts/edderkop.sql b/src/scripts/edderkop.sql new file mode 100644 index 0000000..27c52d0 --- /dev/null +++ b/src/scripts/edderkop.sql @@ -0,0 +1,7 @@ +CREATE TABLE "REVIEW" ( + "sq_object" TEXT NOT NULL UNIQUE, + "time_added" INTEGER, + "set_number" INTEGER, + "time_modified" INTEGER, + PRIMARY KEY("sq_object") +) \ No newline at end of file diff --git a/src/server.py b/src/server.py new file mode 100644 index 0000000..50cc72d --- /dev/null +++ b/src/server.py @@ -0,0 +1,113 @@ +import asyncio +import aiohttp.web +import uuid +import os +from base64 import b64decode +import dotenv + +dotenv.load_dotenv() +SQUARE_LEGO_CAT_ID = os.environ.get("SQUARE_LEGO_CAT_ID") + +from asyncio_throttle.throttler import Throttler + +import asquare as sq +import abricksetapi as alego + + +def valid_uuid(val): + try: + uuid.UUID(str(val)) + return True + except ValueError: + return False + + +async def fullsync(): + """' + full_db = await sq.acat2id(SQUARE_LEGO_CAT_ID) + for l_set in full_db: + l_setinfo = await alego.throttled_get_set(l_set) + await sq.update_itm(l_set, l_setinfo)""" + + +async def stop(): + pass + + +async def priceonly(): + pass + + +async def handle_post(request): + + valid_cmd = ["RUNFULLSYNC", "STOP", "RUNPRICEONLY"] + + async def handle_cmd(cmd: str): + if cmd not in valid_cmd: + return False + else: + return True + + idp_key = request.headers.get("Idempotency-Key") + user_agent = request.headers.get("User-Agent") + + if not idp_key and not valid_uuid(idp_key) and user_agent != "Kopling/0.1": + # 412 Precondition Failed + # The server does not meet one of the preconditions that the requester put on the request header fields. + return aiohttp.web.Response(status=412, text="412 Headers rejected!") + + else: + data = await request.post() + reqdata = data.get(b64decode("CMD")) + + if await handle_cmd(reqdata): + match reqdata: + case "RUNFULLSYNC": + print("Running full sync...") + await fullsync() + return True + case "STOP": + await stop() + return True + case "RUNPRICEONLY": + await priceonly() + return True + else: + return aiohttp.web.Response(status=400, text="400 Bad Request.") + result = {"message": f"Edderkop/0.1 received command: {reqdata}"} + return aiohttp.web.json_response(status=200, data=result) + + +app = aiohttp.web.Application() +app.router.add_post("/", handle_post) + +import subprocess + + +def generate_toilet_banner(text, font="smblock", filter_effect=None, colors=None): + command = ["toilet", "-f", font] + if filter_effect: + command.extend(["-F", filter_effect]) + if colors: + # Toilet uses a specific format for color arguments, e.g., --gay or --metal + # For general colors, you might need to use specific filters or direct color codes. + # This example focuses on filters that inherently use colors. + pass # More complex color handling would be needed for arbitrary colors + + command.append(text) + + try: + result = subprocess.run(command, capture_output=True, text=True, check=True) + return result.stdout + except subprocess.CalledProcessError as e: + return f"Error executing toilet: {e}\n{e.stderr}" + + +async def main(): + throttler = Throttler(rate_limit=500, period=60) # 500 requests per minute + + +if __name__ == "__main__": + print(generate_toilet_banner("Edderkop v0.1")) + print("Built for PLAY-WELL TEKNOLOGIES by contact@traps.moe\n\n") + aiohttp.web.run_app(app, port=7432) diff --git a/src/test.py b/src/test.py new file mode 100644 index 0000000..73fb108 --- /dev/null +++ b/src/test.py @@ -0,0 +1,6 @@ +import src.libsquare as sq +import src.bricksetapi as lego + +oursq = sq.get_itm("71417") +ourlego = lego.get_set(71417) +print(f"{oursq}\n\n{ourlego}\n\n") diff --git a/src/test_client.py b/src/test_client.py new file mode 100644 index 0000000..f5725e3 --- /dev/null +++ b/src/test_client.py @@ -0,0 +1,39 @@ +import asyncio +import aiohttp +from uuid import uuid4 +from base64 import b64encode +import os + + +async def idp_request(cmd: str): + headers = { + "Idempotency-Key": f"{uuid4()}", + "Content-Type": "application/json", + "User-Agent": "Kopling/0.12", + } + print(f"[@DEBUG] Idempotency-Key: {headers['Idempotency-Key']}") + + enc_cmd = b64encode(f"{cmd}".encode("utf-8")).decode("utf-8") + print("[@DEBUG] Encoded command:", enc_cmd) + print("[@DEBUG] Sending request..") + try: + async with aiohttp.ClientSession() as session: + async with session.post( + "http://localhost:7432", # Added http:// protocol + headers=headers, + json={"CMD": enc_cmd}, # Changed to json instead of data + ) as resp: + print(f"[@DEBUG] Response status: {resp.status}") + print(f"[@DEBUG] Response body: {await resp.text()}") + except Exception as e: + print(f"\nRequest failed: {e}") + os._exit(-1) + + +async def main(): + # Await the task instead of just creating it + await idp_request("RUNUPDATE") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/test_server.py b/src/test_server.py new file mode 100644 index 0000000..bece01b --- /dev/null +++ b/src/test_server.py @@ -0,0 +1,121 @@ +import asyncio +import json +from aiohttp import web +from base64 import b64decode +from typing import Dict, Any +import uuid +from time import time +import dotenv +import os +import re +import logging +from pathlib import Path + +Path("./logs/").mkdir(parents=True, exist_ok=True) + +import banner +import get_sq_env +import asquare as sq +from abricksetapi import AsyncBricksetAPI + +lego = AsyncBricksetAPI(debug=True) + +dotenv.load_dotenv() +SQUARE_LEGO_CAT_ID = os.environ.get("SQUARE_LEGO_CAT_ID") + + +from asyncio_throttle.throttler import Throttler + +logger = logging.getLogger("aiohttp.access") +logging.basicConfig( + filename="./logs/edderkop.log", encoding="utf-8", level=logging.DEBUG +) + +UA = re.compile(r"^kopling\/?", re.IGNORECASE) + + +async def run_update(): + set_list = await sq.acat2id(SQUARE_LEGO_CAT_ID) + for l_set in set_list: + print(f"[@DEBUG]{l_set}") + l_setinfo = await lego.throttled_get_set(l_set["number"]) + print(f"[@DEBUG]{l_setinfo}") + # await sq.update_itm(l_set, l_setinfo) + + +def valid_uuid(val): + try: + uuid.UUID(str(val)) + return True + except ValueError: + return False + + +async def request_handler(request: web.Request) -> web.Response: + """Handle incoming POST requests with command processing""" + try: + # Verify request method + if request.method != "POST": + logger.error( + f"{time()}\tInvalid request method from %s at ", request.remote + ) + return web.Response(status=405, text="Method not allowed. Use POST.") + + # Get JSON data from request + try: + data = await request.json() + except json.JSONDecodeError: + logger.error("Invalid JSON from %s", request.remote) + return web.Response( + status=400, text="{time()}\tInvalid JSON in request body" + ) + # Validate Idempotency-Key header + if valid_uuid(request.headers.get("Idempotency-Key")) is False: + logger.error("{time()}\tInvalid Idempotency-Key from %s", request.remote) + return web.Response(status=412, text="412 Headers rejected!") + # Validate User-Agent header + if not re.match(UA, str(request.headers.get("User-Agent"))): + logger.error("{time()}\tInvalid User-Agent from %s", request.remote) + return web.Response(status=412, text="412 Headers rejected!") + # Validate command exists + if "CMD" not in data: + logger.error("{time()}\tMissing CMD field from %s", request.remote) + return web.Response(status=400, text="Missing CMD field in request") + + # Decode and process command + try: + decoded_cmd = b64decode(data["CMD"]).decode("utf-8") + print( + f"""At: {time()}\nReceived command: {decoded_cmd}\nUser-Agent: {request.headers.get('User-Agent')}\nIdempotency-Key: {request.headers.get('Idempotency-Key')}""" + ) + logging.info( + f"{time()}\tCommand: {decoded_cmd}\tFrom: {request.remote}\tUA: {request.headers.get('User-Agent')}\tIdempotency-Key: {request.headers.get('Idempotency-Key')}" + ) + + # Process the command + if decoded_cmd == "RUNUPDATE": + # Add your command handling logic here + await run_update() + response_data = {"status": "success", "message": "Update started"} + logging.info(f"{time()}\tUpdate command processed successfully") + + return web.json_response(response_data) + else: + return web.Response(status=400, text=f"Unknown command: {decoded_cmd}") + + except Exception as e: + print(f"Error processing command: {str(e)}") + return web.Response(status=500, text=f"Server error: {str(e)}") + + except Exception as e: + print(f"Request handler error: {str(e)}") + return web.Response(status=500, text="Internal server error") + + +app = web.Application() +app.router.add_post("/", request_handler) + +if __name__ == "__main__": + print(banner.generate("Edderkop v0.1")) + print("Built for PLAY-WELL TEKNOLOGIES by contact@traps.moe\n\n") + web.run_app(app, host="localhost", port=7432) diff --git a/src/throttler_example.py b/src/throttler_example.py new file mode 100644 index 0000000..70ba7e3 --- /dev/null +++ b/src/throttler_example.py @@ -0,0 +1,15 @@ +import aiohttp +import asyncio + +from asyncio_throttle.throttler import Throttler + +throttler = Throttler(rate_limit=500, period=60) # 500 requests per minute + + +async def worker(throttler, session): + while True: + async with throttler: + async with session.get("http://example.com") as resp: + do_some_job_with(await resp.text()) + + await asyncio.sleep(0.05) diff --git a/tests/test_abricksetapi.py b/tests/test_abricksetapi.py new file mode 100644 index 0000000..bcec05e --- /dev/null +++ b/tests/test_abricksetapi.py @@ -0,0 +1,62 @@ +import pytest +import asyncio +from unittest.mock import patch, AsyncMock +from ..src.abricksetapi import get_set + + +@pytest.mark.asyncio +@patch("abricksetapi.login", new_callable=AsyncMock) +@patch("abricksetapi.aiohttp.ClientSession") +async def test_get_set_success(mock_session_cls, mock_login): + # Mock login response + mock_login.return_value = {"hash": "fakehash"} + + # Prepare mock response data + mock_response_data = { + "sets": [ + { + "number": "10349-1", + "name": "Icons: Modular Jazz Club", + "image": {"thumbnailURL": "http://image.url"}, + "barcode": {"UPC": "123456789012"}, + "LEGOCom": {"US": {"retailPrice": 229.99}}, + } + ] + } + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.read.return_value = asyncio.Future() + mock_response.read.return_value.set_result( + str.encode(str(mock_response_data).replace("'", '"')) + ) + + # Mock session.get to return our mock_response + mock_session = AsyncMock() + mock_session.get.return_value.__aenter__.return_value = mock_response + mock_session_cls.return_value.__aenter__.return_value = mock_session + + result = await get_set("10349") + assert result["set_num"] == "10349-1" + assert result["set_name"] == "Icons: Modular Jazz Club" + assert result["set_image"] == "http://image.url" + assert result["set_barcode"] == "123456789012" + assert result["set_retailprice"] == 230 + assert "set_adjustredprice" in result + + +@pytest.mark.asyncio +@patch("abricksetapi.login", new_callable=AsyncMock) +@patch("abricksetapi.aiohttp.ClientSession") +async def test_get_set_api_error(mock_session_cls, mock_login): + mock_login.return_value = {"hash": "fakehash"} + mock_response = AsyncMock() + mock_response.status = 500 + mock_response.read.return_value = asyncio.Future() + mock_response.read.return_value.set_result(b"{}") + mock_session = AsyncMock() + mock_session.get.return_value.__aenter__.return_value = mock_response + mock_session_cls.return_value.__aenter__.return_value = mock_session + + with pytest.raises(Exception) as excinfo: + await get_set("badset") + assert "Internal Server Error" in str(excinfo.value) diff --git a/tests/test_asquare.py b/tests/test_asquare.py new file mode 100644 index 0000000..88f712e --- /dev/null +++ b/tests/test_asquare.py @@ -0,0 +1,33 @@ +import pytest +import asyncio +from unittest.mock import AsyncMock, patch +from asquare import acat2id + + +@pytest.mark.asyncio +async def test_acat2id_returns_item_ids(): + mock_result = AsyncMock() + mock_result.model_dump_json.return_value = ( + '{"objects": [{"id": "item1"}, {"id": "item2"}]}' + ) + with patch("asquare.client.catalog.search", return_value=mock_result): + item_ids = await acat2id("test_cat_id") + assert item_ids == ["item1", "item2"] + + +@pytest.mark.asyncio +async def test_acat2id_empty_objects(): + mock_result = AsyncMock() + mock_result.model_dump_json.return_value = '{"objects": []}' + with patch("asquare.client.catalog.search", return_value=mock_result): + item_ids = await acat2id("empty_cat_id") + assert item_ids == [] + + +@pytest.mark.asyncio +async def test_acat2id_no_objects_key(): + mock_result = AsyncMock() + mock_result.model_dump_json.return_value = "{}" + with patch("asquare.client.catalog.search", return_value=mock_result): + item_ids = await acat2id("no_objects_cat_id") + assert item_ids == []