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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ The **KPIs** section summarizes the most important metrics of your test suite:
- <strong>Global Filter:</strong> Jump directly to common problem areas across the entire project:
- <strong>Keywords without Documentation:</strong> Keywords that are missing documentation.
- <strong>Unused Keywords:</strong> Keywords that are never called in any analyzed file.
- <strong>Keywords with Calling Cycles:</strong> Keywords that participate in cyclic calls (A calls B, B calls A, etc.), which can indicate design or maintainability issues.
- <strong>Potential Keyword Duplicates:</strong> Keywords that may duplicate or closely resemble existing keywords.

<br>

Expand Down
1,296 changes: 704 additions & 592 deletions packages/roboview/poetry.lock

Large diffs are not rendered by default.

31 changes: 16 additions & 15 deletions packages/roboview/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,29 @@ packages = [
]

[tool.poetry.dependencies]
python = ">=3.10,<3.14"
robotframework = "^7.2.2"
fastapi = "^0.115.8"
uvicorn = "^0.34.0"
pygments = "^2.19.1"
scikit-learn = "^1.6.1"
numpy = "^2.2.4"
pydantic = "^2.11.4"
starlette = "^0.46.0"
pydantic-settings = "^2.12.0"
python = ">=3.10,<=3.14"
robotframework = "^7.4.1"
fastapi = "^0.135.1"
uvicorn = "^0.41.0"
pygments = "^2.19.2"
pydantic = "^2.12.5"
starlette = "^0.52.1"
pydantic-settings = "^2.13.1"
coloredlogs = "^15.0.1"
robotframework-robocop = "^7.2.0"
robotframework-robocop = "^8.2.2"
httpx = "^0.28.1"
robotframework-browser = "^19.12.4"
robotframework-databaselibrary = "^2.4.1"
robotframework-crypto = "^0.4.2"
robotframework-seleniumlibrary = "^6.8.0"
typer = "^0.24.1"

[tool.poetry.group.dev.dependencies]
pyright = "^1.1.408"
pytest = "^9.0.2"
deptry = "^0.24.0"
robotframework-browser = "^19.12.5"
robotframework-databaselibrary = "^2.4.1"
robotframework-crypto = "^0.4.2"
robotframework-seleniumlibrary = "^6.8.0"
robotframework-appiumlibrary = "^3.2.1"
robotframework-requests = "^0.9.7"

[build-system]
requires = ["poetry-core"]
Expand Down
2 changes: 1 addition & 1 deletion packages/roboview/roboview/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,6 @@ async def catch_exceptions_middleware(request: Request, call_next: Callable) ->
uvicorn.run(
app,
host="127.0.0.1",
port=8000,
port=18123,
log_level=settings.LOG_LEVEL.lower(),
)
22 changes: 19 additions & 3 deletions packages/roboview/roboview/schemas/domain/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,29 @@
from enum import Enum


class LibraryType(Enum):
"""Supported Robot Framework libraries."""
class ExternalLibraryType(Enum):
"""Supported external Robot Framework libraries."""

BROWSER = "Browser"
SELENIUM = "SeleniumLibrary"
BUILTIN = "BuiltIn"
DATABASE = "DatabaseLibrary"
APPIUM = "AppiumLibrary"
REQUESTS = "RequestsLibrary"


class BuiltinLibraryType(Enum):
"""Supported Robot Framework BuiltIn libraries."""

BUILTIN = "BuiltIn"
COLLECTIONS = "Collections"
DATETIME = "DateTime"
DIALOGS = "Dialogs"
OPERATINGSYSTEM = "OperatingSystem"
PROCESS = "Process"
SCREENSHOT = "Screenshot"
STRING = "String"
TELNET = "Telnet"
XML = "XML"


class FileType(Enum):
Expand Down
69 changes: 57 additions & 12 deletions packages/roboview/roboview/services/keyword_register_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
import logging
from pathlib import Path

from robot.errors import DataError
from robot.libdocpkg import LibraryDocumentation
from robot.parsing import get_model, get_resource_model
from roboview.models.robot_parsing.keyword_dependency_parsing import KeywordDependencyFinder
from roboview.models.robot_parsing.local_keyword_parsing import LocalKeywordFinder
from roboview.registries.keyword_registry import KeywordRegistry
from roboview.schemas.domain.common import FileType, LibraryType
from roboview.schemas.domain.common import BuiltinLibraryType, ExternalLibraryType, FileType
from roboview.schemas.domain.keywords import KeywordProperties
from roboview.utils.directory_parsing import DirectoryParser

Expand Down Expand Up @@ -45,16 +46,33 @@ def initialize(self) -> None:

This method:
1. Loads local keywords from .robot and .resource files
2. Loads external library keywords (Browser, Selenium, Database, BuiltIn)
3. Populates the KeywordRegistry with all discovered keywords
2. Loads built-in library keywords (BuiltIn, Collections, DateTime, etc.)
3. Loads external library keywords (Browser, Selenium, Database, Appium, Requests) if installed
4. Populates the KeywordRegistry with all discovered keywords

"""
try:
logger.info("Register user-defined keywords")
self._load_local_keywords()
self._load_library_keywords()
logger.info("Registry initialized with %d keywords", len(self.registry))
logger.info("Finished registering user-defined keywords")
except Exception:
logger.exception("Failed to initialize keyword registry")
logger.exception("Failed to register keywords with user-defined keywords")

try:
logger.info("Register built-in library keywords")
self._load_builtin_library_keywords()
logger.info("Finished registering built-in library keywords")
except Exception:
logger.exception("Failed to register keywords with built-in keywords")

try:
logger.info("Register external library keywords")
self._load_external_library_keywords()
logger.info("Finished registering external library keywords")
except Exception:
logger.exception("Failed to register keywords with external keywords")

logger.info("Registry initialized with %d keywords", len(self.registry))

def _load_local_keywords(self) -> None:
"""Load local keywords from Robot Framework files."""
Expand Down Expand Up @@ -125,13 +143,37 @@ def _enrich_with_called_keywords(
keyword_doc.called_keywords = dependency_map.get(keyword_name, [])
return keyword_doc

def _load_library_keywords(self) -> None:
def _load_builtin_library_keywords(self) -> None:
libraries = [
BuiltinLibraryType.BUILTIN,
BuiltinLibraryType.COLLECTIONS,
BuiltinLibraryType.DATETIME,
BuiltinLibraryType.DIALOGS,
BuiltinLibraryType.OPERATINGSYSTEM,
BuiltinLibraryType.PROCESS,
BuiltinLibraryType.SCREENSHOT,
BuiltinLibraryType.STRING,
BuiltinLibraryType.TELNET,
BuiltinLibraryType.XML,
]

for library_type in libraries:
try:
keyword_doc = self._get_library_keywords(library_type)
for keyword in keyword_doc:
self.registry.register(keyword)
except Exception:
logger.exception("Failed to load library: %s", library_type.value)
continue

def _load_external_library_keywords(self) -> None:
"""Load keywords from external Robot Framework libraries."""
libraries = [
LibraryType.BROWSER,
LibraryType.SELENIUM,
LibraryType.DATABASE,
LibraryType.BUILTIN,
ExternalLibraryType.BROWSER,
ExternalLibraryType.SELENIUM,
ExternalLibraryType.APPIUM,
ExternalLibraryType.DATABASE,
ExternalLibraryType.REQUESTS,
]

for library_type in libraries:
Expand All @@ -145,7 +187,7 @@ def _load_library_keywords(self) -> None:
continue

@staticmethod
def _get_library_keywords(library_type: LibraryType) -> list[KeywordProperties]:
def _get_library_keywords(library_type: BuiltinLibraryType | ExternalLibraryType) -> list[KeywordProperties]:
"""Get keyword metadata for a specific library.

Arguments:
Expand Down Expand Up @@ -176,6 +218,9 @@ def _get_library_keywords(library_type: LibraryType) -> list[KeywordProperties]:
validation_str_with_prefix=str(keyword_with_prefix).lower().replace(" ", "").replace("_", ""),
)
)
except DataError:
logger.warning("Library not installed: %s will be skipped", library_type.value)
return []

except Exception:
logger.exception("Library %s could not be loaded", lib_name)
Expand Down
86 changes: 74 additions & 12 deletions packages/roboview/roboview/services/keyword_similarity_service.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
"""Functionality to cover the KeywordSimilarity."""

import logging
from collections import Counter
from math import sqrt

import numpy as np
from pygments import lex
from pygments.lexers import get_lexer_by_name
from roboview.registries.keyword_registry import KeywordRegistry
from roboview.schemas.domain.keywords import KeywordProperties, SimilarKeyword
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.metrics.pairwise import cosine_similarity

logger = logging.getLogger(__name__)

Expand All @@ -17,7 +16,7 @@ class KeywordSimilarityService:
"""Class for calculating and querying the similarity between Keywords.

This class provides functionality to analyze keyword similarity across Robot Framework
files using TF-IDF vectorization and cosine similarity metrics. It can identify
files using token frequency vectors and cosine similarity metrics. It can identify
similar keywords based on their source code structure and content.

Attributes:
Expand All @@ -36,10 +35,71 @@ def __init__(self, keyword_registry: KeywordRegistry) -> None:
"""
self.keyword_registry = keyword_registry
self.keyword_names_list = []
self.similarity_matrix: np.ndarray = np.array([])
self.similarity_matrix: list[list[float]] = []

@staticmethod
def _calculate_cosine_similarity(
vector_a: Counter[str],
vector_b: Counter[str],
norm_a: float,
norm_b: float,
) -> float:
"""Calculate cosine similarity for two sparse token vectors.

Arguments:
vector_a (Counter[str]): Sparse token frequency vector for the first keyword.
vector_b (Counter[str]): Sparse token frequency vector for the second keyword.
norm_a (float): Precomputed Euclidean norm of vector_a.
norm_b (float): Precomputed Euclidean norm of vector_b.

Returns:
float: Cosine similarity score in range [0.0, 1.0]. Returns 0.0 if one
of the vectors has a zero norm.

"""
if norm_a == 0.0 or norm_b == 0.0:
return 0.0

if len(vector_a) > len(vector_b):
vector_a, vector_b = vector_b, vector_a

dot_product = sum(value * vector_b.get(token, 0) for token, value in vector_a.items())
return dot_product / (norm_a * norm_b)

def _build_similarity_matrix(self, tokenized_keywords: list[str]) -> list[list[float]]:
"""Build a full pairwise similarity matrix for tokenized keywords.

Arguments:
tokenized_keywords (list[str]): List of whitespace-separated token strings,
one entry per keyword.

Returns:
list[list[float]]: Symmetric cosine similarity matrix where matrix[i][j]
represents the similarity between keyword i and keyword j.

"""
token_vectors = [Counter(tokens.split()) for tokens in tokenized_keywords]
norms = [sqrt(sum(value * value for value in token_vector.values())) for token_vector in token_vectors]

vector_count = len(token_vectors)
similarity_matrix = [[0.0] * vector_count for _ in range(vector_count)]

for i in range(vector_count):
similarity_matrix[i][i] = 1.0
for j in range(i + 1, vector_count):
similarity = self._calculate_cosine_similarity(
token_vectors[i],
token_vectors[j],
norms[i],
norms[j],
)
similarity_matrix[i][j] = similarity
similarity_matrix[j][i] = similarity

return similarity_matrix

def calculate_keyword_similarity_matrix(self) -> None:
"""Calculate the keyword similarity matrix using TF-IDF and cosine similarity.
"""Calculate the keyword similarity matrix using token vectors and cosine similarity.

Analyzes keyword source code to compute similarity scores between all keywords
in the project using tokenization and vectorization techniques.
Expand Down Expand Up @@ -76,9 +136,7 @@ def calculate_keyword_similarity_matrix(self) -> None:

# Create similarity matrix
try:
vectorizer = CountVectorizer()
vectors = vectorizer.fit_transform(tokenized_keywords)
similarity_matrix = cosine_similarity(vectors)
similarity_matrix = self._build_similarity_matrix(tokenized_keywords)
except Exception:
logger.exception("Failed to create vectors or calculate similarity matrix")
return
Expand Down Expand Up @@ -123,7 +181,11 @@ def get_n_most_similar_keywords(self, keyword_name: str, top_n: int) -> list[Sim

try:
similarities = self.similarity_matrix[index]
similar_indices = np.argsort(similarities)[::-1]
similar_indices = sorted(
range(len(similarities)),
key=lambda similarity_index: similarities[similarity_index],
reverse=True,
)

similar_keywords = []
for i in similar_indices:
Expand Down Expand Up @@ -171,7 +233,7 @@ def get_all_similar_keywords_above_threshold(self, threshold: float = 0.80) -> l
list: List of keywords that have high similarity with at least one other keyword.

"""
if self.similarity_matrix.size == 0:
if not self.similarity_matrix:
logger.warning("Similarity matrix is empty")
return []

Expand All @@ -181,7 +243,7 @@ def get_all_similar_keywords_above_threshold(self, threshold: float = 0.80) -> l

for i in range(n):
for j in range(i + 1, n):
similarity_score = round(float(self.similarity_matrix[i, j]), 4)
similarity_score = round(float(self.similarity_matrix[i][j]), 4)
if similarity_score >= threshold:
similar_keyword_indices.add(i)
similar_keyword_indices.add(j)
Expand Down
11 changes: 7 additions & 4 deletions packages/roboview/roboview/services/robocop_register_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
from pathlib import Path

import click
from robocop.config import Config, ConfigManager
from robocop.config.manager import ConfigManager
from robocop.linter.fix import FixApplier
from robocop.linter.runner import RobocopLinter
from robocop.source_file import SourceFile
from roboview.registries.robocop_registry import RobocopRegistry
from roboview.schemas.domain.robocop import RobocopMessage, RuleCategory
from roboview.utils.directory_parsing import DirectoryParser
Expand Down Expand Up @@ -73,7 +75,7 @@ def _extract_diagnostics(self) -> None:
diagnostics = linter.diagnostics
if diagnostics:
for error in diagnostics:
rf_script_path = Path(error.source)
rf_script_path = Path(error.source.path)
self.robocop_registry.register(
RobocopMessage(
rule_id=self._extract_rule_id(str(error.rule)),
Expand Down Expand Up @@ -105,11 +107,12 @@ def _parse_and_register_files(self) -> None:
files = robot_files + resources_files

try:
config = Config(silent=True)
config = ConfigManager().default_config
linter = RobocopLinter(ConfigManager())

for file in files:
diagnostics = linter.get_model_diagnostics(config, file)
source_file = SourceFile(path=file, config=config)
diagnostics = linter.get_model_diagnostics(source_file, FixApplier())

if diagnostics:
for error in diagnostics:
Expand Down
Loading