diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..368cc70 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,111 @@ +name: Build & Check Package + +on: + push: + # branches: + # - main + paths: + - '**.py' + - pyproject.toml + pull_request: + paths: + - '**.py' + - pyproject.toml + +jobs: + ruff-linter: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Hatch + run: pip install hatch + + - name: Install dependencies + run: hatch env create dev + + - name: Run ruff lint + run: hatch run dev:lint + + check-python-compatibility: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Hatch + run: pip install hatch + + - name: Install dependencies + run: hatch env create dev + + - name: Build wheel with Hatch + run: hatch build + + - name: Install built wheel + run: pip install dist/*.whl + + - name: Test import + run: python -c "import Visualizer" + + run-acceptance-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Hatch + run: pip install hatch + + - name: Install dependencies + run: hatch env create dev + + - name: Build wheel with Hatch + run: hatch build + + - name: Run robot framework acceptance-tests + run: hatch run dev:atest + + # run-unit-tests: + # runs-on: ubuntu-latest + + # steps: + # - name: Checkout code + # uses: actions/checkout@v4 + + # - name: Set up Python + # uses: actions/setup-python@v5 + # with: + # python-version: '3.12' + + # - name: Install Hatch + # run: pip install hatch + + # - name: Install dependencies + # run: hatch env create dev + + # - name: Build wheel with Hatch + # run: hatch build + + # - name: Run unit tests with pytest + # run: hatch run dev:utest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..69e2eae --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,85 @@ +name: Bump Version From PR Title + +on: + pull_request: + types: [closed] + branches: [main] + +jobs: + bump-version: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Hatch + run: pip install hatch + + - name: Install dependencies + run: hatch env create dev + + - name: Determine bump level and bump version + id: bump + run: | + TITLE="${{ github.event.pull_request.title }}" + echo "PR Title: $TITLE" + + if [[ "$TITLE" == major:* ]]; then + hatch version major + echo "bump=true" >> $GITHUB_OUTPUT + elif [[ "$TITLE" == minor:* ]]; then + hatch version minor + echo "bump=true" >> $GITHUB_OUTPUT + elif [[ "$TITLE" == patch:* ]]; then + hatch version patch + echo "bump=true" >> $GITHUB_OUTPUT + else + echo "No version bump (chore or other)." + echo "bump=false" >> $GITHUB_OUTPUT + fi + + - name: Commit and push version + if: steps.bump.outputs.bump == 'true' + run: | + git config user.name "github-actions" + git config user.email "actions@github.com" + git add src/Visualizer/__about__.py + git commit -m "chore: bump version" + git push + + - name: Build wheel with Hatch + run: hatch build + + # - name: Publish to PyPI + # env: + # TWINE_USERNAME: __token__ + # TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + # run: twine upload dist/* + + # - name: Publish to Test PyPI + # env: + # TWINE_USERNAME: __token__ + # TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} + # run: | + # pip install twine + # twine upload --repository-url https://test.pypi.org/legacy/ dist/* + + - name: Get new version + if: steps.bump.outputs.bump == 'true' + id: version + run: | + VERSION=$(grep '__version__' src/Visualizer/__about__.py | cut -d '"' -f2) + echo "version=v$VERSION" >> $GITHUB_OUTPUT + + - name: Create Git Tag + if: steps.bump.outputs.bump == 'true' + run: | + git tag ${{ steps.version.outputs.version }} + git push origin ${{ steps.version.outputs.version }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index b7faf40..da2ef95 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ +# Robot Framework +results/** +log.html +report.html +output.xml +graph*.png +.vscode/** + # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] diff --git a/README.md b/README.md index 9cff43f..e2bf975 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,10 @@ # robot-visualizer -Keywords from this repository can visualize CSV data as graph within the robotframework test results. +Keywords from this repository can visualize CSV data as graph within the robotframework log file. + +## Statistics + +[![Release Pipeline](https://github.com/MarvKler/robotframework-visualizer/actions/workflows/release.yml/badge.svg)](https://github.com/MarvKler/robotframework-visualizer/actions/workflows/release.yml) +[![PyPI - Version](https://img.shields.io/pypi/v/robotframework-visualizer.svg)](https://pypi.org/project/robotframework-visualizer) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/robotframework-visualizer.svg)](https://pypi.org/project/robotframework-visualizer) +[![PyPI Downloads - Total](https://static.pepy.tech/badge/robotframework-visualizer)](https://pepy.tech/projects/robotframework-visualizer) +[![PyPI Downloads - Monthly](https://static.pepy.tech/badge/robotframework-visualizer/month)](https://pepy.tech/projects/robotframework-visualizer) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ac4b7d1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,90 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "robotframework-visualizer" +dynamic = ["version"] +description = "A Robot Framework library providing keywords for the visualization of 'date / value' objects." +readme = "README.md" +requires-python = ">=3.8" +authors = [ + { name = "Marvin Klerx", email = "marvinklerx20@gmail.com" } +] +license = { text = "Apache-2.0" } +classifiers = [ + "Development Status :: 1 - Planning", + "Programming Language :: Python", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dependencies = [ + "overrides", + "robotframework", + "robotframework-pythonlibcore", + "pandas", + "matplotlib" +] + + +[project.optional-dependencies] +dev = [ + "hatch", + "ruff", + "pytest" +] + +[project.urls] +Repository = "https://github.com/MarvKler/robotframework-visualizer" +Documentation = "https://github.com/MarvKler/robotframework-visualizer#readme" +Issues = "https://github.com/MarvKler/robotframework-visualizer/issues" + +[tool.hatch.build.targets.wheel] +require-runtime-dependencies = true +only-include = ["src/Visualizer"] +sources = ["src"] + +[tool.hatch.version] +path = "src/Visualizer/__about__.py" + +[tool.hatch.build] +dev-mode-dirs = ["src"] + +[tool.hatch.envs.types] +extra-dependencies = [ + "mypy>=1.0.0", +] +[tool.hatch.envs.types.scripts] +check = "mypy --install-types --non-interactive {args:src/Visualizer tests}" + +[tool.coverage.run] +source_pkgs = ["Visualizer", "tests"] +branch = true +parallel = true +omit = [ + "src/Visualizer/__about__.py", +] + +[tool.coverage.paths] +robotframework_visualizer = ["src/Visualizer", "*/src/Visualizer"] +tests = ["tests", "*/robotframework-tablelibrary/tests"] + +[tool.coverage.report] +exclude_lines = [ + "no cov", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] + +[tool.hatch.envs.dev] +dependencies = [ + "ruff", + "pytest" +] +[tool.hatch.envs.dev.scripts] +lint = "ruff check ." +atest = "robot tests/atest/" +utest = "pytest" \ No newline at end of file diff --git a/src/Visualizer/__about__.py b/src/Visualizer/__about__.py new file mode 100644 index 0000000..6c8e6b9 --- /dev/null +++ b/src/Visualizer/__about__.py @@ -0,0 +1 @@ +__version__ = "0.0.0" diff --git a/src/Visualizer/__init__.py b/src/Visualizer/__init__.py new file mode 100644 index 0000000..6f6440a --- /dev/null +++ b/src/Visualizer/__init__.py @@ -0,0 +1,32 @@ + +from robot.api.deco import library +from robotlibcore import HybridCore +from .__about__ import __version__ + +from .keywords import ( + Keywords +) + +@library( + scope='GLOBAL', + version=__version__ +) +class Visualizer(HybridCore): + """ + Visualizer Library for creating visual diagrams as embedded 'png' images in the Robot Framework log file.\n + + = Current Implementation = + The initial idea of the library was to create diagrams with the date time series on the x-axis & the raw value on the y-axis.\n + Currently, you need to pass the CSV header names into keyword to visualize the correct data. + + = Future Implementation = + The future implementation idea is, to pass multiple CSV header names into the keyword to visualize more than one value time series on the y-axis. + The x-axis should be kept reserved for the datetime time-series. + """ + + def __init__(self): + + libraries = [ + Keywords() + ] + super().__init__(libraries) \ No newline at end of file diff --git a/src/Visualizer/keywords/__init__.py b/src/Visualizer/keywords/__init__.py new file mode 100644 index 0000000..1eb7f02 --- /dev/null +++ b/src/Visualizer/keywords/__init__.py @@ -0,0 +1,5 @@ +from .keywords import Keywords + +__all__ = [ + "Keywords" +] \ No newline at end of file diff --git a/src/Visualizer/keywords/keywords.py b/src/Visualizer/keywords/keywords.py new file mode 100644 index 0000000..71d7b04 --- /dev/null +++ b/src/Visualizer/keywords/keywords.py @@ -0,0 +1,153 @@ +from robot.libraries.BuiltIn import BuiltIn +from robot.api.deco import keyword +from robot.api import logger + +import os +import pandas as pd +from pandas import DataFrame +from pathlib import Path +from io import StringIO +import matplotlib.pyplot as plt +import matplotlib +matplotlib.use('Agg') +import matplotlib.dates as mdates +import random + +from ..utils.enums import GraphColor + +class Keywords(): + + ROBOT_LIBRARY_SCOPE = "GLOBAL" + ROBOT_LISTENER_API_VERSION = 3 + + def __init__(self): + self.ROBOT_LIBRARY_LISTENER = self + self.graph_data = [] + self.add_graph = False + self.path = {} + self.unique_directory = "visualizer" + self.diagram_name = None + + #################################################################################################################### + # Robot Framework Listener Functions: + #################################################################################################################### + + def start_test(self, data, result): + logger.trace("Resetted state machine") + self.add_graph = False + self.path = {} + + def end_test(self, data, result): + if self.add_graph: + for img_path in self.path: + logger.debug(f"Added graph to test documentation for: {img_path} / {self.path[img_path]}") + result.doc += f"\n\n*{img_path}:* \n\n ["+ self.path[img_path] + f"| {img_path} ]" + self._cleanup() + + #################################################################################################################### + # Internal Helper Functions: + #################################################################################################################### + + def _get_csv_as_pandas(self, csv_data: str, usecols = None) -> DataFrame: + if isinstance(csv_data, str) and os.path.isfile(csv_data): + # csv_data is a file path + return pd.read_csv(csv_data, usecols=usecols) + elif isinstance(csv_data, str): + # csv_data is a CSV string + csv_buffer = StringIO(csv_data) + return pd.read_csv(csv_buffer, usecols=usecols) + else: + raise ValueError("csv_data must be either a CSV string or a valid file path as string.") + + def _validate_columns(self, csv_data: DataFrame, x_axis: str, *y_axis: str): + if x_axis not in csv_data.columns: + raise ValueError(f"Column '{x_axis}' not found in CSV!") + + for col in y_axis: + if col not in csv_data.columns: + raise ValueError(f"Column '{col}' not found in CSV!") + + def _cleanup(self): + self.graph_data.clear() + self.diagram_name = None + self.add_graph = False + self.path = {} + + #################################################################################################################### + # Public Keywords for Robot Framework: + #################################################################################################################### + + @keyword() + def add_to_diagramm( + self, + csv_data: str, + csv_header_x_axis: str, + csv_header_y_axis: str, + graph_name: str, + line_color: GraphColor = GraphColor.Blue + ): + """ + TBD + """ + + # Einlesen der CSV mit nur den benötigten Spalten + df = self._get_csv_as_pandas(csv_data, usecols=[csv_header_x_axis, csv_header_y_axis]) + self._validate_columns(df, csv_header_x_axis, csv_header_y_axis) + + # X-Achse als Zeit formatieren + df[csv_header_x_axis] = pd.to_datetime(df[csv_header_x_axis], format="mixed") + df = df.sort_values(by=csv_header_x_axis) + + # Zwischenspeichern + self.graph_data.append({ + "df": df, + "x_axis": csv_header_x_axis, + "y_axis": csv_header_y_axis, + "graph_name": graph_name, + "color": line_color.value + }) + + @keyword(tags=['Visualizer']) + def visualize( + self, + diagram_name: str + ): + """ + TBD + """ + if not self.graph_data: + raise ValueError("No graph data available. Call 'Add To Diagramm' first.") + + self.diagram_name = diagram_name + + # Set state machine for RF listener + self.add_graph = True + + # Get output directory + create individual sub directory + img_dir = Path(BuiltIn().get_variable_value('$OUTPUT_DIR')) / self.unique_directory + img_dir.mkdir(parents=True, exist_ok=True) + + # Generate random file name + define path variables + file_name = f"graph{''.join(random.choices('123456789', k=10))}.png" + full_file_path = str(img_dir / file_name) + self.path[diagram_name] = f"{self.unique_directory}/{file_name}" + + # Create diagram + fig, ax = plt.subplots(figsize=(10, 3)) + + # Plot given data fron entry list + for entry in self.graph_data: + df = entry["df"] + x = entry["x_axis"] + y = entry["y_axis"] + color = entry["color"] + df.plot(x=x, y=y, ax=ax, label=entry['graph_name'], color=color) + ax.xaxis.set_major_formatter(mdates.DateFormatter('%d.%m.%Y %H:%M')) + fig.autofmt_xdate() + plt.xlabel(self.graph_data[0]["x_axis"]) + plt.ylabel("Value(s)") + plt.legend() + plt.tight_layout(rect=[0, 0, 1, 0.95]) + + # Save plot to PNG file + plt.savefig(full_file_path, format='png') diff --git a/src/Visualizer/utils/enums.py b/src/Visualizer/utils/enums.py new file mode 100644 index 0000000..a7ea883 --- /dev/null +++ b/src/Visualizer/utils/enums.py @@ -0,0 +1,11 @@ +from enum import Enum + +class GraphColor(Enum): + Red = "red" + Blue = "blue" + Green = "green" + Yellow = "yellow" + Black = "black" + White = "white" + Orange = "orange" + Grey = "grey" \ No newline at end of file diff --git a/tests/atest/generic.robot b/tests/atest/generic.robot new file mode 100644 index 0000000..3c4c84f --- /dev/null +++ b/tests/atest/generic.robot @@ -0,0 +1,14 @@ +*** Settings *** +Library Visualizer + + +*** Test Cases *** +Add One Data Set + Visualizer.Add To Diagramm ${CURDIR}${/}testdata${/}dummy_strom_spannung.csv _time _strom Strom Blue + Visualizer.Visualize Strom / Spannung Verlauf + +Add Two Data Sets + Visualizer.Add To Diagramm ${CURDIR}${/}testdata${/}dummy_strom_spannung.csv _time _spannung Spannung Green + Visualizer.Add To Diagramm ${CURDIR}${/}testdata${/}dummy_strom_spannung.csv _time _strom Strom Blue + Visualizer.Visualize Strom / Spannung Verlauf + diff --git a/tests/atest/testdata/dummy_strom_spannung.csv b/tests/atest/testdata/dummy_strom_spannung.csv new file mode 100644 index 0000000..1f35ba1 --- /dev/null +++ b/tests/atest/testdata/dummy_strom_spannung.csv @@ -0,0 +1,101 @@ +_time,_strom,_spannung +2025-07-24 16:37:57.406736,1.13,225.4 +2025-07-24 16:38:57.406736,0.78,221.2 +2025-07-24 16:39:57.406736,1.25,235.2 +2025-07-24 16:40:57.406736,0.98,233.5 +2025-07-24 16:41:57.406736,0.64,233.7 +2025-07-24 16:42:57.406736,1.23,233.4 +2025-07-24 16:43:57.406736,1.18,228.6 +2025-07-24 16:44:57.406736,0.8,231.7 +2025-07-24 16:45:57.406736,1.14,221.9 +2025-07-24 16:46:57.406736,1.02,229.6 +2025-07-24 16:47:57.406736,1.49,232.1 +2025-07-24 16:48:57.406736,1.89,226.2 +2025-07-24 16:49:57.406736,0.65,224.6 +2025-07-24 16:50:57.406736,1.91,238.1 +2025-07-24 16:51:57.406736,1.98,232.6 +2025-07-24 16:52:57.406736,1.5,232.2 +2025-07-24 16:53:57.406736,1.79,225.9 +2025-07-24 16:54:57.406736,0.62,234.3 +2025-07-24 16:55:57.406736,1.76,220.6 +2025-07-24 16:56:57.406736,0.6,224.7 +2025-07-24 16:57:57.406736,1.25,233.7 +2025-07-24 16:58:57.406736,1.64,237.3 +2025-07-24 16:59:57.406736,1.16,222.2 +2025-07-24 17:00:57.406736,1.76,236.9 +2025-07-24 17:01:57.406736,1.69,239.5 +2025-07-24 17:02:57.406736,1.81,222.0 +2025-07-24 17:03:57.406736,1.01,223.1 +2025-07-24 17:04:57.406736,1.77,235.4 +2025-07-24 17:05:57.406736,0.86,233.6 +2025-07-24 17:06:57.406736,0.8,232.4 +2025-07-24 17:07:57.406736,1.04,228.3 +2025-07-24 17:08:57.406736,1.15,222.7 +2025-07-24 17:09:57.406736,1.02,228.7 +2025-07-24 17:10:57.406736,1.41,237.5 +2025-07-24 17:11:57.406736,1.6,222.4 +2025-07-24 17:12:57.406736,1.96,224.9 +2025-07-24 17:13:57.406736,0.8,235.5 +2025-07-24 17:14:57.406736,1.75,230.6 +2025-07-24 17:15:57.406736,1.4,226.7 +2025-07-24 17:16:57.406736,0.92,227.3 +2025-07-24 17:17:57.406736,1.35,227.1 +2025-07-24 17:18:57.406736,0.92,238.8 +2025-07-24 17:19:57.406736,0.65,226.1 +2025-07-24 17:20:57.406736,1.07,220.2 +2025-07-24 17:21:57.406736,1.52,226.2 +2025-07-24 17:22:57.406736,1.18,222.8 +2025-07-24 17:23:57.406736,0.9,230.7 +2025-07-24 17:24:57.406736,1.93,228.8 +2025-07-24 17:25:57.406736,0.88,235.8 +2025-07-24 17:26:57.406736,1.42,230.1 +2025-07-24 17:27:57.406736,0.56,222.9 +2025-07-24 17:28:57.406736,0.84,222.5 +2025-07-24 17:29:57.406736,1.46,231.3 +2025-07-24 17:30:57.406736,1.54,223.5 +2025-07-24 17:31:57.406736,1.8,228.7 +2025-07-24 17:32:57.406736,1.47,227.1 +2025-07-24 17:33:57.406736,0.51,225.5 +2025-07-24 17:34:57.406736,0.56,234.0 +2025-07-24 17:35:57.406736,0.8,225.0 +2025-07-24 17:36:57.406736,1.68,222.8 +2025-07-24 17:37:57.406736,1.02,228.3 +2025-07-24 17:38:57.406736,0.76,220.8 +2025-07-24 17:39:57.406736,0.87,225.6 +2025-07-24 17:40:57.406736,1.3,223.7 +2025-07-24 17:41:57.406736,0.7,232.2 +2025-07-24 17:42:57.406736,1.06,239.7 +2025-07-24 17:43:57.406736,1.28,233.3 +2025-07-24 17:44:57.406736,0.69,232.6 +2025-07-24 17:45:57.406736,0.62,225.9 +2025-07-24 17:46:57.406736,0.78,226.1 +2025-07-24 17:47:57.406736,1.53,230.8 +2025-07-24 17:48:57.406736,1.47,221.1 +2025-07-24 17:49:57.406736,0.89,229.7 +2025-07-24 17:50:57.406736,1.16,223.6 +2025-07-24 17:51:57.406736,0.66,229.6 +2025-07-24 17:52:57.406736,0.87,238.8 +2025-07-24 17:53:57.406736,1.49,235.0 +2025-07-24 17:54:57.406736,1.39,239.1 +2025-07-24 17:55:57.406736,0.97,221.1 +2025-07-24 17:56:57.406736,1.15,225.9 +2025-07-24 17:57:57.406736,0.89,226.2 +2025-07-24 17:58:57.406736,1.56,238.5 +2025-07-24 17:59:57.406736,1.44,232.9 +2025-07-24 18:00:57.406736,0.62,236.3 +2025-07-24 18:01:57.406736,1.31,229.6 +2025-07-24 18:02:57.406736,1.96,223.6 +2025-07-24 18:03:57.406736,1.39,228.5 +2025-07-24 18:04:57.406736,1.32,232.5 +2025-07-24 18:05:57.406736,1.3,223.0 +2025-07-24 18:06:57.406736,1.33,235.2 +2025-07-24 18:07:57.406736,1.89,238.4 +2025-07-24 18:08:57.406736,1.0,233.1 +2025-07-24 18:09:57.406736,1.03,220.4 +2025-07-24 18:10:57.406736,1.65,232.2 +2025-07-24 18:11:57.406736,0.86,222.0 +2025-07-24 18:12:57.406736,0.88,222.6 +2025-07-24 18:13:57.406736,1.46,235.2 +2025-07-24 18:14:57.406736,1.9,237.0 +2025-07-24 18:15:57.406736,1.88,238.0 +2025-07-24 18:16:57.406736,0.63,237.6