ai-parrot 0.17.2__cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentui/.prettierrc +15 -0
- agentui/QUICKSTART.md +272 -0
- agentui/README.md +59 -0
- agentui/env.example +16 -0
- agentui/jsconfig.json +14 -0
- agentui/package-lock.json +4242 -0
- agentui/package.json +34 -0
- agentui/scripts/postinstall/apply-patches.mjs +260 -0
- agentui/src/app.css +61 -0
- agentui/src/app.d.ts +13 -0
- agentui/src/app.html +12 -0
- agentui/src/components/LoadingSpinner.svelte +64 -0
- agentui/src/components/ThemeSwitcher.svelte +159 -0
- agentui/src/components/index.js +4 -0
- agentui/src/lib/api/bots.ts +60 -0
- agentui/src/lib/api/chat.ts +22 -0
- agentui/src/lib/api/http.ts +25 -0
- agentui/src/lib/components/BotCard.svelte +33 -0
- agentui/src/lib/components/ChatBubble.svelte +63 -0
- agentui/src/lib/components/Toast.svelte +21 -0
- agentui/src/lib/config.ts +20 -0
- agentui/src/lib/stores/auth.svelte.ts +73 -0
- agentui/src/lib/stores/theme.svelte.js +64 -0
- agentui/src/lib/stores/toast.svelte.ts +31 -0
- agentui/src/lib/utils/conversation.ts +39 -0
- agentui/src/routes/+layout.svelte +20 -0
- agentui/src/routes/+page.svelte +232 -0
- agentui/src/routes/login/+page.svelte +200 -0
- agentui/src/routes/talk/[agentId]/+page.svelte +297 -0
- agentui/src/routes/talk/[agentId]/+page.ts +7 -0
- agentui/static/README.md +1 -0
- agentui/svelte.config.js +11 -0
- agentui/tailwind.config.ts +53 -0
- agentui/tsconfig.json +3 -0
- agentui/vite.config.ts +10 -0
- ai_parrot-0.17.2.dist-info/METADATA +472 -0
- ai_parrot-0.17.2.dist-info/RECORD +535 -0
- ai_parrot-0.17.2.dist-info/WHEEL +6 -0
- ai_parrot-0.17.2.dist-info/entry_points.txt +2 -0
- ai_parrot-0.17.2.dist-info/licenses/LICENSE +21 -0
- ai_parrot-0.17.2.dist-info/top_level.txt +6 -0
- crew-builder/.prettierrc +15 -0
- crew-builder/QUICKSTART.md +259 -0
- crew-builder/README.md +113 -0
- crew-builder/env.example +17 -0
- crew-builder/jsconfig.json +14 -0
- crew-builder/package-lock.json +4182 -0
- crew-builder/package.json +37 -0
- crew-builder/scripts/postinstall/apply-patches.mjs +260 -0
- crew-builder/src/app.css +62 -0
- crew-builder/src/app.d.ts +13 -0
- crew-builder/src/app.html +12 -0
- crew-builder/src/components/LoadingSpinner.svelte +64 -0
- crew-builder/src/components/ThemeSwitcher.svelte +149 -0
- crew-builder/src/components/index.js +9 -0
- crew-builder/src/lib/api/bots.ts +60 -0
- crew-builder/src/lib/api/chat.ts +80 -0
- crew-builder/src/lib/api/client.ts +56 -0
- crew-builder/src/lib/api/crew/crew.ts +136 -0
- crew-builder/src/lib/api/index.ts +5 -0
- crew-builder/src/lib/api/o365/auth.ts +65 -0
- crew-builder/src/lib/auth/auth.ts +54 -0
- crew-builder/src/lib/components/AgentNode.svelte +43 -0
- crew-builder/src/lib/components/BotCard.svelte +33 -0
- crew-builder/src/lib/components/ChatBubble.svelte +67 -0
- crew-builder/src/lib/components/ConfigPanel.svelte +278 -0
- crew-builder/src/lib/components/JsonTreeNode.svelte +76 -0
- crew-builder/src/lib/components/JsonViewer.svelte +24 -0
- crew-builder/src/lib/components/MarkdownEditor.svelte +48 -0
- crew-builder/src/lib/components/ThemeToggle.svelte +36 -0
- crew-builder/src/lib/components/Toast.svelte +67 -0
- crew-builder/src/lib/components/Toolbar.svelte +157 -0
- crew-builder/src/lib/components/index.ts +10 -0
- crew-builder/src/lib/config.ts +8 -0
- crew-builder/src/lib/stores/auth.svelte.ts +228 -0
- crew-builder/src/lib/stores/crewStore.ts +369 -0
- crew-builder/src/lib/stores/theme.svelte.js +145 -0
- crew-builder/src/lib/stores/toast.svelte.ts +69 -0
- crew-builder/src/lib/utils/conversation.ts +39 -0
- crew-builder/src/lib/utils/markdown.ts +122 -0
- crew-builder/src/lib/utils/talkHistory.ts +47 -0
- crew-builder/src/routes/+layout.svelte +20 -0
- crew-builder/src/routes/+page.svelte +539 -0
- crew-builder/src/routes/agents/+page.svelte +247 -0
- crew-builder/src/routes/agents/[agentId]/+page.svelte +288 -0
- crew-builder/src/routes/agents/[agentId]/+page.ts +7 -0
- crew-builder/src/routes/builder/+page.svelte +204 -0
- crew-builder/src/routes/crew/ask/+page.svelte +1052 -0
- crew-builder/src/routes/crew/ask/+page.ts +1 -0
- crew-builder/src/routes/integrations/o365/+page.svelte +304 -0
- crew-builder/src/routes/login/+page.svelte +197 -0
- crew-builder/src/routes/talk/[agentId]/+page.svelte +487 -0
- crew-builder/src/routes/talk/[agentId]/+page.ts +7 -0
- crew-builder/static/README.md +1 -0
- crew-builder/svelte.config.js +11 -0
- crew-builder/tailwind.config.ts +53 -0
- crew-builder/tsconfig.json +3 -0
- crew-builder/vite.config.ts +10 -0
- mcp_servers/calculator_server.py +309 -0
- parrot/__init__.py +27 -0
- parrot/__pycache__/__init__.cpython-310.pyc +0 -0
- parrot/__pycache__/version.cpython-310.pyc +0 -0
- parrot/_version.py +34 -0
- parrot/a2a/__init__.py +48 -0
- parrot/a2a/client.py +658 -0
- parrot/a2a/discovery.py +89 -0
- parrot/a2a/mixin.py +257 -0
- parrot/a2a/models.py +376 -0
- parrot/a2a/server.py +770 -0
- parrot/agents/__init__.py +29 -0
- parrot/bots/__init__.py +12 -0
- parrot/bots/a2a_agent.py +19 -0
- parrot/bots/abstract.py +3139 -0
- parrot/bots/agent.py +1129 -0
- parrot/bots/basic.py +9 -0
- parrot/bots/chatbot.py +669 -0
- parrot/bots/data.py +1618 -0
- parrot/bots/database/__init__.py +5 -0
- parrot/bots/database/abstract.py +3071 -0
- parrot/bots/database/cache.py +286 -0
- parrot/bots/database/models.py +468 -0
- parrot/bots/database/prompts.py +154 -0
- parrot/bots/database/retries.py +98 -0
- parrot/bots/database/router.py +269 -0
- parrot/bots/database/sql.py +41 -0
- parrot/bots/db/__init__.py +6 -0
- parrot/bots/db/abstract.py +556 -0
- parrot/bots/db/bigquery.py +602 -0
- parrot/bots/db/cache.py +85 -0
- parrot/bots/db/documentdb.py +668 -0
- parrot/bots/db/elastic.py +1014 -0
- parrot/bots/db/influx.py +898 -0
- parrot/bots/db/mock.py +96 -0
- parrot/bots/db/multi.py +783 -0
- parrot/bots/db/prompts.py +185 -0
- parrot/bots/db/sql.py +1255 -0
- parrot/bots/db/tools.py +212 -0
- parrot/bots/document.py +680 -0
- parrot/bots/hrbot.py +15 -0
- parrot/bots/kb.py +170 -0
- parrot/bots/mcp.py +36 -0
- parrot/bots/orchestration/README.md +463 -0
- parrot/bots/orchestration/__init__.py +1 -0
- parrot/bots/orchestration/agent.py +155 -0
- parrot/bots/orchestration/crew.py +3330 -0
- parrot/bots/orchestration/fsm.py +1179 -0
- parrot/bots/orchestration/hr.py +434 -0
- parrot/bots/orchestration/storage/__init__.py +4 -0
- parrot/bots/orchestration/storage/memory.py +100 -0
- parrot/bots/orchestration/storage/mixin.py +119 -0
- parrot/bots/orchestration/verify.py +202 -0
- parrot/bots/product.py +204 -0
- parrot/bots/prompts/__init__.py +96 -0
- parrot/bots/prompts/agents.py +155 -0
- parrot/bots/prompts/data.py +216 -0
- parrot/bots/prompts/output_generation.py +8 -0
- parrot/bots/scraper/__init__.py +3 -0
- parrot/bots/scraper/models.py +122 -0
- parrot/bots/scraper/scraper.py +1173 -0
- parrot/bots/scraper/templates.py +115 -0
- parrot/bots/stores/__init__.py +5 -0
- parrot/bots/stores/local.py +172 -0
- parrot/bots/webdev.py +81 -0
- parrot/cli.py +17 -0
- parrot/clients/__init__.py +16 -0
- parrot/clients/base.py +1491 -0
- parrot/clients/claude.py +1191 -0
- parrot/clients/factory.py +129 -0
- parrot/clients/google.py +4567 -0
- parrot/clients/gpt.py +1975 -0
- parrot/clients/grok.py +432 -0
- parrot/clients/groq.py +986 -0
- parrot/clients/hf.py +582 -0
- parrot/clients/models.py +18 -0
- parrot/conf.py +395 -0
- parrot/embeddings/__init__.py +9 -0
- parrot/embeddings/base.py +157 -0
- parrot/embeddings/google.py +98 -0
- parrot/embeddings/huggingface.py +74 -0
- parrot/embeddings/openai.py +84 -0
- parrot/embeddings/processor.py +88 -0
- parrot/exceptions.c +13868 -0
- parrot/exceptions.cpython-310-x86_64-linux-gnu.so +0 -0
- parrot/exceptions.pxd +22 -0
- parrot/exceptions.pxi +15 -0
- parrot/exceptions.pyx +44 -0
- parrot/generators/__init__.py +29 -0
- parrot/generators/base.py +200 -0
- parrot/generators/html.py +293 -0
- parrot/generators/react.py +205 -0
- parrot/generators/streamlit.py +203 -0
- parrot/generators/template.py +105 -0
- parrot/handlers/__init__.py +4 -0
- parrot/handlers/agent.py +861 -0
- parrot/handlers/agents/__init__.py +1 -0
- parrot/handlers/agents/abstract.py +900 -0
- parrot/handlers/bots.py +338 -0
- parrot/handlers/chat.py +915 -0
- parrot/handlers/creation.sql +192 -0
- parrot/handlers/crew/ARCHITECTURE.md +362 -0
- parrot/handlers/crew/README_BOTMANAGER_PERSISTENCE.md +303 -0
- parrot/handlers/crew/README_REDIS_PERSISTENCE.md +366 -0
- parrot/handlers/crew/__init__.py +0 -0
- parrot/handlers/crew/handler.py +801 -0
- parrot/handlers/crew/models.py +229 -0
- parrot/handlers/crew/redis_persistence.py +523 -0
- parrot/handlers/jobs/__init__.py +10 -0
- parrot/handlers/jobs/job.py +384 -0
- parrot/handlers/jobs/mixin.py +627 -0
- parrot/handlers/jobs/models.py +115 -0
- parrot/handlers/jobs/worker.py +31 -0
- parrot/handlers/models.py +596 -0
- parrot/handlers/o365_auth.py +105 -0
- parrot/handlers/stream.py +337 -0
- parrot/interfaces/__init__.py +6 -0
- parrot/interfaces/aws.py +143 -0
- parrot/interfaces/credentials.py +113 -0
- parrot/interfaces/database.py +27 -0
- parrot/interfaces/google.py +1123 -0
- parrot/interfaces/hierarchy.py +1227 -0
- parrot/interfaces/http.py +651 -0
- parrot/interfaces/images/__init__.py +0 -0
- parrot/interfaces/images/plugins/__init__.py +24 -0
- parrot/interfaces/images/plugins/abstract.py +58 -0
- parrot/interfaces/images/plugins/analisys.py +148 -0
- parrot/interfaces/images/plugins/classify.py +150 -0
- parrot/interfaces/images/plugins/classifybase.py +182 -0
- parrot/interfaces/images/plugins/detect.py +150 -0
- parrot/interfaces/images/plugins/exif.py +1103 -0
- parrot/interfaces/images/plugins/hash.py +52 -0
- parrot/interfaces/images/plugins/vision.py +104 -0
- parrot/interfaces/images/plugins/yolo.py +66 -0
- parrot/interfaces/images/plugins/zerodetect.py +197 -0
- parrot/interfaces/o365.py +978 -0
- parrot/interfaces/onedrive.py +822 -0
- parrot/interfaces/sharepoint.py +1435 -0
- parrot/interfaces/soap.py +257 -0
- parrot/loaders/__init__.py +8 -0
- parrot/loaders/abstract.py +1131 -0
- parrot/loaders/audio.py +199 -0
- parrot/loaders/basepdf.py +53 -0
- parrot/loaders/basevideo.py +1568 -0
- parrot/loaders/csv.py +409 -0
- parrot/loaders/docx.py +116 -0
- parrot/loaders/epubloader.py +316 -0
- parrot/loaders/excel.py +199 -0
- parrot/loaders/factory.py +55 -0
- parrot/loaders/files/__init__.py +0 -0
- parrot/loaders/files/abstract.py +39 -0
- parrot/loaders/files/html.py +26 -0
- parrot/loaders/files/text.py +63 -0
- parrot/loaders/html.py +152 -0
- parrot/loaders/markdown.py +442 -0
- parrot/loaders/pdf.py +373 -0
- parrot/loaders/pdfmark.py +320 -0
- parrot/loaders/pdftables.py +506 -0
- parrot/loaders/ppt.py +476 -0
- parrot/loaders/qa.py +63 -0
- parrot/loaders/splitters/__init__.py +10 -0
- parrot/loaders/splitters/base.py +138 -0
- parrot/loaders/splitters/md.py +228 -0
- parrot/loaders/splitters/token.py +143 -0
- parrot/loaders/txt.py +26 -0
- parrot/loaders/video.py +89 -0
- parrot/loaders/videolocal.py +218 -0
- parrot/loaders/videounderstanding.py +377 -0
- parrot/loaders/vimeo.py +167 -0
- parrot/loaders/web.py +599 -0
- parrot/loaders/youtube.py +504 -0
- parrot/manager/__init__.py +5 -0
- parrot/manager/manager.py +1030 -0
- parrot/mcp/__init__.py +28 -0
- parrot/mcp/adapter.py +105 -0
- parrot/mcp/cli.py +174 -0
- parrot/mcp/client.py +119 -0
- parrot/mcp/config.py +75 -0
- parrot/mcp/integration.py +842 -0
- parrot/mcp/oauth.py +933 -0
- parrot/mcp/server.py +225 -0
- parrot/mcp/transports/__init__.py +3 -0
- parrot/mcp/transports/base.py +279 -0
- parrot/mcp/transports/grpc_session.py +163 -0
- parrot/mcp/transports/http.py +312 -0
- parrot/mcp/transports/mcp.proto +108 -0
- parrot/mcp/transports/quic.py +1082 -0
- parrot/mcp/transports/sse.py +330 -0
- parrot/mcp/transports/stdio.py +309 -0
- parrot/mcp/transports/unix.py +395 -0
- parrot/mcp/transports/websocket.py +547 -0
- parrot/memory/__init__.py +16 -0
- parrot/memory/abstract.py +209 -0
- parrot/memory/agent.py +32 -0
- parrot/memory/cache.py +175 -0
- parrot/memory/core.py +555 -0
- parrot/memory/file.py +153 -0
- parrot/memory/mem.py +131 -0
- parrot/memory/redis.py +613 -0
- parrot/models/__init__.py +46 -0
- parrot/models/basic.py +118 -0
- parrot/models/compliance.py +208 -0
- parrot/models/crew.py +395 -0
- parrot/models/detections.py +654 -0
- parrot/models/generation.py +85 -0
- parrot/models/google.py +223 -0
- parrot/models/groq.py +23 -0
- parrot/models/openai.py +30 -0
- parrot/models/outputs.py +285 -0
- parrot/models/responses.py +938 -0
- parrot/notifications/__init__.py +743 -0
- parrot/openapi/__init__.py +3 -0
- parrot/openapi/components.yaml +641 -0
- parrot/openapi/config.py +322 -0
- parrot/outputs/__init__.py +32 -0
- parrot/outputs/formats/__init__.py +108 -0
- parrot/outputs/formats/altair.py +359 -0
- parrot/outputs/formats/application.py +122 -0
- parrot/outputs/formats/base.py +351 -0
- parrot/outputs/formats/bokeh.py +356 -0
- parrot/outputs/formats/card.py +424 -0
- parrot/outputs/formats/chart.py +436 -0
- parrot/outputs/formats/d3.py +255 -0
- parrot/outputs/formats/echarts.py +310 -0
- parrot/outputs/formats/generators/__init__.py +0 -0
- parrot/outputs/formats/generators/abstract.py +61 -0
- parrot/outputs/formats/generators/panel.py +145 -0
- parrot/outputs/formats/generators/streamlit.py +86 -0
- parrot/outputs/formats/generators/terminal.py +63 -0
- parrot/outputs/formats/holoviews.py +310 -0
- parrot/outputs/formats/html.py +147 -0
- parrot/outputs/formats/jinja2.py +46 -0
- parrot/outputs/formats/json.py +87 -0
- parrot/outputs/formats/map.py +933 -0
- parrot/outputs/formats/markdown.py +172 -0
- parrot/outputs/formats/matplotlib.py +237 -0
- parrot/outputs/formats/mixins/__init__.py +0 -0
- parrot/outputs/formats/mixins/emaps.py +855 -0
- parrot/outputs/formats/plotly.py +341 -0
- parrot/outputs/formats/seaborn.py +310 -0
- parrot/outputs/formats/table.py +397 -0
- parrot/outputs/formats/template_report.py +138 -0
- parrot/outputs/formats/yaml.py +125 -0
- parrot/outputs/formatter.py +152 -0
- parrot/outputs/templates/__init__.py +95 -0
- parrot/pipelines/__init__.py +0 -0
- parrot/pipelines/abstract.py +210 -0
- parrot/pipelines/detector.py +124 -0
- parrot/pipelines/models.py +90 -0
- parrot/pipelines/planogram.py +3002 -0
- parrot/pipelines/table.sql +97 -0
- parrot/plugins/__init__.py +106 -0
- parrot/plugins/importer.py +80 -0
- parrot/py.typed +0 -0
- parrot/registry/__init__.py +18 -0
- parrot/registry/registry.py +594 -0
- parrot/scheduler/__init__.py +1189 -0
- parrot/scheduler/models.py +60 -0
- parrot/security/__init__.py +16 -0
- parrot/security/prompt_injection.py +268 -0
- parrot/security/security_events.sql +25 -0
- parrot/services/__init__.py +1 -0
- parrot/services/mcp/__init__.py +8 -0
- parrot/services/mcp/config.py +13 -0
- parrot/services/mcp/server.py +295 -0
- parrot/services/o365_remote_auth.py +235 -0
- parrot/stores/__init__.py +7 -0
- parrot/stores/abstract.py +352 -0
- parrot/stores/arango.py +1090 -0
- parrot/stores/bigquery.py +1377 -0
- parrot/stores/cache.py +106 -0
- parrot/stores/empty.py +10 -0
- parrot/stores/faiss_store.py +1157 -0
- parrot/stores/kb/__init__.py +9 -0
- parrot/stores/kb/abstract.py +68 -0
- parrot/stores/kb/cache.py +165 -0
- parrot/stores/kb/doc.py +325 -0
- parrot/stores/kb/hierarchy.py +346 -0
- parrot/stores/kb/local.py +457 -0
- parrot/stores/kb/prompt.py +28 -0
- parrot/stores/kb/redis.py +659 -0
- parrot/stores/kb/store.py +115 -0
- parrot/stores/kb/user.py +374 -0
- parrot/stores/models.py +59 -0
- parrot/stores/pgvector.py +3 -0
- parrot/stores/postgres.py +2853 -0
- parrot/stores/utils/__init__.py +0 -0
- parrot/stores/utils/chunking.py +197 -0
- parrot/telemetry/__init__.py +3 -0
- parrot/telemetry/mixin.py +111 -0
- parrot/template/__init__.py +3 -0
- parrot/template/engine.py +259 -0
- parrot/tools/__init__.py +23 -0
- parrot/tools/abstract.py +644 -0
- parrot/tools/agent.py +363 -0
- parrot/tools/arangodbsearch.py +537 -0
- parrot/tools/arxiv_tool.py +188 -0
- parrot/tools/calculator/__init__.py +3 -0
- parrot/tools/calculator/operations/__init__.py +38 -0
- parrot/tools/calculator/operations/calculus.py +80 -0
- parrot/tools/calculator/operations/statistics.py +76 -0
- parrot/tools/calculator/tool.py +150 -0
- parrot/tools/cloudwatch.py +988 -0
- parrot/tools/codeinterpreter/__init__.py +127 -0
- parrot/tools/codeinterpreter/executor.py +371 -0
- parrot/tools/codeinterpreter/internals.py +473 -0
- parrot/tools/codeinterpreter/models.py +643 -0
- parrot/tools/codeinterpreter/prompts.py +224 -0
- parrot/tools/codeinterpreter/tool.py +664 -0
- parrot/tools/company_info/__init__.py +6 -0
- parrot/tools/company_info/tool.py +1138 -0
- parrot/tools/correlationanalysis.py +437 -0
- parrot/tools/database/abstract.py +286 -0
- parrot/tools/database/bq.py +115 -0
- parrot/tools/database/cache.py +284 -0
- parrot/tools/database/models.py +95 -0
- parrot/tools/database/pg.py +343 -0
- parrot/tools/databasequery.py +1159 -0
- parrot/tools/db.py +1800 -0
- parrot/tools/ddgo.py +370 -0
- parrot/tools/decorators.py +271 -0
- parrot/tools/dftohtml.py +282 -0
- parrot/tools/document.py +549 -0
- parrot/tools/ecs.py +819 -0
- parrot/tools/edareport.py +368 -0
- parrot/tools/elasticsearch.py +1049 -0
- parrot/tools/employees.py +462 -0
- parrot/tools/epson/__init__.py +96 -0
- parrot/tools/excel.py +683 -0
- parrot/tools/file/__init__.py +13 -0
- parrot/tools/file/abstract.py +76 -0
- parrot/tools/file/gcs.py +378 -0
- parrot/tools/file/local.py +284 -0
- parrot/tools/file/s3.py +511 -0
- parrot/tools/file/tmp.py +309 -0
- parrot/tools/file/tool.py +501 -0
- parrot/tools/file_reader.py +129 -0
- parrot/tools/flowtask/__init__.py +19 -0
- parrot/tools/flowtask/tool.py +761 -0
- parrot/tools/gittoolkit.py +508 -0
- parrot/tools/google/__init__.py +18 -0
- parrot/tools/google/base.py +169 -0
- parrot/tools/google/tools.py +1251 -0
- parrot/tools/googlelocation.py +5 -0
- parrot/tools/googleroutes.py +5 -0
- parrot/tools/googlesearch.py +5 -0
- parrot/tools/googlesitesearch.py +5 -0
- parrot/tools/googlevoice.py +2 -0
- parrot/tools/gvoice.py +695 -0
- parrot/tools/ibisworld/README.md +225 -0
- parrot/tools/ibisworld/__init__.py +11 -0
- parrot/tools/ibisworld/tool.py +366 -0
- parrot/tools/jiratoolkit.py +1718 -0
- parrot/tools/manager.py +1098 -0
- parrot/tools/math.py +152 -0
- parrot/tools/metadata.py +476 -0
- parrot/tools/msteams.py +1621 -0
- parrot/tools/msword.py +635 -0
- parrot/tools/multidb.py +580 -0
- parrot/tools/multistoresearch.py +369 -0
- parrot/tools/networkninja.py +167 -0
- parrot/tools/nextstop/__init__.py +4 -0
- parrot/tools/nextstop/base.py +286 -0
- parrot/tools/nextstop/employee.py +733 -0
- parrot/tools/nextstop/store.py +462 -0
- parrot/tools/notification.py +435 -0
- parrot/tools/o365/__init__.py +42 -0
- parrot/tools/o365/base.py +295 -0
- parrot/tools/o365/bundle.py +522 -0
- parrot/tools/o365/events.py +554 -0
- parrot/tools/o365/mail.py +992 -0
- parrot/tools/o365/onedrive.py +497 -0
- parrot/tools/o365/sharepoint.py +641 -0
- parrot/tools/openapi_toolkit.py +904 -0
- parrot/tools/openweather.py +527 -0
- parrot/tools/pdfprint.py +1001 -0
- parrot/tools/powerbi.py +518 -0
- parrot/tools/powerpoint.py +1113 -0
- parrot/tools/pricestool.py +146 -0
- parrot/tools/products/__init__.py +246 -0
- parrot/tools/prophet_tool.py +171 -0
- parrot/tools/pythonpandas.py +630 -0
- parrot/tools/pythonrepl.py +910 -0
- parrot/tools/qsource.py +436 -0
- parrot/tools/querytoolkit.py +395 -0
- parrot/tools/quickeda.py +827 -0
- parrot/tools/resttool.py +553 -0
- parrot/tools/retail/__init__.py +0 -0
- parrot/tools/retail/bby.py +528 -0
- parrot/tools/sandboxtool.py +703 -0
- parrot/tools/sassie/__init__.py +352 -0
- parrot/tools/scraping/__init__.py +7 -0
- parrot/tools/scraping/docs/select.md +466 -0
- parrot/tools/scraping/documentation.md +1278 -0
- parrot/tools/scraping/driver.py +436 -0
- parrot/tools/scraping/models.py +576 -0
- parrot/tools/scraping/options.py +85 -0
- parrot/tools/scraping/orchestrator.py +517 -0
- parrot/tools/scraping/readme.md +740 -0
- parrot/tools/scraping/tool.py +3115 -0
- parrot/tools/seasonaldetection.py +642 -0
- parrot/tools/shell_tool/__init__.py +5 -0
- parrot/tools/shell_tool/actions.py +408 -0
- parrot/tools/shell_tool/engine.py +155 -0
- parrot/tools/shell_tool/models.py +322 -0
- parrot/tools/shell_tool/tool.py +442 -0
- parrot/tools/site_search.py +214 -0
- parrot/tools/textfile.py +418 -0
- parrot/tools/think.py +378 -0
- parrot/tools/toolkit.py +298 -0
- parrot/tools/webapp_tool.py +187 -0
- parrot/tools/whatif.py +1279 -0
- parrot/tools/workday/MULTI_WSDL_EXAMPLE.md +249 -0
- parrot/tools/workday/__init__.py +6 -0
- parrot/tools/workday/models.py +1389 -0
- parrot/tools/workday/tool.py +1293 -0
- parrot/tools/yfinance_tool.py +306 -0
- parrot/tools/zipcode.py +217 -0
- parrot/utils/__init__.py +2 -0
- parrot/utils/helpers.py +73 -0
- parrot/utils/parsers/__init__.py +5 -0
- parrot/utils/parsers/toml.c +12078 -0
- parrot/utils/parsers/toml.cpython-310-x86_64-linux-gnu.so +0 -0
- parrot/utils/parsers/toml.pyx +21 -0
- parrot/utils/toml.py +11 -0
- parrot/utils/types.cpp +20936 -0
- parrot/utils/types.cpython-310-x86_64-linux-gnu.so +0 -0
- parrot/utils/types.pyx +213 -0
- parrot/utils/uv.py +11 -0
- parrot/version.py +10 -0
- parrot/yaml-rs/Cargo.lock +350 -0
- parrot/yaml-rs/Cargo.toml +19 -0
- parrot/yaml-rs/pyproject.toml +19 -0
- parrot/yaml-rs/python/yaml_rs/__init__.py +81 -0
- parrot/yaml-rs/src/lib.rs +222 -0
- requirements/docker-compose.yml +24 -0
- requirements/requirements-dev.txt +21 -0
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Google Services Client for AI-Parrot.
|
|
3
|
+
|
|
4
|
+
Simplified async-only implementation using aiogoogle.
|
|
5
|
+
Provides unified interface for Google services with credential management
|
|
6
|
+
and environment variable replacement.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import contextlib
|
|
10
|
+
from pathlib import Path, PurePath
|
|
11
|
+
from typing import Union, List, Dict, Any, Optional, Callable
|
|
12
|
+
from abc import ABC
|
|
13
|
+
import asyncio
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
from contextlib import suppress
|
|
19
|
+
from urllib.parse import urlparse
|
|
20
|
+
import webbrowser
|
|
21
|
+
from aiohttp import web
|
|
22
|
+
from redis import asyncio as aioredis
|
|
23
|
+
from selenium.webdriver.chrome.service import Service as ChromeService
|
|
24
|
+
from selenium.webdriver.chrome.options import Options as ChromeOptions
|
|
25
|
+
from webdriver_manager.core.driver_cache import DriverCacheManager
|
|
26
|
+
from webdriver_manager.chrome import ChromeDriverManager
|
|
27
|
+
from playwright.async_api import async_playwright
|
|
28
|
+
from aiogoogle import Aiogoogle
|
|
29
|
+
from aiogoogle.auth.creds import ServiceAccountCreds, UserCreds
|
|
30
|
+
from aiogoogle.auth.utils import create_secret
|
|
31
|
+
from navconfig import BASE_DIR, config
|
|
32
|
+
from ..exceptions import ConfigError # pylint: disable=E0611 # noqa
|
|
33
|
+
from ..conf import GOOGLE_CREDENTIALS_FILE, REDIS_HISTORY_URL
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ============================================================================
|
|
37
|
+
# Default Scopes for Google Services
|
|
38
|
+
# ============================================================================
|
|
39
|
+
|
|
40
|
+
DEFAULT_SCOPES = {
|
|
41
|
+
# Google Drive
|
|
42
|
+
'drive': [
|
|
43
|
+
'https://www.googleapis.com/auth/drive',
|
|
44
|
+
'https://www.googleapis.com/auth/drive.file',
|
|
45
|
+
'https://www.googleapis.com/auth/drive.metadata.readonly'
|
|
46
|
+
],
|
|
47
|
+
# Google Sheets
|
|
48
|
+
'sheets': [
|
|
49
|
+
'https://www.googleapis.com/auth/spreadsheets',
|
|
50
|
+
'https://www.googleapis.com/auth/spreadsheets.readonly'
|
|
51
|
+
],
|
|
52
|
+
# Google Docs
|
|
53
|
+
'docs': [
|
|
54
|
+
'https://www.googleapis.com/auth/documents',
|
|
55
|
+
'https://www.googleapis.com/auth/documents.readonly'
|
|
56
|
+
],
|
|
57
|
+
# Google Calendar
|
|
58
|
+
'calendar': [
|
|
59
|
+
'https://www.googleapis.com/auth/calendar',
|
|
60
|
+
'https://www.googleapis.com/auth/calendar.readonly',
|
|
61
|
+
'https://www.googleapis.com/auth/calendar.events'
|
|
62
|
+
],
|
|
63
|
+
# Google Cloud Storage
|
|
64
|
+
'storage': [
|
|
65
|
+
'https://www.googleapis.com/auth/devstorage.full_control',
|
|
66
|
+
'https://www.googleapis.com/auth/devstorage.read_only',
|
|
67
|
+
'https://www.googleapis.com/auth/devstorage.read_write'
|
|
68
|
+
],
|
|
69
|
+
# Gmail
|
|
70
|
+
'gmail': [
|
|
71
|
+
'https://www.googleapis.com/auth/gmail.readonly',
|
|
72
|
+
'https://www.googleapis.com/auth/gmail.modify',
|
|
73
|
+
'https://www.googleapis.com/auth/gmail.compose'
|
|
74
|
+
],
|
|
75
|
+
# Google Search
|
|
76
|
+
'search': [
|
|
77
|
+
'https://www.googleapis.com/auth/cse'
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Combined scopes for full access
|
|
82
|
+
DEFAULT_SCOPES['all'] = list(set(
|
|
83
|
+
DEFAULT_SCOPES['drive'] +
|
|
84
|
+
DEFAULT_SCOPES['sheets'] +
|
|
85
|
+
DEFAULT_SCOPES['docs'] +
|
|
86
|
+
DEFAULT_SCOPES['calendar'] +
|
|
87
|
+
DEFAULT_SCOPES['storage']
|
|
88
|
+
))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ============================================================================
|
|
92
|
+
# Credentials Interface Mixin
|
|
93
|
+
# ============================================================================
|
|
94
|
+
|
|
95
|
+
class CredentialsInterface:
|
|
96
|
+
"""
|
|
97
|
+
Mixin for processing credentials with environment variable replacement.
|
|
98
|
+
|
|
99
|
+
Handles ${VAR_NAME} patterns in credential dictionaries.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
ENV_VAR_PATTERN = re.compile(r'\$\{([^}]+)\}')
|
|
103
|
+
|
|
104
|
+
def processing_credentials(self) -> None:
|
|
105
|
+
"""
|
|
106
|
+
Process credentials dictionary and replace environment variables.
|
|
107
|
+
|
|
108
|
+
Replaces ${VAR_NAME} patterns with values from environment variables.
|
|
109
|
+
Works with both navconfig and os.environ.
|
|
110
|
+
"""
|
|
111
|
+
if not hasattr(self, 'credentials_dict') or not self.credentials_dict: # pylint: disable=E0203 # noqa
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
self.credentials_dict = self._replace_env_vars(self.credentials_dict)
|
|
115
|
+
|
|
116
|
+
def _replace_env_vars(self, obj: Any) -> Any:
|
|
117
|
+
"""
|
|
118
|
+
Recursively replace environment variables in strings.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
obj: Object to process (dict, list, str, or other)
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Processed object with environment variables replaced
|
|
125
|
+
"""
|
|
126
|
+
if isinstance(obj, dict):
|
|
127
|
+
return {k: self._replace_env_vars(v) for k, v in obj.items()}
|
|
128
|
+
elif isinstance(obj, list):
|
|
129
|
+
return [self._replace_env_vars(item) for item in obj]
|
|
130
|
+
elif isinstance(obj, str):
|
|
131
|
+
return self._replace_env_var_string(obj)
|
|
132
|
+
return obj
|
|
133
|
+
|
|
134
|
+
def _replace_env_var_string(self, value: str) -> str:
|
|
135
|
+
"""
|
|
136
|
+
Replace environment variables in a string.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
value: String potentially containing ${VAR} patterns
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
String with variables replaced
|
|
143
|
+
"""
|
|
144
|
+
def replacer(match):
|
|
145
|
+
var_name = match.group(1)
|
|
146
|
+
# Try navconfig first, then os.environ
|
|
147
|
+
if hasattr(config, 'get'):
|
|
148
|
+
env_value = config.get(var_name)
|
|
149
|
+
if env_value is not None:
|
|
150
|
+
return env_value
|
|
151
|
+
return os.environ.get(var_name, match.group(0))
|
|
152
|
+
|
|
153
|
+
return self.ENV_VAR_PATTERN.sub(replacer, value)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ============================================================================
|
|
157
|
+
# Google Client
|
|
158
|
+
# ============================================================================
|
|
159
|
+
|
|
160
|
+
class GoogleClient(CredentialsInterface, ABC):
|
|
161
|
+
"""
|
|
162
|
+
Google Services Client for AI-Parrot.
|
|
163
|
+
|
|
164
|
+
Async-only implementation using aiogoogle for:
|
|
165
|
+
- Google Drive (file management)
|
|
166
|
+
- Google Sheets (spreadsheets)
|
|
167
|
+
- Google Docs (documents)
|
|
168
|
+
- Google Calendar (events)
|
|
169
|
+
- Google Cloud Storage (buckets)
|
|
170
|
+
- Gmail (email)
|
|
171
|
+
- Google Custom Search
|
|
172
|
+
|
|
173
|
+
Features:
|
|
174
|
+
- Service account and user credentials support
|
|
175
|
+
- Environment variable replacement in credentials
|
|
176
|
+
- Full async/await support via aiogoogle
|
|
177
|
+
- OAuth2 interactive login support (framework ready)
|
|
178
|
+
- Credential caching
|
|
179
|
+
|
|
180
|
+
Authentication Methods:
|
|
181
|
+
1. Service Account (recommended for server apps):
|
|
182
|
+
- Use JSON key file
|
|
183
|
+
- Use JSON string
|
|
184
|
+
- Use dictionary
|
|
185
|
+
|
|
186
|
+
2. User Credentials (OAuth2):
|
|
187
|
+
- Interactive browser login (TODO: implement)
|
|
188
|
+
- Cached credentials
|
|
189
|
+
|
|
190
|
+
Example:
|
|
191
|
+
# Service account from file
|
|
192
|
+
client = GoogleClient(credentials="path/to/key.json")
|
|
193
|
+
await client.initialize()
|
|
194
|
+
|
|
195
|
+
# Service account from dict with env vars
|
|
196
|
+
client = GoogleClient(credentials={
|
|
197
|
+
"type": "service_account",
|
|
198
|
+
"project_id": "${GCP_PROJECT_ID}",
|
|
199
|
+
"private_key": "${GCP_PRIVATE_KEY}",
|
|
200
|
+
...
|
|
201
|
+
})
|
|
202
|
+
await client.initialize()
|
|
203
|
+
|
|
204
|
+
# Context manager (recommended)
|
|
205
|
+
async with GoogleClient(credentials="key.json", scopes="drive") as client:
|
|
206
|
+
result = await client.execute_api_call(...)
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
def __init__(
|
|
210
|
+
self,
|
|
211
|
+
credentials: Optional[Union[str, dict, Path]] = None,
|
|
212
|
+
scopes: Optional[Union[List[str], str]] = None,
|
|
213
|
+
user_creds_cache_file: Optional[Union[str, Path]] = None,
|
|
214
|
+
**kwargs
|
|
215
|
+
):
|
|
216
|
+
"""
|
|
217
|
+
Initialize Google Client.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
credentials: Credentials (file path, dict, "user" for OAuth)
|
|
221
|
+
scopes: Service scopes (e.g., ["drive", "sheets"] or "all")
|
|
222
|
+
**kwargs: Additional arguments
|
|
223
|
+
"""
|
|
224
|
+
self.logger = logging.getLogger(
|
|
225
|
+
f'Parrot.Interfaces.{self.__class__.__name__}'
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Credential storage
|
|
229
|
+
self.credentials_file: Optional[PurePath] = None
|
|
230
|
+
self.credentials_str: Optional[str] = None
|
|
231
|
+
self.credentials_dict: Optional[dict] = None
|
|
232
|
+
self.auth_type: str = 'service_account' # or 'user'
|
|
233
|
+
self._oauth_client_config: Optional[Dict[str, Any]] = None
|
|
234
|
+
self._client_credentials_source: Optional[str] = None
|
|
235
|
+
self.redis_url: str = kwargs.get(
|
|
236
|
+
"redis_url", REDIS_HISTORY_URL or "redis://localhost:6379/0"
|
|
237
|
+
)
|
|
238
|
+
self.redis: Optional[aioredis.Redis] = None
|
|
239
|
+
try:
|
|
240
|
+
self.redis = aioredis.from_url(self.redis_url, encoding="utf-8", decode_responses=True)
|
|
241
|
+
except Exception as e:
|
|
242
|
+
self.logger.warning(
|
|
243
|
+
"Google: Redis unavailable (%s); falling back to file cache only.", e
|
|
244
|
+
)
|
|
245
|
+
self.user_creds_ttl: int = int(kwargs.get("user_creds_ttl", 75 * 24 * 3600)) # 75 days
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# Process scopes
|
|
249
|
+
self.scopes: List[str] = self._process_scopes(scopes or 'all')
|
|
250
|
+
|
|
251
|
+
# Credentials
|
|
252
|
+
self._service_account_creds: Optional[ServiceAccountCreds] = None
|
|
253
|
+
self._user_creds: Optional[UserCreds] = None
|
|
254
|
+
self._user_creds_payload: Optional[Dict[str, Any]] = None
|
|
255
|
+
|
|
256
|
+
# Authentication state
|
|
257
|
+
self._authenticated = False
|
|
258
|
+
|
|
259
|
+
# User credential cache
|
|
260
|
+
if isinstance(user_creds_cache_file, (str, Path)):
|
|
261
|
+
self.user_creds_cache_file: Optional[Path] = Path(user_creds_cache_file).expanduser().resolve()
|
|
262
|
+
else:
|
|
263
|
+
# Default cache location inside env directory
|
|
264
|
+
self.user_creds_cache_file = BASE_DIR.joinpath('env', 'google', 'user_creds.json')
|
|
265
|
+
|
|
266
|
+
# Process credentials
|
|
267
|
+
self._load_credentials(credentials)
|
|
268
|
+
super().__init__()
|
|
269
|
+
|
|
270
|
+
async def __aenter__(self) -> GoogleClient:
|
|
271
|
+
await self.ensure_interactive_session()
|
|
272
|
+
await self.initialize()
|
|
273
|
+
return self
|
|
274
|
+
|
|
275
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
276
|
+
await self.close()
|
|
277
|
+
|
|
278
|
+
def _process_scopes(self, scopes: Union[List[str], str]) -> List[str]:
|
|
279
|
+
"""
|
|
280
|
+
Process scope specification into full scope URLs.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
scopes: Scope names or URLs
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
List of full scope URLs
|
|
287
|
+
"""
|
|
288
|
+
if isinstance(scopes, str):
|
|
289
|
+
# Single scope name or "all"
|
|
290
|
+
if scopes in DEFAULT_SCOPES:
|
|
291
|
+
return DEFAULT_SCOPES[scopes].copy()
|
|
292
|
+
scopes = [scopes]
|
|
293
|
+
|
|
294
|
+
# Expand scope names to URLs
|
|
295
|
+
result = []
|
|
296
|
+
for scope in scopes:
|
|
297
|
+
if scope.startswith('https://'):
|
|
298
|
+
result.append(scope)
|
|
299
|
+
elif scope in DEFAULT_SCOPES:
|
|
300
|
+
result.extend(DEFAULT_SCOPES[scope])
|
|
301
|
+
else:
|
|
302
|
+
self.logger.warning(f"Unknown scope: {scope}")
|
|
303
|
+
|
|
304
|
+
return list(set(result)) # Remove duplicates
|
|
305
|
+
|
|
306
|
+
def _redis_cache_key(self, client_id: Optional[str], user_hint: Optional[str], scopes: list[str]) -> str:
|
|
307
|
+
# Use OAuth client_id + (optional) user email hint + stable scopes hash
|
|
308
|
+
sid = (client_id or "unknown").strip()
|
|
309
|
+
u = (user_hint or "").strip()
|
|
310
|
+
scopes_key = "|".join(sorted(scopes))
|
|
311
|
+
return f"google:oauth:{sid}:{u}:{hash(scopes_key)}"
|
|
312
|
+
|
|
313
|
+
async def _save_user_creds_to_redis(self, creds: dict, client_id: Optional[str], user_hint: Optional[str], scopes: list[str]) -> None:
|
|
314
|
+
if not self.redis:
|
|
315
|
+
return
|
|
316
|
+
key = self._redis_cache_key(client_id, user_hint, scopes)
|
|
317
|
+
try:
|
|
318
|
+
await self.redis.set(key, json.dumps(creds, default=str), ex=self.user_creds_ttl)
|
|
319
|
+
self.logger.info("Google: saved user credentials to Redis cache")
|
|
320
|
+
self._user_creds_payload = creds.copy()
|
|
321
|
+
except Exception as e:
|
|
322
|
+
self.logger.warning("Google: failed to save creds to Redis: %s", e)
|
|
323
|
+
|
|
324
|
+
async def _load_user_creds_from_redis(self, client_id: Optional[str], user_hint: Optional[str], scopes: list[str]) -> bool:
|
|
325
|
+
if not self.redis:
|
|
326
|
+
return False
|
|
327
|
+
key = self._redis_cache_key(client_id, user_hint, scopes)
|
|
328
|
+
try:
|
|
329
|
+
blob = await self.redis.get(key)
|
|
330
|
+
if not blob:
|
|
331
|
+
return False
|
|
332
|
+
data = json.loads(blob)
|
|
333
|
+
scopes2 = data.get("scopes", scopes)
|
|
334
|
+
if isinstance(scopes2, str):
|
|
335
|
+
scopes2 = [scopes2]
|
|
336
|
+
d = data.copy()
|
|
337
|
+
d.pop("scopes", None)
|
|
338
|
+
self._user_creds = UserCreds(scopes=scopes2, **d)
|
|
339
|
+
self._user_creds_payload = data
|
|
340
|
+
self._service_account_creds = None
|
|
341
|
+
self._authenticated = True
|
|
342
|
+
self._client_credentials_source = f"redis:{key}"
|
|
343
|
+
self.logger.info("Google: loaded user credentials from Redis cache")
|
|
344
|
+
return True
|
|
345
|
+
except Exception as e:
|
|
346
|
+
self.logger.warning("Google: failed to load creds from Redis: %s", e)
|
|
347
|
+
return False
|
|
348
|
+
|
|
349
|
+
def _load_credentials(
|
|
350
|
+
self,
|
|
351
|
+
credentials: Optional[Union[str, dict, Path]]
|
|
352
|
+
) -> None:
|
|
353
|
+
"""
|
|
354
|
+
Load and validate credentials.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
credentials: Credentials specification
|
|
358
|
+
"""
|
|
359
|
+
if credentials is None:
|
|
360
|
+
if not GOOGLE_CREDENTIALS_FILE.exists():
|
|
361
|
+
raise RuntimeError(
|
|
362
|
+
"Google: No credentials provided and GOOGLE_CREDENTIALS_FILE not found."
|
|
363
|
+
)
|
|
364
|
+
self.credentials_file = GOOGLE_CREDENTIALS_FILE
|
|
365
|
+
self._client_credentials_source = f"file:{self.credentials_file}"
|
|
366
|
+
try:
|
|
367
|
+
self.credentials_dict = json.loads(self.credentials_file.read_text())
|
|
368
|
+
self._set_auth_type_from_dict(self.credentials_dict)
|
|
369
|
+
except json.JSONDecodeError:
|
|
370
|
+
# Keep lazy loading for malformed files; will raise during initialize
|
|
371
|
+
self.logger.debug("Google: Could not parse default credentials file on load.")
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
if isinstance(credentials, str):
|
|
375
|
+
if credentials.lower() == "user":
|
|
376
|
+
# OAuth2 user credentials
|
|
377
|
+
self.auth_type = 'user'
|
|
378
|
+
self._client_credentials_source = 'user:prompt'
|
|
379
|
+
return
|
|
380
|
+
elif credentials.endswith(".json"):
|
|
381
|
+
# JSON file path
|
|
382
|
+
self.credentials_file = Path(credentials).resolve()
|
|
383
|
+
if not self.credentials_file.exists():
|
|
384
|
+
# Try BASE_DIR
|
|
385
|
+
self.credentials_file = BASE_DIR.joinpath(credentials).resolve()
|
|
386
|
+
if not self.credentials_file.exists():
|
|
387
|
+
raise ConfigError(
|
|
388
|
+
f"Google: Credentials file not found: {credentials}"
|
|
389
|
+
)
|
|
390
|
+
try:
|
|
391
|
+
self.credentials_dict = json.loads(self.credentials_file.read_text())
|
|
392
|
+
self._set_auth_type_from_dict(self.credentials_dict)
|
|
393
|
+
self._client_credentials_source = f"file:{self.credentials_file}"
|
|
394
|
+
except json.JSONDecodeError as exc:
|
|
395
|
+
raise ConfigError(
|
|
396
|
+
f"Google: Invalid JSON in credentials file: {self.credentials_file}"
|
|
397
|
+
) from exc
|
|
398
|
+
else:
|
|
399
|
+
# JSON string
|
|
400
|
+
try:
|
|
401
|
+
self.credentials_dict = json.loads(credentials)
|
|
402
|
+
self._set_auth_type_from_dict(self.credentials_dict)
|
|
403
|
+
self._client_credentials_source = 'string:json'
|
|
404
|
+
except json.JSONDecodeError as e:
|
|
405
|
+
raise ConfigError(
|
|
406
|
+
"Google: Invalid JSON credentials string"
|
|
407
|
+
) from e
|
|
408
|
+
|
|
409
|
+
elif isinstance(credentials, (Path, PurePath)):
|
|
410
|
+
self.credentials_file = Path(credentials).resolve()
|
|
411
|
+
if not self.credentials_file.exists():
|
|
412
|
+
raise ConfigError(
|
|
413
|
+
f"Google: Credentials file not found: {self.credentials_file}"
|
|
414
|
+
)
|
|
415
|
+
try:
|
|
416
|
+
self.credentials_dict = json.loads(self.credentials_file.read_text())
|
|
417
|
+
self._set_auth_type_from_dict(self.credentials_dict)
|
|
418
|
+
self._client_credentials_source = f"file:{self.credentials_file}"
|
|
419
|
+
except json.JSONDecodeError as exc:
|
|
420
|
+
raise ConfigError(
|
|
421
|
+
f"Google: Invalid JSON in credentials file: {self.credentials_file}"
|
|
422
|
+
) from exc
|
|
423
|
+
|
|
424
|
+
elif isinstance(credentials, dict):
|
|
425
|
+
self.credentials_dict = credentials
|
|
426
|
+
self._set_auth_type_from_dict(self.credentials_dict)
|
|
427
|
+
self._client_credentials_source = 'dict:provided'
|
|
428
|
+
|
|
429
|
+
else:
|
|
430
|
+
raise ConfigError(
|
|
431
|
+
f"Google: Invalid credentials type: {type(credentials)}"
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
def _set_auth_type_from_dict(self, data: Optional[Dict[str, Any]]) -> None:
|
|
435
|
+
"""Determine authentication type based on credentials dictionary."""
|
|
436
|
+
if not data:
|
|
437
|
+
return
|
|
438
|
+
|
|
439
|
+
if data.get('type') == 'service_account':
|
|
440
|
+
self.auth_type = 'service_account'
|
|
441
|
+
self._oauth_client_config = None
|
|
442
|
+
return
|
|
443
|
+
|
|
444
|
+
if (oauth_config := self._extract_oauth_client_config(data)):
|
|
445
|
+
self.auth_type = 'user'
|
|
446
|
+
self._oauth_client_config = oauth_config
|
|
447
|
+
else:
|
|
448
|
+
self._oauth_client_config = None
|
|
449
|
+
|
|
450
|
+
@staticmethod
|
|
451
|
+
def _extract_oauth_client_config(data: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
452
|
+
"""Extract OAuth client configuration from credentials dictionary."""
|
|
453
|
+
if not data or not isinstance(data, dict):
|
|
454
|
+
return None
|
|
455
|
+
|
|
456
|
+
if data.get('type') == 'service_account':
|
|
457
|
+
return None
|
|
458
|
+
|
|
459
|
+
for key in ('installed', 'web'):
|
|
460
|
+
value = data.get(key)
|
|
461
|
+
if isinstance(value, dict) and 'client_id' in value:
|
|
462
|
+
return value
|
|
463
|
+
|
|
464
|
+
if 'client_id' in data and ('auth_uri' in data or 'token_uri' in data):
|
|
465
|
+
return data
|
|
466
|
+
|
|
467
|
+
return None
|
|
468
|
+
|
|
469
|
+
def _get_oauth_client_config(self) -> Dict[str, Any]:
|
|
470
|
+
"""Resolve OAuth client credentials for interactive login."""
|
|
471
|
+
if self._oauth_client_config:
|
|
472
|
+
return self._oauth_client_config
|
|
473
|
+
|
|
474
|
+
candidates: List[Dict[str, Any]] = []
|
|
475
|
+
|
|
476
|
+
if isinstance(self.credentials_dict, dict):
|
|
477
|
+
candidates.append(self.credentials_dict)
|
|
478
|
+
|
|
479
|
+
if self.credentials_file and Path(self.credentials_file).exists():
|
|
480
|
+
try:
|
|
481
|
+
candidates.append(json.loads(Path(self.credentials_file).read_text()))
|
|
482
|
+
except json.JSONDecodeError:
|
|
483
|
+
self.logger.debug(
|
|
484
|
+
"Google: Failed to parse credentials file %s for OAuth config.",
|
|
485
|
+
self.credentials_file
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
default_file = GOOGLE_CREDENTIALS_FILE
|
|
489
|
+
if default_file and default_file.exists():
|
|
490
|
+
if not self.credentials_file or Path(self.credentials_file).resolve() != default_file.resolve():
|
|
491
|
+
try:
|
|
492
|
+
candidates.append(json.loads(default_file.read_text()))
|
|
493
|
+
except json.JSONDecodeError:
|
|
494
|
+
self.logger.debug(
|
|
495
|
+
"Google: Failed to parse default credentials file %s for OAuth config.",
|
|
496
|
+
default_file
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
for candidate in candidates:
|
|
500
|
+
if (oauth_config := self._extract_oauth_client_config(candidate)):
|
|
501
|
+
self._oauth_client_config = oauth_config
|
|
502
|
+
return oauth_config
|
|
503
|
+
|
|
504
|
+
raise ConfigError(
|
|
505
|
+
"Google: OAuth client credentials not found. Provide OAuth client JSON for user authentication."
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
def _prepare_user_creds(
|
|
509
|
+
self,
|
|
510
|
+
creds: Dict[str, Any],
|
|
511
|
+
scopes: List[str]
|
|
512
|
+
) -> Dict[str, Any]:
|
|
513
|
+
"""Prepare user credential payload for storage and UserCreds construction."""
|
|
514
|
+
allowed_keys = [
|
|
515
|
+
'access_token', 'refresh_token', 'expires_in', 'expires_at',
|
|
516
|
+
'token_type', 'token_uri', 'token_info_uri', 'revoke_uri', 'id_token_jwt'
|
|
517
|
+
]
|
|
518
|
+
sanitized = {key: creds.get(key) for key in allowed_keys if creds.get(key) is not None}
|
|
519
|
+
|
|
520
|
+
id_token = creds.get('id_token')
|
|
521
|
+
if isinstance(id_token, (dict, str)):
|
|
522
|
+
sanitized['id_token'] = id_token
|
|
523
|
+
|
|
524
|
+
sanitized['scopes'] = scopes
|
|
525
|
+
return sanitized
|
|
526
|
+
|
|
527
|
+
def _export_active_user_creds(self, scopes: List[str]) -> Dict[str, Any]:
|
|
528
|
+
"""Return the sanitized credential payload for the active user session."""
|
|
529
|
+
if not self._user_creds:
|
|
530
|
+
raise RuntimeError("Google: No active user credentials to export")
|
|
531
|
+
|
|
532
|
+
payload: Dict[str, Any]
|
|
533
|
+
if self._user_creds_payload:
|
|
534
|
+
payload = self._user_creds_payload.copy()
|
|
535
|
+
else:
|
|
536
|
+
payload = dict(self._user_creds)
|
|
537
|
+
payload['scopes'] = scopes
|
|
538
|
+
return self._prepare_user_creds(payload, scopes)
|
|
539
|
+
|
|
540
|
+
def _save_user_creds_to_cache(self, creds: Dict[str, Any]) -> None:
|
|
541
|
+
"""Persist user credentials to cache for subsequent sessions."""
|
|
542
|
+
if not self.user_creds_cache_file:
|
|
543
|
+
return
|
|
544
|
+
|
|
545
|
+
try:
|
|
546
|
+
self.user_creds_cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
547
|
+
self.user_creds_cache_file.write_text(json.dumps(creds, indent=2, default=str))
|
|
548
|
+
except Exception as cache_error: # pragma: no cover - defensive
|
|
549
|
+
self.logger.warning(
|
|
550
|
+
"Google: Failed to cache user credentials: %s",
|
|
551
|
+
cache_error
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
def _load_cached_user_creds(self) -> bool:
|
|
555
|
+
"""Load cached user credentials if available."""
|
|
556
|
+
if not self.user_creds_cache_file or not self.user_creds_cache_file.exists():
|
|
557
|
+
return False
|
|
558
|
+
|
|
559
|
+
try:
|
|
560
|
+
cached = json.loads(self.user_creds_cache_file.read_text())
|
|
561
|
+
scopes = cached.get('scopes', self.scopes)
|
|
562
|
+
if isinstance(scopes, str):
|
|
563
|
+
scopes = [scopes]
|
|
564
|
+
creds_kwargs = cached.copy()
|
|
565
|
+
creds_kwargs.pop('scopes', None)
|
|
566
|
+
self._user_creds = UserCreds(scopes=scopes, **creds_kwargs)
|
|
567
|
+
self._user_creds_payload = cached
|
|
568
|
+
self._client_credentials_source = f"cache:{self.user_creds_cache_file}"
|
|
569
|
+
return True
|
|
570
|
+
except Exception as cache_error: # pragma: no cover - defensive
|
|
571
|
+
self.logger.warning(
|
|
572
|
+
"Google: Failed to load cached user credentials: %s",
|
|
573
|
+
cache_error
|
|
574
|
+
)
|
|
575
|
+
return False
|
|
576
|
+
|
|
577
|
+
def load_cached_user_credentials(self) -> bool:
|
|
578
|
+
"""Public helper for loading cached user credentials."""
|
|
579
|
+
return self._load_cached_user_creds()
|
|
580
|
+
|
|
581
|
+
def set_credentials(self, credentials: Optional[Union[str, dict, Path]]) -> None:
|
|
582
|
+
"""Public helper to update credentials after initialization."""
|
|
583
|
+
self._load_credentials(credentials)
|
|
584
|
+
self._authenticated = False
|
|
585
|
+
|
|
586
|
+
@property
|
|
587
|
+
def active_credentials(self) -> Optional[Union[ServiceAccountCreds, UserCreds]]:
|
|
588
|
+
"""Return whichever credential set is currently active."""
|
|
589
|
+
return self._service_account_creds or self._user_creds
|
|
590
|
+
|
|
591
|
+
@property
|
|
592
|
+
def credentials_source(self) -> Optional[str]:
|
|
593
|
+
"""Return the source the client used to obtain credentials."""
|
|
594
|
+
return self._client_credentials_source
|
|
595
|
+
|
|
596
|
+
@property
|
|
597
|
+
def is_authenticated(self) -> bool:
|
|
598
|
+
"""Expose authentication status for callers."""
|
|
599
|
+
return self._authenticated
|
|
600
|
+
|
|
601
|
+
def using_service_account(self) -> bool:
|
|
602
|
+
"""Return True if the client is configured for service-account credentials."""
|
|
603
|
+
return self.auth_type == 'service_account' and self._service_account_creds is not None
|
|
604
|
+
|
|
605
|
+
def using_user_credentials(self) -> bool:
|
|
606
|
+
"""Return True if the client is configured for end-user OAuth credentials."""
|
|
607
|
+
return self.auth_type == 'user' and self._user_creds is not None
|
|
608
|
+
|
|
609
|
+
async def initialize(self) -> GoogleClient:
|
|
610
|
+
"""
|
|
611
|
+
Initialize the client and authenticate.
|
|
612
|
+
|
|
613
|
+
Returns:
|
|
614
|
+
Self for method chaining
|
|
615
|
+
"""
|
|
616
|
+
if self._authenticated:
|
|
617
|
+
return self
|
|
618
|
+
|
|
619
|
+
# Process environment variables in credentials
|
|
620
|
+
self.processing_credentials()
|
|
621
|
+
if self.auth_type != 'service_account':
|
|
622
|
+
# user creds: try Redis first
|
|
623
|
+
client_id = None
|
|
624
|
+
try:
|
|
625
|
+
oauth_cfg = self._get_oauth_client_config() # you already have this
|
|
626
|
+
client_id = oauth_cfg.get("client_id")
|
|
627
|
+
except Exception:
|
|
628
|
+
pass
|
|
629
|
+
|
|
630
|
+
# Optional user hint from config/env (email), else None
|
|
631
|
+
user_hint = (self.credentials_dict or {}).get("user_email") or os.environ.get("GOOGLE_USER_HINT")
|
|
632
|
+
|
|
633
|
+
if not self._user_creds:
|
|
634
|
+
loaded = await self._load_user_creds_from_redis(client_id, user_hint, self.scopes)
|
|
635
|
+
if not loaded:
|
|
636
|
+
# Fall back to file cache
|
|
637
|
+
if not self._load_cached_user_creds():
|
|
638
|
+
raise RuntimeError(
|
|
639
|
+
"Google: User credentials not available. Run interactive_login() first."
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
elif self.auth_type == 'service_account':
|
|
643
|
+
# Service account credentials
|
|
644
|
+
if self.credentials_dict:
|
|
645
|
+
creds_dict = self.credentials_dict
|
|
646
|
+
elif self.credentials_file:
|
|
647
|
+
creds_dict = json.loads(self.credentials_file.read_text())
|
|
648
|
+
else:
|
|
649
|
+
raise RuntimeError("Google: No credentials available")
|
|
650
|
+
|
|
651
|
+
self._service_account_creds = ServiceAccountCreds(
|
|
652
|
+
scopes=self.scopes,
|
|
653
|
+
**creds_dict
|
|
654
|
+
)
|
|
655
|
+
self._user_creds = None
|
|
656
|
+
self._user_creds_payload = None
|
|
657
|
+
if not self._client_credentials_source:
|
|
658
|
+
self._client_credentials_source = 'service_account:runtime'
|
|
659
|
+
else:
|
|
660
|
+
# User credentials require interactive login
|
|
661
|
+
self._service_account_creds = None
|
|
662
|
+
if not self._user_creds and not self._load_cached_user_creds():
|
|
663
|
+
raise RuntimeError(
|
|
664
|
+
"Google: User credentials not available. Run interactive_login() first."
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
self._authenticated = True
|
|
668
|
+
self.logger.info("Google Client initialized")
|
|
669
|
+
return self
|
|
670
|
+
|
|
671
|
+
async def execute_api_call(
|
|
672
|
+
self,
|
|
673
|
+
service_name: str,
|
|
674
|
+
api_name: str,
|
|
675
|
+
method_chain: str,
|
|
676
|
+
version: str = None,
|
|
677
|
+
**kwargs
|
|
678
|
+
) -> Any:
|
|
679
|
+
"""
|
|
680
|
+
Execute a Google API call.
|
|
681
|
+
|
|
682
|
+
Args:
|
|
683
|
+
service_name: Service name (drive, sheets, docs, calendar, storage, gmail)
|
|
684
|
+
api_name: API resource name (e.g., 'files', 'spreadsheets', 'events')
|
|
685
|
+
method_chain: Method to call (e.g., 'list', 'get', 'create')
|
|
686
|
+
version: API version (defaults based on service)
|
|
687
|
+
**kwargs: Method parameters
|
|
688
|
+
|
|
689
|
+
Returns:
|
|
690
|
+
API response
|
|
691
|
+
|
|
692
|
+
Example:
|
|
693
|
+
# List Drive files
|
|
694
|
+
files = await client.execute_api_call(
|
|
695
|
+
'drive', 'files', 'list',
|
|
696
|
+
pageSize=10,
|
|
697
|
+
fields='files(id, name)'
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
# Get spreadsheet
|
|
701
|
+
sheet = await client.execute_api_call(
|
|
702
|
+
'sheets', 'spreadsheets', 'get',
|
|
703
|
+
version='v4',
|
|
704
|
+
spreadsheetId='abc123'
|
|
705
|
+
)
|
|
706
|
+
"""
|
|
707
|
+
if not self._authenticated:
|
|
708
|
+
await self.initialize()
|
|
709
|
+
|
|
710
|
+
# Default versions
|
|
711
|
+
version_map = {
|
|
712
|
+
'drive': 'v3',
|
|
713
|
+
'sheets': 'v4',
|
|
714
|
+
'docs': 'v1',
|
|
715
|
+
'calendar': 'v3',
|
|
716
|
+
'storage': 'v1',
|
|
717
|
+
'gmail': 'v1',
|
|
718
|
+
'customsearch': 'v1'
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if version is None:
|
|
722
|
+
version = version_map.get(service_name, 'v1')
|
|
723
|
+
|
|
724
|
+
async with Aiogoogle(
|
|
725
|
+
service_account_creds=self._service_account_creds,
|
|
726
|
+
user_creds=self._user_creds
|
|
727
|
+
) as aiogoogle:
|
|
728
|
+
# Discover the API
|
|
729
|
+
api = await aiogoogle.discover(service_name, version)
|
|
730
|
+
|
|
731
|
+
# Navigate to the resource
|
|
732
|
+
resource = getattr(api, api_name)
|
|
733
|
+
# Get the method
|
|
734
|
+
method = getattr(resource, method_chain)
|
|
735
|
+
# Execute the request
|
|
736
|
+
if self._service_account_creds:
|
|
737
|
+
result = await aiogoogle.as_service_account(method(**kwargs))
|
|
738
|
+
else:
|
|
739
|
+
result = await aiogoogle.as_user(method(**kwargs))
|
|
740
|
+
|
|
741
|
+
return result
|
|
742
|
+
|
|
743
|
+
async def get_drive_client(self, version: str = 'v3') -> Dict[str, Any]:
|
|
744
|
+
"""Get Google Drive client config."""
|
|
745
|
+
return {'service': 'drive', 'version': version}
|
|
746
|
+
|
|
747
|
+
async def get_sheets_client(self, version: str = 'v4') -> Dict[str, Any]:
|
|
748
|
+
"""Get Google Sheets client config."""
|
|
749
|
+
return {'service': 'sheets', 'version': version}
|
|
750
|
+
|
|
751
|
+
async def get_docs_client(self, version: str = 'v1') -> Dict[str, Any]:
|
|
752
|
+
"""Get Google Docs client config."""
|
|
753
|
+
return {'service': 'docs', 'version': version}
|
|
754
|
+
|
|
755
|
+
async def get_calendar_client(self, version: str = 'v3') -> Dict[str, Any]:
|
|
756
|
+
"""Get Google Calendar client config."""
|
|
757
|
+
return {'service': 'calendar', 'version': version}
|
|
758
|
+
|
|
759
|
+
async def get_storage_client(self, version: str = 'v1') -> Dict[str, Any]:
|
|
760
|
+
"""Get Google Cloud Storage client config."""
|
|
761
|
+
return {'service': 'storage', 'version': version}
|
|
762
|
+
|
|
763
|
+
async def get_gmail_client(self, version: str = 'v1') -> Dict[str, Any]:
|
|
764
|
+
"""Get Gmail client config."""
|
|
765
|
+
return {'service': 'gmail', 'version': version}
|
|
766
|
+
|
|
767
|
+
async def search(
|
|
768
|
+
self,
|
|
769
|
+
query: str,
|
|
770
|
+
cse_id: Optional[str] = None,
|
|
771
|
+
**kwargs
|
|
772
|
+
) -> Dict[str, Any]:
|
|
773
|
+
"""
|
|
774
|
+
Perform a Google Custom Search.
|
|
775
|
+
|
|
776
|
+
Args:
|
|
777
|
+
query: Search query
|
|
778
|
+
cse_id: Custom Search Engine ID
|
|
779
|
+
**kwargs: Additional search parameters
|
|
780
|
+
|
|
781
|
+
Returns:
|
|
782
|
+
Search results
|
|
783
|
+
"""
|
|
784
|
+
if not (cse_id := cse_id or os.environ.get('GOOGLE_SEARCH_ENGINE_ID')):
|
|
785
|
+
raise RuntimeError(
|
|
786
|
+
"Google Custom Search requires cse_id parameter or "
|
|
787
|
+
"GOOGLE_SEARCH_ENGINE_ID environment variable"
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
return await self.execute_api_call(
|
|
791
|
+
'customsearch',
|
|
792
|
+
'cse',
|
|
793
|
+
'list',
|
|
794
|
+
q=query,
|
|
795
|
+
cx=cse_id,
|
|
796
|
+
**kwargs
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
async def interactive_login(
|
|
800
|
+
self,
|
|
801
|
+
scopes: Optional[Union[List[str], str]] = None,
|
|
802
|
+
port: int = 5050,
|
|
803
|
+
redirect_uri: Optional[str] = None,
|
|
804
|
+
open_browser: bool = True,
|
|
805
|
+
browser: str = "system",
|
|
806
|
+
login_callback: Optional[Callable[[str], Optional[bool]]] = None,
|
|
807
|
+
timeout: int = 300
|
|
808
|
+
) -> Dict[str, Any]:
|
|
809
|
+
"""
|
|
810
|
+
Perform interactive OAuth2 login for user credentials.
|
|
811
|
+
|
|
812
|
+
This opens a browser for the user to authenticate.
|
|
813
|
+
|
|
814
|
+
Args:
|
|
815
|
+
scopes: Scopes to request (defaults to self.scopes)
|
|
816
|
+
port: Local server port for OAuth redirect
|
|
817
|
+
redirect_uri: Custom redirect URI
|
|
818
|
+
open_browser: When True, launch a Playwright browser to complete login
|
|
819
|
+
login_callback: Optional callback invoked with the authorization URL
|
|
820
|
+
timeout: Seconds to wait for the authentication flow to complete
|
|
821
|
+
"""
|
|
822
|
+
|
|
823
|
+
self.auth_type = 'user'
|
|
824
|
+
scopes_list = self._process_scopes(scopes or self.scopes)
|
|
825
|
+
self.processing_credentials()
|
|
826
|
+
|
|
827
|
+
try:
|
|
828
|
+
await self.ensure_interactive_session(scopes_list)
|
|
829
|
+
except Exception as cached_error: # pragma: no cover - defensive reuse path
|
|
830
|
+
self.logger.debug(
|
|
831
|
+
"Google: cached interactive session unavailable: %s",
|
|
832
|
+
cached_error
|
|
833
|
+
)
|
|
834
|
+
else:
|
|
835
|
+
if self._user_creds:
|
|
836
|
+
self.logger.info(
|
|
837
|
+
"Google: reusing cached credentials for interactive login request"
|
|
838
|
+
)
|
|
839
|
+
return self._export_active_user_creds(scopes_list)
|
|
840
|
+
|
|
841
|
+
oauth_client_config = self._get_oauth_client_config()
|
|
842
|
+
redirect_uri = redirect_uri or oauth_client_config.get('redirect_uri')
|
|
843
|
+
if not redirect_uri:
|
|
844
|
+
redirect_uris = oauth_client_config.get('redirect_uris', [])
|
|
845
|
+
if redirect_uris:
|
|
846
|
+
redirect_uri = redirect_uris[0]
|
|
847
|
+
if not redirect_uri:
|
|
848
|
+
redirect_uri = f"http://localhost:{port}/callback/aiogoogle"
|
|
849
|
+
|
|
850
|
+
parsed_redirect = urlparse(redirect_uri)
|
|
851
|
+
callback_host = parsed_redirect.hostname or 'localhost'
|
|
852
|
+
callback_port = parsed_redirect.port or port
|
|
853
|
+
callback_path = parsed_redirect.path or '/'
|
|
854
|
+
if not callback_path.startswith('/'):
|
|
855
|
+
callback_path = f'/{callback_path}'
|
|
856
|
+
|
|
857
|
+
client_creds = {
|
|
858
|
+
'client_id': oauth_client_config['client_id'],
|
|
859
|
+
'client_secret': oauth_client_config.get('client_secret'),
|
|
860
|
+
'scopes': scopes_list,
|
|
861
|
+
'redirect_uri': redirect_uri
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
aiogoogle_client = Aiogoogle(client_creds=client_creds)
|
|
865
|
+
if not aiogoogle_client.oauth2.is_ready(client_creds):
|
|
866
|
+
raise ConfigError("Google: OAuth client configuration is incomplete for interactive login")
|
|
867
|
+
|
|
868
|
+
state = create_secret()
|
|
869
|
+
authorization_url = aiogoogle_client.oauth2.authorization_url(
|
|
870
|
+
client_creds=client_creds,
|
|
871
|
+
state=state,
|
|
872
|
+
access_type="offline",
|
|
873
|
+
include_granted_scopes=True,
|
|
874
|
+
prompt="consent"
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
# Provide URL via callback or console
|
|
878
|
+
if login_callback:
|
|
879
|
+
try:
|
|
880
|
+
login_callback(authorization_url)
|
|
881
|
+
except Exception as callback_error: # pragma: no cover - defensive
|
|
882
|
+
self.logger.warning(
|
|
883
|
+
"Login callback raised an exception: %s",
|
|
884
|
+
callback_error
|
|
885
|
+
)
|
|
886
|
+
self.logger.info("Authorize Google access by visiting: %s", authorization_url)
|
|
887
|
+
print("\n" + "=" * 60)
|
|
888
|
+
print("Open the following URL in your browser to authenticate:")
|
|
889
|
+
print(authorization_url)
|
|
890
|
+
print("=" * 60 + "\n")
|
|
891
|
+
|
|
892
|
+
login_event = asyncio.Event()
|
|
893
|
+
result_container: Dict[str, Any] = {}
|
|
894
|
+
error_container: Dict[str, Any] = {}
|
|
895
|
+
|
|
896
|
+
routes = web.RouteTableDef()
|
|
897
|
+
|
|
898
|
+
@routes.get(callback_path)
|
|
899
|
+
async def oauth_callback(request): # type: ignore[unused-variable]
|
|
900
|
+
if request.query.get('error'):
|
|
901
|
+
error_container['error'] = request.query.get('error_description') or request.query.get('error')
|
|
902
|
+
login_event.set()
|
|
903
|
+
return web.json_response({'status': 'error', **error_container}, status=400)
|
|
904
|
+
|
|
905
|
+
if not request.query.get('code'):
|
|
906
|
+
login_event.set()
|
|
907
|
+
error_container['error'] = 'Missing authorization code'
|
|
908
|
+
return web.Response(text="Missing authorization code", status=400)
|
|
909
|
+
|
|
910
|
+
returned_state = request.query.get('state')
|
|
911
|
+
if returned_state != state:
|
|
912
|
+
login_event.set()
|
|
913
|
+
error_container['error'] = 'State mismatch during OAuth2 callback'
|
|
914
|
+
return web.Response(text="State mismatch", status=400)
|
|
915
|
+
|
|
916
|
+
try:
|
|
917
|
+
full_user_creds = await aiogoogle_client.oauth2.build_user_creds(
|
|
918
|
+
grant=request.query.get('code'),
|
|
919
|
+
client_creds=client_creds
|
|
920
|
+
)
|
|
921
|
+
result_container['creds'] = full_user_creds
|
|
922
|
+
login_event.set()
|
|
923
|
+
return web.Response(
|
|
924
|
+
text="Authentication complete. You may close this window.",
|
|
925
|
+
content_type='text/plain'
|
|
926
|
+
)
|
|
927
|
+
except Exception as auth_error: # pragma: no cover - defensive
|
|
928
|
+
error_container['error'] = str(auth_error)
|
|
929
|
+
login_event.set()
|
|
930
|
+
return web.Response(
|
|
931
|
+
text=f"Authentication failed: {auth_error}",
|
|
932
|
+
status=500
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
app = web.Application()
|
|
936
|
+
app.add_routes(routes)
|
|
937
|
+
|
|
938
|
+
runner = web.AppRunner(app)
|
|
939
|
+
await runner.setup()
|
|
940
|
+
site = web.TCPSite(runner, host=callback_host, port=callback_port)
|
|
941
|
+
await site.start()
|
|
942
|
+
|
|
943
|
+
playwright_task: Optional[asyncio.Task] = None
|
|
944
|
+
if open_browser:
|
|
945
|
+
if browser == "system":
|
|
946
|
+
webbrowser.open(authorization_url, new=1, autoraise=True)
|
|
947
|
+
elif browser == "playwright":
|
|
948
|
+
try:
|
|
949
|
+
async def launch_browser():
|
|
950
|
+
try:
|
|
951
|
+
async with async_playwright() as playwright:
|
|
952
|
+
browser = await playwright.chromium.launch(channel="chrome", headless=False)
|
|
953
|
+
page = await browser.new_page()
|
|
954
|
+
try:
|
|
955
|
+
await page.goto(authorization_url, wait_until="load")
|
|
956
|
+
await login_event.wait()
|
|
957
|
+
finally:
|
|
958
|
+
with suppress(Exception):
|
|
959
|
+
await page.close()
|
|
960
|
+
with suppress(Exception):
|
|
961
|
+
await browser.close()
|
|
962
|
+
except asyncio.CancelledError: # pragma: no cover - cancellation support
|
|
963
|
+
raise
|
|
964
|
+
except Exception as browser_error: # pragma: no cover - defensive
|
|
965
|
+
self.logger.warning(
|
|
966
|
+
"Playwright interactive session failed: %s",
|
|
967
|
+
browser_error
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
playwright_task = asyncio.create_task(launch_browser())
|
|
971
|
+
except ImportError:
|
|
972
|
+
self.logger.warning(
|
|
973
|
+
"Playwright is not installed; open the authorization URL manually."
|
|
974
|
+
)
|
|
975
|
+
elif browser == "selenium":
|
|
976
|
+
try:
|
|
977
|
+
from selenium import webdriver
|
|
978
|
+
from selenium.webdriver.chrome.options import Options
|
|
979
|
+
|
|
980
|
+
def launch_selenium_browser():
|
|
981
|
+
try:
|
|
982
|
+
options = Options()
|
|
983
|
+
options.add_argument("--disable-infobars")
|
|
984
|
+
options.add_argument("--disable-extensions")
|
|
985
|
+
driver = webdriver.Chrome(options=options)
|
|
986
|
+
driver.get(authorization_url)
|
|
987
|
+
# Wait until login_event is set
|
|
988
|
+
while not login_event.is_set():
|
|
989
|
+
asyncio.sleep(1)
|
|
990
|
+
except Exception as selenium_error: # pragma: no cover - defensive
|
|
991
|
+
self.logger.warning(
|
|
992
|
+
"Selenium interactive session failed: %s",
|
|
993
|
+
selenium_error
|
|
994
|
+
)
|
|
995
|
+
finally:
|
|
996
|
+
with suppress(Exception):
|
|
997
|
+
driver.quit()
|
|
998
|
+
|
|
999
|
+
loop = asyncio.get_event_loop()
|
|
1000
|
+
playwright_task = loop.run_in_executor(None, launch_selenium_browser)
|
|
1001
|
+
except ImportError:
|
|
1002
|
+
self.logger.warning(
|
|
1003
|
+
"Selenium is not installed; open the authorization URL manually."
|
|
1004
|
+
)
|
|
1005
|
+
else:
|
|
1006
|
+
self.logger.warning("Unknown browser=%s, falling back to system browser", browser)
|
|
1007
|
+
webbrowser.open(authorization_url, new=1, autoraise=True)
|
|
1008
|
+
|
|
1009
|
+
try:
|
|
1010
|
+
await asyncio.wait_for(login_event.wait(), timeout=timeout)
|
|
1011
|
+
except asyncio.TimeoutError as exc:
|
|
1012
|
+
raise RuntimeError(
|
|
1013
|
+
"Google interactive login timed out. Try again and ensure the browser completes authentication."
|
|
1014
|
+
) from exc
|
|
1015
|
+
finally:
|
|
1016
|
+
if playwright_task:
|
|
1017
|
+
playwright_task.cancel()
|
|
1018
|
+
with suppress(Exception):
|
|
1019
|
+
await playwright_task
|
|
1020
|
+
await runner.cleanup()
|
|
1021
|
+
|
|
1022
|
+
if error_container.get('error'):
|
|
1023
|
+
raise RuntimeError(f"Google interactive login failed: {error_container['error']}")
|
|
1024
|
+
|
|
1025
|
+
if 'creds' not in result_container:
|
|
1026
|
+
raise RuntimeError("Google interactive login did not return credentials")
|
|
1027
|
+
|
|
1028
|
+
sanitized_creds = self._prepare_user_creds(result_container['creds'], scopes_list)
|
|
1029
|
+
creds_for_instance = sanitized_creds.copy()
|
|
1030
|
+
scopes_for_user = creds_for_instance.pop('scopes', scopes_list)
|
|
1031
|
+
self._user_creds = UserCreds(scopes=scopes_for_user, **creds_for_instance)
|
|
1032
|
+
self._service_account_creds = None
|
|
1033
|
+
self._authenticated = True
|
|
1034
|
+
self._client_credentials_source = 'user:interactive'
|
|
1035
|
+
self._user_creds_payload = sanitized_creds.copy()
|
|
1036
|
+
|
|
1037
|
+
self._save_user_creds_to_cache(sanitized_creds)
|
|
1038
|
+
client_id = oauth_client_config.get('client_id')
|
|
1039
|
+
|
|
1040
|
+
self.logger.info("Google interactive login completed successfully")
|
|
1041
|
+
user_hint = sanitized_creds.get("id_token", {}) if isinstance(sanitized_creds.get("id_token"), dict) else None
|
|
1042
|
+
if isinstance(user_hint, dict):
|
|
1043
|
+
# try extracting email if present
|
|
1044
|
+
user_hint = user_hint.get("email")
|
|
1045
|
+
await self._save_user_creds_to_redis(sanitized_creds, client_id, user_hint, scopes_list)
|
|
1046
|
+
return sanitized_creds
|
|
1047
|
+
|
|
1048
|
+
async def close(self) -> None:
|
|
1049
|
+
"""Clean up resources."""
|
|
1050
|
+
self._authenticated = False
|
|
1051
|
+
self.logger.info("Google Client closed")
|
|
1052
|
+
|
|
1053
|
+
async def __aenter__(self) -> GoogleClient:
|
|
1054
|
+
"""Async context manager entry."""
|
|
1055
|
+
await self.initialize()
|
|
1056
|
+
return self
|
|
1057
|
+
|
|
1058
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
1059
|
+
"""Async context manager exit."""
|
|
1060
|
+
await self.close()
|
|
1061
|
+
|
|
1062
|
+
def __repr__(self) -> str:
|
|
1063
|
+
return (
|
|
1064
|
+
f"GoogleClient("
|
|
1065
|
+
f"auth_type={self.auth_type}, "
|
|
1066
|
+
f"authenticated={self._authenticated})"
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
async def ensure_interactive_session(self, scopes: Optional[Union[List[str], str]] = None) -> None:
|
|
1070
|
+
"""Ensure we have usable user creds in memory; load from Redis/file cache if possible."""
|
|
1071
|
+
scopes_list = self._process_scopes(scopes or self.scopes)
|
|
1072
|
+
if self._user_creds:
|
|
1073
|
+
return
|
|
1074
|
+
|
|
1075
|
+
client_id = None
|
|
1076
|
+
with contextlib.suppress(Exception):
|
|
1077
|
+
oauth_cfg = self._get_oauth_client_config()
|
|
1078
|
+
client_id = oauth_cfg.get("client_id")
|
|
1079
|
+
|
|
1080
|
+
user_hint = (self.credentials_dict or {}).get("user_email") or os.environ.get("GOOGLE_USER_HINT")
|
|
1081
|
+
|
|
1082
|
+
loaded = await self._load_user_creds_from_redis(client_id, user_hint, scopes_list)
|
|
1083
|
+
if not loaded and not self._load_cached_user_creds():
|
|
1084
|
+
raise RuntimeError("Google: no cached session; call interactive_login()")
|
|
1085
|
+
|
|
1086
|
+
# Optionally probe a trivial endpoint to trigger refresh if needed:
|
|
1087
|
+
try:
|
|
1088
|
+
async with Aiogoogle(user_creds=self._user_creds) as ag:
|
|
1089
|
+
# a lightweight no-op call: get token info endpoint
|
|
1090
|
+
_ = await ag.oauth2.get_user_info() # if scopes include openid/profile; otherwise skip
|
|
1091
|
+
except Exception as e:
|
|
1092
|
+
# If this fails due to expired token & bad refresh, force re-login
|
|
1093
|
+
raise RuntimeError(
|
|
1094
|
+
"Google: cached session expired; run interactive_login() again"
|
|
1095
|
+
) from e
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
# ============================================================================
|
|
1100
|
+
# Helper Functions
|
|
1101
|
+
# ============================================================================
|
|
1102
|
+
|
|
1103
|
+
def create_google_client(
|
|
1104
|
+
credentials: Optional[Union[str, dict, Path]] = None,
|
|
1105
|
+
scopes: Optional[Union[List[str], str]] = None,
|
|
1106
|
+
**kwargs
|
|
1107
|
+
) -> GoogleClient:
|
|
1108
|
+
"""
|
|
1109
|
+
Factory function to create a GoogleClient.
|
|
1110
|
+
|
|
1111
|
+
Args:
|
|
1112
|
+
credentials: Credentials specification
|
|
1113
|
+
scopes: Service scopes
|
|
1114
|
+
**kwargs: Additional GoogleClient arguments
|
|
1115
|
+
|
|
1116
|
+
Returns:
|
|
1117
|
+
GoogleClient instance
|
|
1118
|
+
"""
|
|
1119
|
+
return GoogleClient(
|
|
1120
|
+
credentials=credentials,
|
|
1121
|
+
scopes=scopes,
|
|
1122
|
+
**kwargs
|
|
1123
|
+
)
|