-
Notifications
You must be signed in to change notification settings - Fork 10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Initial Python Repo Manager #97
Changes from all commits
31d0ac2
8bc6b5c
8de212b
9d68e03
b1b8dbf
58f0e1f
e141197
1ee9b1b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -33,4 +33,5 @@ repos: | |
[ | ||
"types-mock==5.0.*", | ||
"types-PyYAML==6.0", | ||
"types-toml~=0.10", | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
from .requirements_txt_file_parser import RequirementsTxtParser | ||
from .pyproject_toml_file_parser import PyprojectTomlParser | ||
from .setup_cfg_file_parser import SetupCfgParser | ||
from .setup_py_file_parser import SetupPyParser |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
from abc import ABC, abstractmethod | ||
|
||
from pathlib import Path | ||
from typing import List | ||
from .package_store import PackageStore | ||
from packaging.requirements import Requirement | ||
|
||
|
||
class BaseParser(ABC): | ||
def __init__(self, parent_directory: Path): | ||
self.parent_directory = parent_directory | ||
|
||
@property | ||
@abstractmethod | ||
def file_name(self): | ||
... # pragma: no cover | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just a tiny thing but I think you can have the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oh I hope so! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. black doesn't like inline ... so I left it as is |
||
|
||
def _parse_dependencies(self, dependencies: List[str]): | ||
return [ | ||
Requirement(line) | ||
for x in dependencies | ||
# Skip empty lines and comments | ||
if (line := x.strip()) and not line.startswith("#") | ||
] | ||
|
||
@abstractmethod | ||
def _parse_file(self, file: Path): | ||
... # pragma: no cover | ||
|
||
def find_file_locations(self) -> List[Path]: | ||
return list(Path(self.parent_directory).rglob(self.file_name)) | ||
|
||
def parse(self) -> list[PackageStore]: | ||
""" | ||
Find 0 or more project config or dependency files within a project repo. | ||
""" | ||
stores = [] | ||
req_files = self.find_file_locations() | ||
for file in req_files: | ||
store = self._parse_file(file) | ||
stores.append(store) | ||
return stores |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
from dataclasses import dataclass | ||
from packaging.requirements import Requirement | ||
|
||
|
||
@dataclass | ||
class PackageStore: | ||
type: str | ||
file: str | ||
dependencies: list[Requirement] | ||
py_versions: list[str] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
from codemodder.project_analysis.file_parsers.package_store import PackageStore | ||
from pathlib import Path | ||
import toml | ||
|
||
from .base_parser import BaseParser | ||
|
||
|
||
class PyprojectTomlParser(BaseParser): | ||
@property | ||
def file_name(self): | ||
return "pyproject.toml" | ||
|
||
def _parse_dependencies_from_toml(self, toml_data: dict): | ||
# todo: handle cases for | ||
# 1. no dependencies | ||
return self._parse_dependencies(toml_data["project"]["dependencies"]) | ||
|
||
def _parse_py_versions(self, toml_data: dict): | ||
# todo: handle cases for | ||
# 1. no requires-python | ||
# 2. multiple requires-python such as "">3.5.2"", ">=3.11.1,<3.11.2" | ||
return [toml_data["project"]["requires-python"]] | ||
|
||
def _parse_file(self, file: Path): | ||
data = toml.load(file) | ||
# todo: handle no "project" in data | ||
|
||
return PackageStore( | ||
type=self.file_name, | ||
file=str(file), | ||
dependencies=self._parse_dependencies_from_toml(data), | ||
py_versions=self._parse_py_versions(data), | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
from codemodder.project_analysis.file_parsers.package_store import PackageStore | ||
from pathlib import Path | ||
from .base_parser import BaseParser | ||
|
||
|
||
class RequirementsTxtParser(BaseParser): | ||
@property | ||
def file_name(self): | ||
return "requirements.txt" | ||
|
||
def _parse_file(self, file: Path): | ||
with open(file, "r", encoding="utf-8") as f: | ||
lines = f.readlines() | ||
|
||
return PackageStore( | ||
type=self.file_name, | ||
file=str(file), | ||
dependencies=self._parse_dependencies(lines), | ||
# requirements.txt files do not declare py versions explicitly | ||
# though we could create a heuristic by analyzing each dependency | ||
# and extracting py versions from them. | ||
py_versions=[], | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
from codemodder.project_analysis.file_parsers.package_store import PackageStore | ||
from pathlib import Path | ||
import configparser | ||
|
||
from .base_parser import BaseParser | ||
|
||
|
||
class SetupCfgParser(BaseParser): | ||
@property | ||
def file_name(self): | ||
return "setup.cfg" | ||
|
||
def _parse_dependencies_from_cfg(self, config: configparser.ConfigParser): | ||
# todo: handle cases for | ||
# 1. no dependencies, no options dict | ||
# setup_requires, tests_require, extras_require | ||
dependency_lines = config["options"]["install_requires"].split("\n") | ||
return self._parse_dependencies(dependency_lines) | ||
|
||
def _parse_py_versions(self, config: configparser.ConfigParser): | ||
# todo: handle cases for | ||
# 1. no options/ no requires-python | ||
# 2. various requires-python such as "">3.5.2"", ">=3.11.1,<3.11.2" | ||
return [config["options"]["python_requires"]] | ||
|
||
def _parse_file(self, file: Path): | ||
config = configparser.ConfigParser() | ||
config.read(file) | ||
|
||
# todo: handle no config, no "options" in config | ||
|
||
return PackageStore( | ||
type=self.file_name, | ||
file=str(file), | ||
dependencies=self._parse_dependencies_from_cfg(config), | ||
py_versions=self._parse_py_versions(config), | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
from codemodder.project_analysis.file_parsers.package_store import PackageStore | ||
from pathlib import Path | ||
import libcst as cst | ||
from libcst import matchers | ||
from packaging.requirements import Requirement | ||
|
||
from .base_parser import BaseParser | ||
from .utils import clean_simplestring | ||
|
||
|
||
class SetupPyParser(BaseParser): | ||
@property | ||
def file_name(self): | ||
return "setup.py" | ||
|
||
def _parse_dependencies(self, dependencies): | ||
return [ | ||
Requirement(line) | ||
for x in dependencies | ||
# Skip empty lines and comments | ||
if (line := clean_simplestring(x.value)) and not line.startswith("#") | ||
] | ||
|
||
def _parse_dependencies_from_cst(self, cst_dependencies): | ||
# todo: handle cases for | ||
# 1. no dependencies, | ||
return self._parse_dependencies(cst_dependencies) | ||
|
||
def _parse_py_versions(self, version_str): | ||
# todo: handle for multiple versions | ||
return [clean_simplestring(version_str)] | ||
|
||
def _parse_file(self, file: Path): | ||
visitor = SetupCallVisitor() | ||
with open(str(file), "r", encoding="utf-8") as f: | ||
# todo: handle failure in parsing | ||
module = cst.parse_module(f.read()) | ||
module.visit(visitor) | ||
|
||
# todo: handle no python_requires, install_requires | ||
|
||
return PackageStore( | ||
type=self.file_name, | ||
file=str(file), | ||
dependencies=self._parse_dependencies_from_cst(visitor.install_requires), | ||
py_versions=self._parse_py_versions(visitor.python_requires), | ||
) | ||
|
||
|
||
class SetupCallVisitor(cst.CSTVisitor): | ||
def __init__(self): | ||
self.python_requires = None | ||
self.install_requires = None | ||
# todo setup_requires, tests_require, extras_require | ||
|
||
def visit_Call(self, node: cst.Call) -> None: | ||
# todo: only handle setup from setuptools, not others tho unlikely | ||
if matchers.matches(node.func, cst.Name(value="setup")): | ||
visitor = SetupArgVisitor() | ||
node.visit(visitor) | ||
self.python_requires = visitor.python_requires | ||
self.install_requires = visitor.install_requires | ||
|
||
|
||
class SetupArgVisitor(cst.CSTVisitor): | ||
def __init__(self): | ||
self.python_requires = None | ||
self.install_requires = None | ||
|
||
def visit_Arg(self, node: cst.Arg) -> None: | ||
if matchers.matches(node.keyword, cst.Name(value="python_requires")): | ||
# todo: this works for `python_requires=">=3.7",` but what about | ||
# a list of versions? | ||
self.python_requires = node.value.value | ||
if matchers.matches(node.keyword, cst.Name(value="install_requires")): | ||
# todo: could it be something other than a list? | ||
self.install_requires = node.value.elements |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
import libcst as cst | ||
|
||
|
||
def clean_simplestring(node: cst.SimpleString | str) -> str: | ||
if isinstance(node, str): | ||
return node.strip('"') | ||
return node.raw_value |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
from functools import cached_property | ||
from pathlib import Path | ||
from codemodder.project_analysis.file_parsers import ( | ||
RequirementsTxtParser, | ||
PyprojectTomlParser, | ||
SetupCfgParser, | ||
SetupPyParser, | ||
) | ||
from codemodder.project_analysis.file_parsers.package_store import PackageStore | ||
|
||
|
||
class PythonRepoManager: | ||
def __init__(self, parent_directory: Path): | ||
self.parent_directory = parent_directory | ||
self._potential_stores = [ | ||
RequirementsTxtParser, | ||
PyprojectTomlParser, | ||
SetupCfgParser, | ||
SetupPyParser, | ||
] | ||
|
||
@cached_property | ||
def package_stores(self) -> list[PackageStore]: | ||
return self._parse_all_stores() | ||
|
||
def _parse_all_stores(self) -> list[PackageStore]: | ||
discovered_pkg_stores: list[PackageStore] = [] | ||
for store in self._potential_stores: | ||
discovered_pkg_stores.extend(store(self.parent_directory).parse()) | ||
return discovered_pkg_stores |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very minor thing but we should add the type declaration at the class definition above.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you referring to something else that's not already line 43,
repo_manager: PythonRepoManager,
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, it should be added on line 35 above too.