++
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WorldQuant BRAIN Forum Functions - Python Version
|
||||
Comprehensive forum functionality including glossary, search, and post viewing using Playwright.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from playwright.async_api import async_playwright
|
||||
import requests
|
||||
import os
|
||||
|
||||
def log(message: str, level: str = "INFO"):
|
||||
"""Log message with timestamp."""
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"[{timestamp}] [{level}] {message}", file=sys.stderr)
|
||||
|
||||
# --- Parsing Helper Functions (from playwright_forum_test.py) ---
|
||||
|
||||
def _is_navigation_or_metadata(line: str) -> bool:
|
||||
"""Check if a line is navigation or metadata."""
|
||||
navigation_patterns = [
|
||||
r'^\d+ days? ago$',
|
||||
r'~\d+ minute read',
|
||||
r'^Follow',
|
||||
r'^Not yet followed',
|
||||
r'^Updated$',
|
||||
r'^AS\d+$',
|
||||
r'^[A-Z] - [A-Z] - [A-Z]', # Letter navigation
|
||||
r'^A$',
|
||||
r'^B$',
|
||||
r'^[A-Z]$' # Single letters
|
||||
]
|
||||
return any(re.match(pattern, line.strip()) for pattern in navigation_patterns)
|
||||
|
||||
def _looks_like_term(line: str) -> bool:
|
||||
"""Check if a line looks like a glossary term."""
|
||||
if len(line) > 100:
|
||||
return False
|
||||
if _is_navigation_or_metadata(line):
|
||||
return False
|
||||
definition_starters = ['the', 'a', 'an', 'this', 'that', 'it', 'is', 'are', 'was', 'were', 'for', 'to', 'in', 'on', 'at', 'by', 'with']
|
||||
first_word = line.lower().split(' ')[0] if line else ''
|
||||
if first_word and first_word in definition_starters:
|
||||
return False
|
||||
is_short = len(line) <= 80
|
||||
starts_with_capital = bool(re.match(r'^[A-Z]', line))
|
||||
has_all_caps = bool(re.match(r'^[A-Z\s\-\/\(\)]+$', line))
|
||||
has_reasonable_length = len(line) >= 2
|
||||
return is_short and has_reasonable_length and (starts_with_capital or has_all_caps)
|
||||
|
||||
def _parse_glossary_terms(content: str) -> List[Dict[str, str]]:
|
||||
"""Parse glossary terms from HTML content."""
|
||||
soup = BeautifulSoup(content, 'html.parser')
|
||||
# Get text from the article body, which is more reliable than splitting the whole HTML
|
||||
article_body = soup.select_one('.article-body')
|
||||
if not article_body:
|
||||
return []
|
||||
|
||||
# Use .get_text with a separator to preserve line breaks, which is key for the logic below
|
||||
lines = article_body.get_text(separator='\n').split('\n')
|
||||
|
||||
terms = []
|
||||
current_term = None
|
||||
current_definition = []
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if _looks_like_term(line):
|
||||
if current_term:
|
||||
# Save the previous term
|
||||
terms.append({
|
||||
"term": current_term,
|
||||
"definition": " ".join(current_definition).strip()
|
||||
})
|
||||
# Start a new term
|
||||
current_term = line
|
||||
current_definition = []
|
||||
elif current_term:
|
||||
# Add to the current definition
|
||||
current_definition.append(line)
|
||||
|
||||
# Add the last term
|
||||
if current_term:
|
||||
terms.append({
|
||||
"term": current_term,
|
||||
"definition": " ".join(current_definition).strip()
|
||||
})
|
||||
|
||||
# Filter out invalid terms and improve quality
|
||||
return [term for term in terms if
|
||||
len(term["term"]) > 0 and
|
||||
len(term["definition"]) > 10 and
|
||||
not _is_navigation_or_metadata(term["term"]) and
|
||||
"ago" not in term["definition"] and
|
||||
"minute read" not in term["definition"]]
|
||||
|
||||
class ForumClient:
|
||||
"""Forum client for WorldQuant BRAIN support site, using Playwright."""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = "https://support.worldquantbrain.com"
|
||||
# The session is mainly used for the initial authentication via brain_client
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'
|
||||
})
|
||||
|
||||
async def _get_browser_context(self, p: async_playwright, email: str, password: str):
|
||||
"""Authenticate and return a browser context with the session."""
|
||||
# Import brain_client here to avoid circular dependency
|
||||
from platform_functions import brain_client
|
||||
|
||||
log("Authenticating with BRAIN platform...", "INFO")
|
||||
auth_result = await brain_client.authenticate(email, password)
|
||||
if auth_result.get('status') != 'authenticated':
|
||||
raise Exception("BRAIN platform authentication failed.")
|
||||
log("Successfully authenticated with BRAIN platform.", "SUCCESS")
|
||||
|
||||
browser = await p.chromium.launch(channel="chrome", headless=True, args=['--no-sandbox'])
|
||||
context = await browser.new_context(user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36')
|
||||
|
||||
log("Transferring authentication session to browser...", "INFO")
|
||||
cookies = brain_client.session.cookies
|
||||
playwright_cookies = []
|
||||
for cookie in cookies:
|
||||
cookie_dict = {
|
||||
'name': cookie.name,
|
||||
'value': cookie.value,
|
||||
'domain': cookie.domain,
|
||||
'path': cookie.path,
|
||||
'secure': cookie.secure,
|
||||
'httpOnly': 'HttpOnly' in cookie._rest,
|
||||
'sameSite': 'Lax'
|
||||
}
|
||||
if cookie.expires:
|
||||
cookie_dict['expires'] = cookie.expires
|
||||
playwright_cookies.append(cookie_dict)
|
||||
|
||||
await context.add_cookies(playwright_cookies)
|
||||
log("Session transferred.", "SUCCESS")
|
||||
|
||||
return browser, context
|
||||
|
||||
async def get_glossary_terms(self, email: str, password: str) -> List[Dict[str, str]]:
|
||||
"""Extract glossary terms from the forum using Playwright."""
|
||||
async with async_playwright() as p:
|
||||
browser = None
|
||||
try:
|
||||
log("Starting glossary extraction process with Playwright", "INFO")
|
||||
browser, context = await self._get_browser_context(p, email, password)
|
||||
|
||||
page = await context.new_page()
|
||||
log("Navigating to BRAIN support forum glossary...", "INFO")
|
||||
await page.goto("https://support.worldquantbrain.com/hc/en-us/articles/4902349883927-Click-here-for-a-list-of-terms-and-their-definitions")
|
||||
|
||||
log("Extracting glossary content...", "INFO")
|
||||
content = await page.content()
|
||||
|
||||
terms = _parse_glossary_terms(content)
|
||||
|
||||
log(f"Extracted {len(terms)} glossary terms", "SUCCESS")
|
||||
return terms
|
||||
|
||||
except Exception as e:
|
||||
log(f"Glossary extraction failed: {str(e)}", "ERROR")
|
||||
# Re-raise to be handled by the MCP server wrapper
|
||||
raise
|
||||
finally:
|
||||
if browser:
|
||||
await browser.close()
|
||||
log("Browser closed.", "INFO")
|
||||
|
||||
async def search_forum_posts(self, email: str, password: str, search_query: str, max_results: int = 50, locale: str = "zh-cn") -> Dict[str, Any]:
|
||||
"""Search for posts on the forum using Playwright, with pagination."""
|
||||
async with async_playwright() as p:
|
||||
browser = None
|
||||
try:
|
||||
log(f"Starting forum search for '{search_query}'", "INFO")
|
||||
browser, context = await self._get_browser_context(p, email, password)
|
||||
|
||||
page = await context.new_page()
|
||||
|
||||
search_results = []
|
||||
page_num = 1
|
||||
|
||||
while len(search_results) < max_results:
|
||||
search_url = f"{self.base_url}/hc/{locale}/search?page={page_num}&query={search_query}#results"
|
||||
log(f"Navigating to search page: {search_url}", "INFO")
|
||||
|
||||
try:
|
||||
response = await page.goto(search_url)
|
||||
if response.status == 404:
|
||||
log(f"Page {page_num} not found. End of results.", "INFO")
|
||||
break
|
||||
await page.wait_for_selector('ul.search-results-list', timeout=15000)
|
||||
except Exception as e:
|
||||
log(f"Could not load search results on page {page_num}: {e}", "INFO")
|
||||
break
|
||||
|
||||
content = await page.content()
|
||||
soup = BeautifulSoup(content, 'html.parser')
|
||||
|
||||
results_on_page = soup.select('li.search-result-list-item')
|
||||
if not results_on_page:
|
||||
log("No more search results found.", "INFO")
|
||||
break
|
||||
|
||||
for result in results_on_page:
|
||||
title_element = result.select_one('h2.search-result-title a')
|
||||
snippet_element = result.select_one('.search-results-description')
|
||||
|
||||
if title_element:
|
||||
title = title_element.get_text(strip=True)
|
||||
link = title_element.get('href')
|
||||
|
||||
votes_element = result.select_one('.search-result-votes span[aria-hidden="true"]')
|
||||
votes_text = votes_element.get_text(strip=True) if votes_element else '0'
|
||||
votes_match = re.search(r'\d+', votes_text)
|
||||
votes = int(votes_match.group()) if votes_match else 0
|
||||
|
||||
comments_element = result.select_one('.search-result-meta-count span[aria-hidden="true"]')
|
||||
comments_text = comments_element.get_text(strip=True) if comments_element else '0'
|
||||
comments_match = re.search(r'\d+', comments_text)
|
||||
comments = int(comments_match.group()) if comments_match else 0
|
||||
|
||||
breadcrumbs_elements = result.select('ol.search-result-breadcrumbs li')
|
||||
breadcrumbs = [bc.get_text(strip=True) for bc in breadcrumbs_elements]
|
||||
|
||||
meta_group = result.select_one('ul.meta-group')
|
||||
author = 'Unknown'
|
||||
post_date = 'Unknown'
|
||||
if meta_group:
|
||||
meta_data_elements = meta_group.select('li.meta-data')
|
||||
if len(meta_data_elements) > 0:
|
||||
author = meta_data_elements[0].get_text(strip=True)
|
||||
if len(meta_data_elements) > 1:
|
||||
time_element = meta_data_elements[1].select_one('time')
|
||||
if time_element:
|
||||
post_date = time_element.get('datetime', time_element.get_text(strip=True))
|
||||
|
||||
snippet = snippet_element.get_text(strip=True) if snippet_element else ''
|
||||
|
||||
full_link = ''
|
||||
if link:
|
||||
if link.startswith('http'):
|
||||
full_link = link
|
||||
else:
|
||||
full_link = f"{self.base_url}{link}"
|
||||
|
||||
search_results.append({
|
||||
'title': title,
|
||||
'link': full_link,
|
||||
'snippet': snippet,
|
||||
'votes': votes,
|
||||
'comments': comments,
|
||||
'author': author,
|
||||
'date': post_date,
|
||||
'breadcrumbs': breadcrumbs
|
||||
})
|
||||
|
||||
if len(search_results) >= max_results:
|
||||
break
|
||||
|
||||
if len(search_results) >= max_results:
|
||||
break
|
||||
|
||||
page_num += 1
|
||||
|
||||
log(f"Found {len(search_results)} results for '{search_query}'", "SUCCESS")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"results": search_results,
|
||||
"total_found": len(search_results)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
log(f"Forum search failed: {str(e)}", "ERROR")
|
||||
raise
|
||||
finally:
|
||||
if browser:
|
||||
await browser.close()
|
||||
|
||||
async def read_full_forum_post(self, email: str, password: str, post_url_or_id: str, include_comments: bool = True) -> Dict[str, Any]:
|
||||
"""Read a complete forum post and all its comments using Playwright."""
|
||||
async with async_playwright() as p:
|
||||
browser = None
|
||||
try:
|
||||
log("Starting forum post reading process with Playwright", "INFO")
|
||||
|
||||
if post_url_or_id.startswith('http'):
|
||||
initial_url = post_url_or_id
|
||||
else:
|
||||
initial_url = f"https://support.worldquantbrain.com/hc/zh-cn/community/posts/{post_url_or_id}"
|
||||
|
||||
browser, context = await self._get_browser_context(p, email, password)
|
||||
page = await context.new_page()
|
||||
|
||||
# --- Get Main Post Content and Final URL ---
|
||||
log(f"Navigating to initial URL: {initial_url}", "INFO")
|
||||
await page.goto(initial_url)
|
||||
await page.wait_for_selector('.post-body, .article-body', timeout=15000)
|
||||
|
||||
# Get the final URL after any redirects
|
||||
base_url = re.sub(r'(\?|&)page=\d+', '', page.url).split('#')[0]
|
||||
log(f"Resolved to Base URL: {base_url}", "INFO")
|
||||
await page.wait_for_selector('.post-body, .article-body', timeout=15000)
|
||||
content = await page.content()
|
||||
soup = BeautifulSoup(content, 'html.parser')
|
||||
|
||||
post_data = {}
|
||||
title_element = soup.select_one('.post-title, h1.article-title, .article__title')
|
||||
post_data['title'] = title_element.get_text(strip=True) if title_element else 'Unknown Title'
|
||||
|
||||
author_span = soup.select_one('.post-author span[title]')
|
||||
post_data['author'] = author_span['title'] if author_span else 'Unknown Author'
|
||||
|
||||
body_element = soup.select_one('.post-body, .article-body')
|
||||
post_data['body'] = body_element.get_text(strip=True) if body_element else 'Body not found'
|
||||
|
||||
votes_element = soup.select_one('.vote-sum')
|
||||
date_element = soup.select_one('.post-meta .meta-data')
|
||||
post_data['details'] = {
|
||||
'votes': votes_element.get_text(strip=True) if votes_element else '0',
|
||||
'date': date_element.get_text(strip=True) if date_element else 'Unknown Date'
|
||||
}
|
||||
|
||||
# --- Get Comments with Pagination ---
|
||||
comments = []
|
||||
if include_comments:
|
||||
log("Starting comment extraction...", "INFO")
|
||||
page_num = 1
|
||||
while True:
|
||||
comment_url = f"{base_url}?page={page_num}#comments"
|
||||
log(f"Navigating to comment page: {comment_url}", "INFO")
|
||||
|
||||
try:
|
||||
response = await page.goto(comment_url)
|
||||
if response.status == 404:
|
||||
log(f"Page {page_num} returned 404. End of comments.", "INFO")
|
||||
break
|
||||
await page.wait_for_selector('.comment-list', timeout=10000)
|
||||
except Exception as e:
|
||||
log(f"Could not load page {page_num}: {e}. Assuming end of comments.", "INFO")
|
||||
break
|
||||
|
||||
comment_soup = BeautifulSoup(await page.content(), 'html.parser')
|
||||
comment_elements = comment_soup.select('.comment')
|
||||
|
||||
if not comment_elements:
|
||||
log(f"No comments found on page {page_num}. Ending extraction.", "INFO")
|
||||
break
|
||||
|
||||
log(f"Found {len(comment_elements)} comments on page {page_num}.", "INFO")
|
||||
|
||||
new_comments_found_on_page = 0
|
||||
for comment_element in comment_elements:
|
||||
author_span = comment_element.select_one('.comment-author span[title]')
|
||||
author_id = author_span['title'] if author_span else 'Unknown'
|
||||
|
||||
body_element = comment_element.select_one('.comment-body')
|
||||
date_element = comment_element.select_one('.comment-meta .meta-data')
|
||||
|
||||
comment_data = {
|
||||
'author': author_id,
|
||||
'body': body_element.get_text(strip=True) if body_element else '',
|
||||
'date': date_element.get_text(strip=True) if date_element else 'Unknown Date'
|
||||
}
|
||||
|
||||
if comment_data not in comments:
|
||||
comments.append(comment_data)
|
||||
new_comments_found_on_page += 1
|
||||
|
||||
if new_comments_found_on_page == 0 and page_num > 1:
|
||||
log(f"No new comments detected on page {page_num}. Ending extraction.", "INFO")
|
||||
break
|
||||
|
||||
page_num += 1
|
||||
|
||||
log(f"Extracted {len(comments)} comments in total.", "SUCCESS")
|
||||
return {
|
||||
"success": True, "post": post_data, "comments": comments, "total_comments": len(comments)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
log(f"Failed to read forum post: {str(e)}", "ERROR")
|
||||
raise
|
||||
finally:
|
||||
if browser:
|
||||
await browser.close()
|
||||
|
||||
# Initialize forum client
|
||||
forum_client = ForumClient()
|
||||
|
||||
# The main block is for testing and won't be run by the MCP server.
|
||||
if __name__ == "__main__":
|
||||
print("📚 WorldQuant BRAIN Forum Functions - This script provides the ForumClient class.", file=sys.stderr)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"credentials": {
|
||||
"email": "youremail@mai.com",
|
||||
"password": "yourpassword"
|
||||
},
|
||||
"api_settings": {
|
||||
"base_url": "https://api.worldquantbrain.com",
|
||||
"timeout": 30,
|
||||
"retry_attempts": 3
|
||||
},
|
||||
"forum_settings": {
|
||||
"base_url": "https://support.worldquantbrain.com",
|
||||
"headless": true,
|
||||
"timeout": 30
|
||||
},
|
||||
"simulation_defaults": {
|
||||
"type": "REGULAR",
|
||||
"instrument_type": "EQUITY",
|
||||
"region": "USA",
|
||||
"universe": "TOP3000",
|
||||
"delay": 1,
|
||||
"decay": 0,
|
||||
"neutralization": "NONE",
|
||||
"truncation": 0,
|
||||
"test_period": "P0Y0M",
|
||||
"unit_handling": "NONE",
|
||||
"nan_handling": "NONE",
|
||||
"language": "FASTEXPR",
|
||||
"visualization": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
# Guide: Setting Up Playwright with a Local Chrome Executable
|
||||
|
||||
This guide explains how to configure Playwright to use a locally extracted Google Chrome browser. This is useful in environments where you don't have `sudo` permissions to install Chrome or its dependencies system-wide.
|
||||
|
||||
## The Problem
|
||||
|
||||
When running a Playwright script, you might encounter an error like this:
|
||||
|
||||
```
|
||||
playwright._impl._errors.Error: BrowserType.launch: Executable doesn't exist at ...
|
||||
```
|
||||
|
||||
This means Playwright cannot find the Chrome browser executable in its default search paths.
|
||||
|
||||
## The Solution: Local Extraction
|
||||
|
||||
The solution is to download the Chrome `.deb` package, extract the executable, and then point your Playwright script to it.
|
||||
|
||||
### Step 0: pip install playwright
|
||||
|
||||
Before you begin, make sure you have Playwright installed. You can install it using pip:
|
||||
|
||||
```bash
|
||||
pip install playwright
|
||||
```
|
||||
|
||||
### Step 1: Download the Chrome `.deb` Package
|
||||
|
||||
You can download the latest stable version of Google Chrome for Debian/Ubuntu-based systems using `wget`:
|
||||
|
||||
```bash
|
||||
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||
```
|
||||
|
||||
### Step 2: Extract the Executable
|
||||
|
||||
Once you have the `.deb` file, you can extract its contents without installing it.
|
||||
|
||||
1. **Extract the `.deb` archive:** The `ar` command extracts the contents of the `.deb` file.
|
||||
|
||||
```bash
|
||||
ar x google-chrome-stable_current_amd64.deb
|
||||
```
|
||||
|
||||
This will create several files, including `data.tar.xz`.
|
||||
|
||||
2. **Extract the data archive:** The `data.tar.xz` file contains the actual program files.
|
||||
|
||||
```bash
|
||||
tar -xf data.tar.xz
|
||||
```
|
||||
|
||||
This will create an `opt` directory in your current location.
|
||||
|
||||
### Step 3: Locate the Chrome Executable
|
||||
|
||||
The Chrome executable is now located at `./opt/google/chrome/chrome`.
|
||||
|
||||
### Step 4: Configure Your Playwright Script
|
||||
|
||||
In your Python script forum_functions.py, you need to tell Playwright where to find the Chrome executable by using the `executable_path` argument when launching the browser.
|
||||
|
||||
It is best practice to use an **absolute path** to avoid issues with the script's working directory. You can construct the absolute path by combining your project's root directory with the relative path to the executable.
|
||||
|
||||
Here is an example:
|
||||
|
||||
```python
|
||||
import os
|
||||
from playwright.async_api import async_playwright
|
||||
import asyncio
|
||||
|
||||
async def main():
|
||||
# Get the absolute path to the executable
|
||||
project_dir = os.getcwd() # Or specify your project's absolute path
|
||||
executable_path = os.path.join(project_dir, 'opt/google/chrome/chrome')
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(
|
||||
executable_path=executable_path,
|
||||
args=['--headless=new', '--no-sandbox']
|
||||
)
|
||||
page = await browser.new_page()
|
||||
await page.goto('http://playwright.dev')
|
||||
print(await page.title())
|
||||
await browser.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
By providing the absolute `executable_path`, you are explicitly telling Playwright where to find the browser, making your script more robust.
|
||||
|
||||
## Alternative: `playwright install`
|
||||
|
||||
If you have `sudo` permissions, the recommended way to install the browsers for Playwright is to use the following command:
|
||||
|
||||
```bash
|
||||
playwright install --with-deps
|
||||
```
|
||||
|
||||
This will download the browser and also install all the necessary system dependencies.
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
check_install_packages.py
|
||||
|
||||
Simple script that hardcodes the project's required packages and ensures they are installed
|
||||
before running the server or tests. It will install missing packages (or upgrade if below
|
||||
required version) by invoking pip via the running Python interpreter.
|
||||
|
||||
Usage: python check_install_packages.py
|
||||
|
||||
This script is intentionally conservative: it uses the distribution/package names from
|
||||
`requirements.txt` and calls pip to install the exact spec (e.g. "requests>=2.31.0").
|
||||
It prints a concise summary at the end.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
import subprocess
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
import re
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
# Hardcoded package requirements (from requirements.txt)
|
||||
REQUIRED_PACKAGES: List[str] = [
|
||||
"fastmcp>=0.1.0",
|
||||
"requests>=2.31.0",
|
||||
"pandas>=2.0.0",
|
||||
"selenium>=4.15.0",
|
||||
"beautifulsoup4>=4.12.0",
|
||||
"pydantic>=2.0.0",
|
||||
"email-validator>=2.0.0",
|
||||
"aiohttp>=3.8.0",
|
||||
"webdriver-manager>=4.0.0"
|
||||
]
|
||||
|
||||
|
||||
def parse_spec(spec: str) -> Tuple[str, Optional[str]]:
|
||||
"""Return (name, min_version) for a spec like 'pkg>=1.2.3'."""
|
||||
if ">=" in spec:
|
||||
name, ver = spec.split(">=", 1)
|
||||
return name.strip(), ver.strip()
|
||||
return spec.strip(), None
|
||||
|
||||
|
||||
def version_tuple(v: str) -> List[int]:
|
||||
parts = re.split(r"[^0-9]+", v)
|
||||
nums = []
|
||||
for p in parts:
|
||||
if p == "":
|
||||
continue
|
||||
try:
|
||||
nums.append(int(p))
|
||||
except ValueError:
|
||||
# Non-numeric part, stop parsing further
|
||||
break
|
||||
return nums
|
||||
|
||||
|
||||
def is_version_sufficient(installed: str, required: str) -> bool:
|
||||
if not installed:
|
||||
return False
|
||||
try:
|
||||
i_parts = version_tuple(installed)
|
||||
r_parts = version_tuple(required)
|
||||
# Compare element-wise
|
||||
for a, b in zip(i_parts, r_parts):
|
||||
if a > b:
|
||||
return True
|
||||
if a < b:
|
||||
return False
|
||||
# If equal up to the length of required, installed is sufficient if it's at least as long
|
||||
return len(i_parts) >= len(r_parts)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def install_package(spec: str) -> Tuple[bool, str]:
|
||||
"""Install a package spec using pip. Streams output live and returns (success, output)."""
|
||||
# Use -v for verbose pip output; capture stdout/stderr while streaming to console
|
||||
cmd = [sys.executable, "-m", "pip", "install", "-v", spec]
|
||||
try:
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
output_lines = []
|
||||
if proc.stdout:
|
||||
for line in proc.stdout:
|
||||
output_lines.append(line)
|
||||
# print each pip line as it arrives for visibility
|
||||
try:
|
||||
print(line.rstrip())
|
||||
except Exception:
|
||||
# fallback: print raw line
|
||||
print(line)
|
||||
proc.wait()
|
||||
out = "".join(output_lines)
|
||||
success = proc.returncode == 0
|
||||
return success, out
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def main():
|
||||
results = []
|
||||
print("Checking required packages...\n")
|
||||
for spec in REQUIRED_PACKAGES:
|
||||
name, min_ver = parse_spec(spec)
|
||||
installed_ver = None
|
||||
try:
|
||||
installed_ver = importlib.metadata.version(name)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
installed_ver = None
|
||||
except Exception:
|
||||
# Some packages may have different distribution vs import names; try best-effort
|
||||
installed_ver = None
|
||||
|
||||
needs_install = False
|
||||
action = "present"
|
||||
if installed_ver is None:
|
||||
needs_install = True
|
||||
action = "install"
|
||||
elif min_ver is not None:
|
||||
if not is_version_sufficient(installed_ver, min_ver):
|
||||
needs_install = True
|
||||
action = f"upgrade (installed {installed_ver} < required {min_ver})"
|
||||
|
||||
if needs_install:
|
||||
print(f"-> {name}: {action} via pip ({spec})")
|
||||
success, output = install_package(spec)
|
||||
# Attempt to read installed version after install
|
||||
installed_after = None
|
||||
if success:
|
||||
try:
|
||||
installed_after = importlib.metadata.version(name)
|
||||
except Exception:
|
||||
installed_after = None
|
||||
if installed_after:
|
||||
print(f" -> {name} now installed as: {installed_after}")
|
||||
|
||||
results.append({
|
||||
"name": name,
|
||||
"spec": spec,
|
||||
"installed_version_before": installed_ver,
|
||||
"installed_version_after": installed_after,
|
||||
"action": action,
|
||||
"success": success,
|
||||
"output": output
|
||||
})
|
||||
else:
|
||||
print(f"-> {name}: OK (installed: {installed_ver})")
|
||||
results.append({
|
||||
"name": name,
|
||||
"spec": spec,
|
||||
"installed_version_before": installed_ver,
|
||||
"action": "none",
|
||||
"success": True,
|
||||
"output": ""
|
||||
})
|
||||
|
||||
# Summary
|
||||
print("\nSummary:")
|
||||
installed_count = sum(1 for r in results if r["success"] and r["action"] == "none")
|
||||
installed_or_fixed = sum(1 for r in results if r["success"] and r["action"] != "none")
|
||||
failed = [r for r in results if not r["success"]]
|
||||
|
||||
print(f" Packages already OK: {installed_count}")
|
||||
print(f" Packages installed/upgraded by this script: {installed_or_fixed}")
|
||||
print(f" Packages failed to install: {len(failed)}")
|
||||
if failed:
|
||||
for f in failed:
|
||||
print(f" - {f['name']}: attempted '{f['spec']}' -> error (see details below)")
|
||||
|
||||
# If any failed, print their outputs for debugging
|
||||
if failed:
|
||||
print("\nFailed install details:\n")
|
||||
for f in failed:
|
||||
print("-----")
|
||||
print(f"Package: {f['name']}")
|
||||
print(f"Spec: {f['spec']}")
|
||||
print(f"Installed before: {f.get('installed_version_before')}")
|
||||
print(f"Installed after: {f.get('installed_version_after')}")
|
||||
print("Output:")
|
||||
print(f.get("output", "(no output)"))
|
||||
|
||||
print("\nDone.")
|
||||
# Exit with non-zero code if any install failed
|
||||
if failed:
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user