Skip to content

Commit d106508

Browse files
committed
improves pypi access
1 parent 5f33747 commit d106508

File tree

2 files changed

+76
-102
lines changed

2 files changed

+76
-102
lines changed

ngwidgets/projects.py

Lines changed: 75 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
"""
22
Created on 2023-12-14
33
4-
This module, developed as part of the ngwidgets package under the instruction of WF, provides
5-
classes and methods for interacting with the Python Package Index (PyPI). It includes the
6-
`Project` data class for representing software projects and the `PyPi` class for searching
7-
and retrieving package information from PyPI. The code facilitates the creation of tools and
4+
This module, developed as part of the ngwidgets package under the instruction of WF, provides
5+
classes and methods for interacting with the Python Package Index (PyPI). It includes the
6+
`Project` data class for representing software projects and the `PyPi` class for searching
7+
and retrieving package information from PyPI. The code facilitates the creation of tools and
88
applications that interact with PyPI for information about Python packages.
99
10-
Prompts for LLM:
10+
Prompts for LLM:
1111
- Create Python classes Project and Projects (holding a list of Project elements) for interacting with PyPI and github, including search functionality.
1212
- Develop a data class in Python to represent a software project with the attributes.
1313
name (str): The name of the project.
@@ -28,7 +28,7 @@
2828
downloads (int): Number of downloads from PyPI.
2929
categories (List[str]): Categories associated with the project.
3030
version (str): The current version of the project on PyPI.
31-
31+
3232
- Implement methods to search PyPI and github for packages/repos that represent projects and retrieve detailed package information on a given topic.
3333
- allow saving and loading the collected projects
3434
@@ -649,108 +649,82 @@ class PyPi:
649649
def __init__(self, debug: bool = False):
650650
self.base_url = "https://pypi.org/pypi"
651651
self.debug = debug
652+
self.cache_dir = Path.home() / ".pypi"
653+
self.cache_dir.mkdir(exist_ok=True)
654+
self.cache_file = self.cache_dir / "pypi-package-list.json"
655+
self.cache_ttl = 3600 # 1 hour
656+
657+
658+
def pypi_request(self, url: str, headers: Optional[Dict] = None) -> Optional[Dict]:
659+
"""Make pypi request with simple file caching."""
660+
data = None
661+
if self.cache_file.exists():
662+
file_age = time.time() - self.cache_file.stat().st_mtime
663+
if file_age < self.cache_ttl:
664+
try:
665+
with open(self.cache_file) as f:
666+
data = json.load(f)
667+
except json.JSONDecodeError:
668+
pass
669+
670+
if not data:
671+
data = self._make_request(url, headers)
672+
if data:
673+
with open(self.cache_file, "w") as f:
674+
json.dump(data, f)
675+
676+
return data
677+
678+
def _make_request(self, url: str, headers: Optional[Dict] = None) -> Optional[Dict]:
679+
"""Make a request to PyPI API and return JSON response."""
680+
try:
681+
request = urllib.request.Request(url, headers=headers if headers else {})
682+
response = urllib.request.urlopen(request)
683+
if response.getcode() == 200:
684+
json_response = json.loads(response.read())
685+
return json_response
686+
except (urllib.error.URLError, json.JSONDecodeError) as e:
687+
if self.debug:
688+
print(f"Error in request to {url}: {e}")
689+
return None
652690

653-
def search_projects(self, term: str, limit: int = None) -> List[Project]:
654-
"""
655-
Search for packages on PyPI and return them as Project instances.
656-
657-
Args:
658-
term (str): The search term.
659-
limit (int, optional): Maximum number of results to return.
660-
661-
Returns:
662-
List[Project]: A list of Project instances representing the search results.
663-
"""
664-
package_dicts = self.search_packages(term, limit)
665-
return [Project.from_pypi(pkg) for pkg in package_dicts]
666-
667-
def get_package_info(self, package_name: str) -> dict:
668-
"""
669-
Get detailed information about a package from PyPI using urllib.
670-
671-
Args:
672-
package_name (str): The name of the package to retrieve information for.
673-
674-
Returns:
675-
dict: A dictionary containing package information.
676691

677-
Raises:
678-
urllib.error.URLError: If there is an issue with the URL.
679-
ValueError: If the response status code is not 200.
680-
"""
692+
def get_package_info(self, package_name: str) -> Optional[Dict]:
693+
"""Get detailed info for a specific package."""
681694
url = f"{self.base_url}/{package_name}/json"
682-
683-
response = urllib.request.urlopen(url)
684-
685-
if response.getcode() != 200:
686-
raise ValueError(
687-
f"Failed to fetch package info for {package_name}. Status code: {response.getcode()}"
688-
)
689-
690-
package_data = json.loads(response.read())
691-
695+
package_data = self._make_request(url)
696+
if package_data:
697+
package_data['package_url'] = f"https://pypi.org/project/{package_name}"
692698
return package_data
693699

694-
def search_packages(self, term: str, limit: int = None) -> list:
695-
"""Search a package in the pypi repositories and retrieve detailed package information.
696-
697-
Args:
698-
term (str): The search term.
699-
limit (int, optional): Maximum number of results to return.
700-
701-
Returns:
702-
List[Dict]: A list of dictionaries containing detailed package information.
703-
704-
see https://raw.githubusercontent.com/shubhodeep9/pipsearch/master/pipsearch/api.py
705-
"""
706-
# Constructing a search URL and sending the request
707-
url = "https://pypi.org/search/?q=" + term
708-
try:
709-
response = urllib.request.urlopen(url)
710-
text = response.read()
711-
except Exception as e:
712-
raise e
713-
714-
soup = BeautifulSoup(text, "html.parser")
715-
packagestable = soup.find("ul", {"class": "unstyled"})
716-
# Constructing the result list
700+
def search_packages(self, term: str, limit: Optional[int] = None) -> List[Dict]:
701+
"""Search packages using PyPI JSON API."""
702+
headers = {"Accept": "application/vnd.pypi.simple.v1+json"}
703+
url = "https://pypi.org/simple/"
717704
packages = []
718705

719-
# If no package exists then there is no table displayed hence soup.table will be None
720-
if packagestable is None:
721-
return packages
706+
projects = self.pypi_request(url, headers)
707+
if projects:
708+
matched_projects = [
709+
p for p in projects['projects']
710+
if term.lower() in p['name'].lower()
711+
]
712+
if limit:
713+
matched_projects = matched_projects[:limit]
722714

723-
packagerows: ResultSet[Tag] = packagestable.findAll("li")
715+
for project in matched_projects:
716+
package_info = self.get_package_info(project['name'])
717+
if package_info:
718+
packages.append(package_info)
724719

725-
if self.debug:
726-
print(f"found len{packagerows} package rows")
727-
if limit:
728-
selected_rows = packagerows[:limit]
729-
else:
730-
selected_rows = packagerows
731-
for package in selected_rows:
732-
nameSelector = package.find("span", {"class": "package-snippet__name"})
733-
if nameSelector is None:
734-
continue
735-
name = nameSelector.text
736-
737-
link = ""
738-
if package.a is not None:
739-
href = package.a["href"]
740-
if isinstance(href, list):
741-
href = href[0]
742-
link = "https://pypi.org" + href
743-
744-
description = (
745-
package.find("p", {"class": "package-snippet__description"}) or Tag()
746-
).text
747-
748-
version = (
749-
package.find("span", {"class": "package-snippet__version"}) or Tag()
750-
).text
751-
package_info = self.get_package_info(name)
752-
package_info["package_url"] = link
753-
packages.append(package_info)
754-
755-
# returning the result list back
756720
return packages
721+
722+
def search_projects(self, term: str, limit: Optional[int] = None) -> List[Project]:
723+
"""Get PyPI package info as Project instances."""
724+
package_dicts = self.search_packages(term, limit)
725+
projects = []
726+
for package in package_dicts:
727+
if package:
728+
project = Project.from_pypi(package)
729+
projects.append(project)
730+
return projects

tests/test_nicegui_components.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class TestNiceguiProjects(Basetest):
2424
Test cases for the nicegui_projects module.
2525
"""
2626

27-
def setUp(self, debug=False, profile=True):
27+
def setUp(self, debug=True, profile=True):
2828
Basetest.setUp(self, debug=debug, profile=profile)
2929
self.pypi_test_projects = [
3030
(

0 commit comments

Comments
 (0)