Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/twyn/base/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,20 @@


MANUAL_INPUT_SOURCE = "manual_input"
"""Source identifier for manually provided dependencies."""

SELECTOR_METHOD_MAPPING: dict[str, type[selectors.AbstractSelector]] = {
"first-letter": selectors.FirstLetterExact,
"nearby-letter": selectors.FirstLetterNearbyInKeyboard,
"all": selectors.AllSimilar,
}
"""Mapping of selector method names to their corresponding classes."""

SELECTOR_METHOD_KEYS = set(SELECTOR_METHOD_MAPPING.keys())
"""Set of available selector method names."""

SelectorMethod = Literal["first-letter", "nearby-letter", "all"]
"""Type alias for valid selector method strings."""

DEPENDENCY_FILE_MAPPING: dict[str, type[AbstractParser]] = {
"requirements.txt": dependency_parser.RequirementsTxtParser,
Expand All @@ -29,13 +34,24 @@
"package-lock.json": dependency_parser.PackageLockJsonParser,
"yarn.lock": dependency_parser.YarnLockParser,
}
"""Mapping of dependency file names to their parser classes."""


DEFAULT_SELECTOR_METHOD = "all"
"""Default method for selecting similar packages."""

DEFAULT_PROJECT_TOML_FILE = "pyproject.toml"
"""Default filename for project configuration."""

DEFAULT_TWYN_TOML_FILE = "twyn.toml"
"""Default filename for Twyn-specific configuration."""

DEFAULT_USE_CACHE = True
"""Default setting for cache usage."""

DEFAULT_RECURSIVE = False
"""Default setting for recursive processing."""


PackageEcosystems: TypeAlias = Literal["pypi", "npm"]
"""Type alias for supported package ecosystems."""
1 change: 1 addition & 0 deletions src/twyn/base/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def __init__(self, message: str = "") -> None:
super().__init__(message)

def show(self, file: Optional[IO[Any]] = None) -> None:
"""Display the error message."""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🦉 ✨ Quality

Severity: LOW 🔵

"""Display the error message."""

This docstring is a bit vague. The method logs the error message, so a more descriptive docstring would be more accurate and helpful.

Suggested change
"""Display the error message."""
"""Logs the error message using the configured logger."""
More information about this comment
  • File: src/twyn/base/exceptions.py
  • Line: 32
  • Relative line: 4
  • With suggestion: Yes
  • Suggestion ready for replacement: Yes

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI correcting AI. gotta love it

logger.debug(self.format_message(), exc_info=True)
logger.error(self.format_message(), exc_info=False)

Expand Down
Empty file removed src/twyn/base/utils.py
Empty file.
5 changes: 5 additions & 0 deletions src/twyn/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
@click.group()
@click.version_option(__version__, "--version")
def entry_point() -> None:
"""Provide main CLI entry point for Twyn."""
pass


Expand Down Expand Up @@ -188,13 +189,15 @@ def run( # noqa: C901

@entry_point.group()
def allowlist() -> None:
"""Manage package allowlist configuration."""
pass


@allowlist.command()
@click.option("--config", type=click.STRING)
@click.argument("package_name")
def add(package_name: str, config: str) -> None:
"""Add package to allowlist."""
fh = FileHandler(config or ConfigHandler.get_default_config_file_path())
ConfigHandler(fh).add_package_to_allowlist(package_name)

Expand All @@ -203,12 +206,14 @@ def add(package_name: str, config: str) -> None:
@click.option("--config", type=click.STRING)
@click.argument("package_name")
def remove(package_name: str, config: str) -> None:
"""Remove package from allowlist."""
fh = FileHandler(config or DEFAULT_PROJECT_TOML_FILE)
ConfigHandler(fh).remove_package_from_allowlist(package_name)


@entry_point.group()
def cache() -> None:
"""Manage cache operations."""
pass


Expand Down
20 changes: 20 additions & 0 deletions src/twyn/config/config_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,27 +33,43 @@ class TwynConfiguration:
"""Fully resolved configuration for Twyn."""

dependency_files: set[str]
"""Set of dependency file paths to analyze."""
selector_method: str
"""Method for selecting similar packages."""
allowlist: set[str]
"""Set of package names to allow without checking."""
pypi_source: Optional[str]
"""Alternative PyPI source URL."""
npm_source: Optional[str]
"""Alternative npm source URL."""
use_cache: bool
"""Whether to use cached trusted packages."""
package_ecosystem: Optional[PackageEcosystems]
"""Target package ecosystem for analysis."""
recursive: Optional[bool]
"""Whether to recursively search for dependency files."""


@dataclass
class ReadTwynConfiguration:
"""Configuration for twyn as set by the user. It may have None values."""

dependency_files: Optional[set[str]] = field(default_factory=set)
"""Optional set of dependency file paths to analyze."""
selector_method: Optional[str] = None
"""Optional method for selecting similar packages."""
allowlist: set[str] = field(default_factory=set)
"""Set of package names to allow without checking."""
pypi_source: Optional[str] = None
"""Optional alternative PyPI source URL."""
npm_source: Optional[str] = None
"""Optional alternative npm source URL."""
use_cache: Optional[bool] = None
"""Optional setting for using cached trusted packages."""
package_ecosystem: Optional[PackageEcosystems] = None
"""Optional target package ecosystem for analysis."""
recursive: Optional[bool] = None
"""Optional setting for recursive dependency file search."""


class ConfigHandler:
Expand Down Expand Up @@ -200,11 +216,13 @@ def _write_config(self, toml: TOMLDocument, config: ReadTwynConfiguration) -> No
self._write_toml(toml)

def _write_toml(self, toml: TOMLDocument) -> None:
"""Write TOML document to file."""
if not self.file_handler:
raise ConfigFileNotConfiguredError("Config file not configured. Cannot perform write operation.")
self.file_handler.write(dumps(toml))

def _read_toml(self) -> TOMLDocument:
"""Read TOML document from file."""
if not self.file_handler:
raise ConfigFileNotConfiguredError("Config file not configured. Cannot perform read operation.")
try:
Expand All @@ -227,6 +245,8 @@ def get_default_config_file_path() -> str:


def _serialize_config(x: Any) -> Union[Any, str, list[Any]]:
"""Serialize configuration values for TOML format."""

def _value_to_for_config(v: Any) -> Union[str, list[Any], Any]:
if isinstance(v, Enum):
return v.name
Expand Down
21 changes: 0 additions & 21 deletions src/twyn/dependency_managers/dependency_manager.py

This file was deleted.

6 changes: 6 additions & 0 deletions src/twyn/dependency_managers/managers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,25 @@ class BaseDependencyManager:
"""

name: str
"""Name identifier for the package ecosystem."""
trusted_packages_source: type[AbstractPackageReference]
"""Reference class for trusted packages source."""
dependency_files: set[str]
"""Set of supported dependency file names."""

@classmethod
def matches_dependency_file(cls, dependency_file: str) -> bool:
"""Check if this manager can handle the given dependency file."""
return Path(dependency_file).name in cls.dependency_files

@classmethod
def matches_ecosystem_name(cls, name: str) -> bool:
"""Check if this manager matches the given ecosystem name."""
return cls.name == Path(name).name.lower()

@classmethod
def get_alternative_source(cls, sources: dict[str, str]) -> Optional[str]:
"""Get alternative source URL for this ecosystem from sources dict."""
match = [x for x in sources if x == cls.name]

return sources[match[0]] if match else None
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,8 @@
@dataclass
class NpmDependencyManager(BaseDependencyManager):
name = "npm"
"""Name of the npm package ecosystem."""
trusted_packages_source = TopNpmReference
"""Reference source for trusted npm packages."""
dependency_files = {PACKAGE_LOCK_JSON, YARN_LOCK}
"""Set of supported npm dependency file names."""
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,8 @@
@dataclass
class PypiDependencyManager(BaseDependencyManager):
name = "pypi"
"""Name of the PyPI package ecosystem."""
trusted_packages_source = TopPyPiReference
"""Reference source for trusted PyPI packages."""
dependency_files = {UV_LOCK, POETRY_LOCK, REQUIREMENTS_TXT}
"""Set of supported Python dependency file names."""
18 changes: 11 additions & 7 deletions src/twyn/dependency_managers/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,27 @@
from twyn.dependency_managers.managers.npm_dependency_manager import NpmDependencyManager
from twyn.dependency_managers.managers.pypi_dependency_manager import PypiDependencyManager

DEPENDENCY_MANAGERS: list[type[BaseDependencyManager]] = [
PypiDependencyManager,
NpmDependencyManager,
]
"""List of available dependency manager classes."""

PACKAGE_ECOSYSTEMS = {x.name for x in DEPENDENCY_MANAGERS}
"""Set of package ecosystem names from available dependency managers."""


def get_dependency_manager_from_file(dependency_file: str) -> type[BaseDependencyManager]:
"""Get dependency manager that can handle the given file."""
for manager in DEPENDENCY_MANAGERS:
if manager.matches_dependency_file(dependency_file):
return manager
raise NoMatchingDependencyManagerError


def get_dependency_manager_from_name(name: str) -> type[BaseDependencyManager]:
"""Get dependency manager by ecosystem name."""
for manager in DEPENDENCY_MANAGERS:
if manager.matches_ecosystem_name(name):
return manager
raise NoMatchingDependencyManagerError


DEPENDENCY_MANAGERS: list[type[BaseDependencyManager]] = [
PypiDependencyManager,
NpmDependencyManager,
]
PACKAGE_ECOSYSTEMS = {x.name for x in DEPENDENCY_MANAGERS}
5 changes: 5 additions & 0 deletions src/twyn/dependency_parser/dependency_selector.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@


class DependencySelector:
"""Select and provide parsers for dependency files."""

def __init__(self, dependency_files: Optional[set[str]] = None, root_path: str = ".") -> None:
self.dependency_files = dependency_files or set()
self.root_path = root_path

def auto_detect_dependency_file_parser(self) -> list[AbstractParser]:
"""Automatically detect and return parsers for dependency files."""
parsers: list[AbstractParser] = []
root = Path(self.root_path)
for path in root.rglob("*"):
Expand All @@ -37,6 +40,7 @@ def auto_detect_dependency_file_parser(self) -> list[AbstractParser]:
return parsers

def get_dependency_file_parsers_from_file_name(self) -> list[AbstractParser]:
"""Get parsers for dependency files based on their names."""
parsers = []
for dependency_file in self.dependency_files:
for known_dependency_file_name in DEPENDENCY_FILE_MAPPING:
Expand All @@ -49,6 +53,7 @@ def get_dependency_file_parsers_from_file_name(self) -> list[AbstractParser]:
return parsers

def get_dependency_parsers(self) -> list[AbstractParser]:
"""Get appropriate dependency parsers based on configuration."""
if self.dependency_files:
logger.debug("Dependency file provided. Assigning a parser.")
return self.get_dependency_file_parsers_from_file_name()
Expand Down
2 changes: 2 additions & 0 deletions src/twyn/dependency_parser/parsers/abstract_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ def __init__(self, file_path: str) -> None:
self.file_handler = FileHandler(file_path=self.file_path)

def __str__(self) -> str:
"""Return string representation of parser class name."""
return self.__class__.__name__

def file_exists(self) -> bool:
"""Check if dependency file exists."""
return self.file_handler.exists()

@abstractmethod
Expand Down
9 changes: 9 additions & 0 deletions src/twyn/dependency_parser/parsers/constants.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
UV_LOCK = "uv.lock"
"""Filename for uv package manager lock files."""

PACKAGE_LOCK_JSON = "package-lock.json"
"""Filename for npm package lock files."""

POETRY_LOCK = "poetry.lock"
"""Filename for Poetry dependency lock files."""

REQUIREMENTS_TXT = "requirements.txt"
"""Filename for pip requirements files."""

YARN_LOCK = "yarn.lock"
"""Filename for Yarn package lock files."""
1 change: 1 addition & 0 deletions src/twyn/dependency_parser/parsers/package_lock_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def parse(self) -> set[str]:
return result

def _collect_deps(self, dep_tree: dict[str, Any], collected: set[str]):
"""Recursively collect dependencies from dependency tree."""
for name, info in dep_tree.items():
collected.add(name)
if "dependencies" in info:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class RequirementsTxtParser(AbstractParser):
""",
re.VERBOSE,
)
"""Regular expression pattern for parsing requirement specifications."""

def __init__(self, file_path: str = REQUIREMENTS_TXT) -> None:
super().__init__(file_path)
Expand All @@ -37,6 +38,7 @@ def parse(self) -> set[str]:
return self._parse_internal(self.file_path, seen_files=set())

def _parse_internal(self, source: Union[str, Path], seen_files: set[Path]) -> set[str]:
"""Parse requirements file and handle includes recursively."""
packages: set[str] = set()
base_dir = Path(source).parent if isinstance(source, Path) else Path(".")

Expand Down Expand Up @@ -74,6 +76,7 @@ def _parse_internal(self, source: Union[str, Path], seen_files: set[Path]) -> se

@staticmethod
def _is_valid_line(line: str) -> bool:
"""Check if line is valid for parsing."""
return (
bool(line)
and not line.startswith("#")
Expand Down
3 changes: 2 additions & 1 deletion src/twyn/dependency_parser/parsers/yarn_lock_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ def __init__(self, file_path: str = YARN_LOCK) -> None:
super().__init__(file_path)

def parse(self) -> set[str]:
"""Parse yarn lock file and return package names."""
with self.file_handler.open() as fp:
# We want to find out if it's a v1 or v2 file.
# we will check maximum on the first 20 lines in order to guess
Expand All @@ -29,7 +30,7 @@ def parse(self) -> set[str]:
raise InvalidFileFormatError

def _parse_v1(self, fp: TextIO) -> set[str]:
"""Parse a yarn.lock file and return all the dependencies in it."""
"""Parse a yarn.lock file (v1) and return all the dependencies in it."""
# Match the entire line up to the colon (allows multiple quoted keys)
key_line_re = re.compile(r"^(?P<key>[^ \t].*?):\s*$")
names = set()
Expand Down
Loading