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
21 changes: 21 additions & 0 deletions .github/workflows/Changelog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Check Changelog

on: pull_request

jobs:
changelog:
runs-on: ubuntu-latest
name: Changelog should be updated
strategy:
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 2

- name: Git fetch
run: git fetch

- name: Check that changelog has been updated.
run: git diff --exit-code origin/${{ github.base_ref }} -- changelog.md && exit 1 || exit 0
12 changes: 10 additions & 2 deletions .github/workflows/pylint.yml → .github/workflows/Linting.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Pylint
name: Linting

on: [push]

Expand All @@ -8,17 +8,25 @@ jobs:
strategy:
matrix:
python-version: ["3.11"]
fail-fast: false
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pylint
pip install flake8
pip install .
- name: Analysing the code with pylint

- name: Analyzing the code with pylint
run: |
pylint --rcfile=.pylintrc $(git ls-files '*.py')

- name: Analyzing the code with flake8
run: |
flake8 --extend-ignore=E501,E251 $(git ls-files '*.py')
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ celerybeat.pid

# Environments
.env
.venv
.venv*
env/
venv/
ENV/
Expand Down
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
# itk-dev-shared-components

https://pypi.org/project/ITK-dev-shared-components/
## Links

pip install ITK-dev-shared-components
[Documentation](https://itk-dev-rpa.github.io/itk-dev-shared-components-docs/)

[Pypi](https://pypi.org/project/ITK-dev-shared-components/)

## Installation

```
pip install itk-dev-shared-components
```

## Intro

This python library contains helper modules for RPA development.
It's based on the need of [ITK Dev](https://itk.aarhus.dk/), but it has been
generalized to be useful for others as well.

## Integrations

### SAP Gui

Helper functions for using SAP Gui. A few examples include:

- Login to SAP.
- Handling multiple sessions in multiple threads.
- Convenience functions for gridviews and trees.

### Microsoft Graph

Helper functions for using Microsoft Graph to read emails from shared inboxes.
Some examples are:

- Authentication using username and password.
- List and get emails.
- Get attachment data.
- Move and delete emails.
37 changes: 37 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [1.1.0] - 2023-11-28

### Changed

- sap.opret_kundekontakt: Function 'opret_kundekontakter' made more stable.
- sap.multi_session: Function 'spawn_session' is no longer hardcoded to 1080p screen size.
- sap.multi_session: Arrange session windows moved to separate function.
- Bunch o' linting.
- run_tests.bat: Dedicated test venv.
- readme: Updated readme.

### Added

- Changelog!
- pylint.yml: Flake8 added.

### Fixed

- sap.multi_session: Critical bug in 'run_batches'.
- tests.sap.login: Change environ 'SAP Login' for later tests.

## [1.0.0] - 2023-11-14

- Initial release

[Unreleased] https://github.com/itk-dev-rpa/ITK-dev-shared-components/compare/1.1.0...HEAD
[1.1.0] https://github.com/itk-dev-rpa/ITK-dev-shared-components/releases/tag/1.1.0
[1.0.0] https://github.com/itk-dev-rpa/ITK-dev-shared-components/releases/tag/1.0.0
3 changes: 2 additions & 1 deletion itk_dev_shared_components/graph/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import msal


# pylint: disable-next=too-few-public-methods
class GraphAccess:
"""An object that handles access to the Graph api.
Expand Down Expand Up @@ -45,7 +46,7 @@ def authorize_by_username_password(username: str, password: str, *, client_id: s
password: The password of the user.
client_id: The Graph API client id in 8-4-4-12 format.
tenant_id: The Graph API tenant id in 8-4-4-12 format.

Returns:
GraphAccess: The GraphAccess object used to authorize Graph access.
"""
Expand Down
12 changes: 6 additions & 6 deletions itk_dev_shared_components/graph/mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,15 +162,15 @@ def get_attachment_data(attachment: Attachment, graph_access: GraphAccess) -> io
"""
email = attachment.email
endpoint = f"https://graph.microsoft.com/v1.0/users/{email.user}/messages/{email.id}/attachments/{attachment.id}/$value"
response = _get_request(endpoint, graph_access)
response = _get_request(endpoint, graph_access)
data_bytes = response.content
return io.BytesIO(data_bytes)


def move_email(email: Email, folder_path: str, graph_access: GraphAccess, *, well_known_folder: bool=False) -> None:
def move_email(email: Email, folder_path: str, graph_access: GraphAccess, *, well_known_folder: bool = False) -> None:
"""Move an email to another folder under the same user.
If well_known_folder is true, the folder path is assumed to be a well defined folder.
See https://learn.microsoft.com/en-us/graph/api/resources/mailfolder?view=graph-rest-1.0
See https://learn.microsoft.com/en-us/graph/api/resources/mailfolder?view=graph-rest-1.0
for a list of well defined folder names.

Args:
Expand Down Expand Up @@ -208,7 +208,7 @@ def move_email(email: Email, folder_path: str, graph_access: GraphAccess, *, wel
email.id = new_id


def delete_email(email: Email, graph_access: GraphAccess, *, permanent: bool=False) -> None:
def delete_email(email: Email, graph_access: GraphAccess, *, permanent: bool = False) -> None:
"""Delete an email from the mailbox.
If permanent is true the email is completely removed from the user's mailbox.
If permanent is false the email is instead moved to the Deleted Items folder.
Expand All @@ -235,7 +235,7 @@ def delete_email(email: Email, graph_access: GraphAccess, *, permanent: bool=Fal


def _find_folder(response: dict, target_folder: str) -> str:
"""Find the target folder in
"""Find the target folder in

Args:
response: The json dict of the HTTP response.
Expand Down Expand Up @@ -300,7 +300,7 @@ def _get_request(endpoint: str, graph_access: GraphAccess) -> requests.models.Re

Returns:
Response: The response object of the GET request.

Raises:
HTTPError: Any errors raised while performing GET request.
"""
Expand Down
12 changes: 7 additions & 5 deletions itk_dev_shared_components/sap/gridview_util.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""This module provides static functions to perform common tasks with SAP GuiGridView COM objects."""


def scroll_entire_table(grid_view, return_to_top=False) -> None:
"""This function scrolls through the entire table to load all cells.

Expand All @@ -23,7 +24,7 @@ def get_all_rows(grid_view, pre_load=True) -> tuple[tuple[str]]:
Args:
grid_view: A SAP GuiGridView object.
pre_load: Whether to first scroll through the table to load all values.
If a row hasn't been loaded before reading, the row data will be empty.
If a row hasn't been loaded before reading, the row data will be empty.

Returns:
tuple[tuple[str]]: A 2D tuple of all cell values in the gridview.
Expand All @@ -48,14 +49,14 @@ def get_all_rows(grid_view, pre_load=True) -> tuple[tuple[str]]:
return tuple(output)


def get_row(grid_view, row:int, scroll_to_row=False) -> tuple[str]:
def get_row(grid_view, row: int, scroll_to_row=False) -> tuple[str]:
"""Returns the data of a single row.

Args:
grid_view: A SAP GuiGridView object.
row: The zero-based index of the row.
scroll_to_row: Whether to scroll to the row before reading the data.
This ensures the data of the row has been loaded before reading.
This ensures the data of the row has been loaded before reading.

Returns:
tuple[str]: A tuple of the row's data.
Expand Down Expand Up @@ -108,7 +109,7 @@ def get_column_titles(grid_view) -> tuple[str]:
return tuple(grid_view.GetColumnTitles(c)[0] for c in grid_view.ColumnOrder)


def find_row_index_by_value(grid_view, column:str, value:str) -> int:
def find_row_index_by_value(grid_view, column: str, value: str) -> int:
"""Find the index of the first row where the given column's value
matches the given value. Returns -1 if no row is found.

Expand Down Expand Up @@ -137,7 +138,8 @@ def find_row_index_by_value(grid_view, column:str, value:str) -> int:

return -1

def find_all_row_indices_by_value(grid_view, column:str, value:str) -> list[int]:

def find_all_row_indices_by_value(grid_view, column: str, value: str) -> list[int]:
"""Find all indices of the rows where the given column's value
match the given value. Returns an empty list if no row is found.

Expand Down
69 changes: 44 additions & 25 deletions itk_dev_shared_components/sap/multi_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@
import pythoncom
import win32com.client
import win32gui
import win32api

def run_with_session(session_index:int, func:Callable, args:tuple) -> None:

def run_with_session(session_index: int, func: Callable, args: tuple) -> None:
"""Run a function in a specific session based on the sessions index.
This function is meant to be run inside a separate thread.
The function must take a session object as its first argument.
Note that this function will not spawn the sessions before running,
use spawn_sessions to do that.
"""

pythoncom.CoInitialize()

sap = win32com.client.GetObject("SAPGUI")
Expand All @@ -31,17 +32,24 @@ def run_with_session(session_index:int, func:Callable, args:tuple) -> None:
pythoncom.CoUninitialize()


def run_batch(func:Callable, args:tuple[tuple], num_sessions=6) -> None:
def run_batch(func: Callable, args: tuple[tuple]) -> None:
"""Run a function in parallel sessions.
The function will be run {num_sessions} times with args[i] as input.
A number of threads equal to the length of args will be spawned.
The function must take a session object as its first argument.
Note that this function will not spawn the sessions before running,
use spawn_sessions to do that.

Args:
func: A callable function to run in the threads.
args: A tuple of tuples containing arguments to be passed to func.

Raises:
Exception: Any exception raised in any of the threads.
"""

threads = []
for i in range(num_sessions):
t = ExThread(target=run_with_session, args=(i, func, args[i]))
for i, arg in enumerate(args):
t = ExThread(target=run_with_session, args=(i, func, arg))
threads.append(t)

for t in threads:
Expand All @@ -53,7 +61,7 @@ def run_batch(func:Callable, args:tuple[tuple], num_sessions=6) -> None:
raise t.error


def run_batches(func:Callable, args:tuple[tuple], num_sessions=6):
def run_batches(func: Callable, args: tuple[tuple], num_sessions: int = 6) -> None:
"""Run a function in parallel batches.
This function runs the input function for each set of arguments in args.
The function will be run in parallel batches of size {num_sessions}.
Expand All @@ -64,7 +72,7 @@ def run_batches(func:Callable, args:tuple[tuple], num_sessions=6):

for b in range(0, len(args), num_sessions):
batch = args[b:b+num_sessions]
run_batch(func, args, len(batch))
run_batch(func, batch)


def spawn_sessions(num_sessions=6) -> list:
Expand Down Expand Up @@ -100,14 +108,39 @@ def spawn_sessions(num_sessions=6) -> list:
while connection.Sessions.count < num_sessions:
time.sleep(0.1)

sessions = tuple(connection.Sessions)
arrange_sessions()

return tuple(connection.Sessions)


def get_all_sap_sessions() -> tuple:
"""Returns a tuple of all open SAP sessions (on connection index 0).

Returns:
tuple: A tuple of SAP GuiSession objects.
"""
sap = win32com.client.GetObject("SAPGUI")
app = sap.GetScriptingEngine
connection = app.Connections(0)
return tuple(connection.Sessions)


def arrange_sessions():
"""Take all toplevel windows of currently open SAP sessions
and arrange them equally on the screen.
"""
sessions = get_all_sap_sessions()
num_sessions = len(sessions)

# Calculate number of columns and rows
c = math.ceil(math.sqrt(num_sessions))
r = math.ceil(num_sessions / c)

w, h = 1920//c, 1040//r
screen_width = win32api.GetSystemMetrics(0)
screen_height = win32api.GetSystemMetrics(1)

w = screen_width // c
h = screen_height // r

for i, session in enumerate(sessions):
window = session.findById("wnd[0]")
Expand All @@ -117,20 +150,6 @@ def spawn_sessions(num_sessions=6) -> list:
y = i // c * h
win32gui.MoveWindow(hwnd, x, y, w, h, True)

return sessions


def get_all_sap_sessions() -> tuple:
"""Returns a tuple of all open SAP sessions (on connection index 0).

Returns:
tuple: A tuple of SAP GuiSession objects.
"""
sap = win32com.client.GetObject("SAPGUI")
app = sap.GetScriptingEngine
connection = app.Connections(0)
return tuple(connection.Sessions)


class ExThread(threading.Thread):
"""A thread with a handle to get an exception raised inside the thread: ExThread.error"""
Expand All @@ -141,5 +160,5 @@ def __init__(self, *args, **kwargs):
def run(self):
try:
self._target(*self._args, **self._kwargs)
except Exception as e: # pylint: disable=broad-exception-caught
except Exception as e: # pylint: disable=broad-exception-caught
self.error = e
Loading