fullscript.spellbook 2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fullscript.spellbook-2.1.dist-info/LICENSE +21 -0
- fullscript.spellbook-2.1.dist-info/METADATA +208 -0
- fullscript.spellbook-2.1.dist-info/RECORD +23 -0
- fullscript.spellbook-2.1.dist-info/WHEEL +5 -0
- fullscript.spellbook-2.1.dist-info/top_level.txt +1 -0
- spellbook/__init__.py +0 -0
- spellbook/db_magic.py +266 -0
- spellbook/drive_magic.py +266 -0
- spellbook/sheet_magic.py +181 -0
- spellbook/stats_magic.py +211 -0
- spellbook/utils.py +93 -0
- spellbook/wizards/wizard1.txt +11 -0
- spellbook/wizards/wizard10.txt +18 -0
- spellbook/wizards/wizard11.txt +14 -0
- spellbook/wizards/wizard12.txt +13 -0
- spellbook/wizards/wizard2.txt +2 -0
- spellbook/wizards/wizard3.txt +2 -0
- spellbook/wizards/wizard4.txt +7 -0
- spellbook/wizards/wizard5.txt +4 -0
- spellbook/wizards/wizard6.txt +3 -0
- spellbook/wizards/wizard7.txt +4 -0
- spellbook/wizards/wizard8.txt +22 -0
- spellbook/wizards/wizard9.txt +24 -0
spellbook/drive_magic.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
'''
|
|
2
|
+
What this script does:
|
|
3
|
+
|
|
4
|
+
https://www.thepythoncode.com/article/using-google-drive--api-in-python
|
|
5
|
+
https://developers.google.com/drive/api/v3/reference/files
|
|
6
|
+
'''
|
|
7
|
+
|
|
8
|
+
# importing packages
|
|
9
|
+
import os
|
|
10
|
+
import io
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import base64
|
|
13
|
+
import pickle
|
|
14
|
+
from googleapiclient.discovery import build
|
|
15
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
16
|
+
from google.auth.transport.requests import Request
|
|
17
|
+
from googleapiclient.http import MediaFileUpload
|
|
18
|
+
from googleapiclient.http import MediaIoBaseDownload
|
|
19
|
+
from googleapiclient.errors import HttpError
|
|
20
|
+
from google.oauth2 import service_account
|
|
21
|
+
from spellbook.utils import load_and_parse_config
|
|
22
|
+
|
|
23
|
+
'''
|
|
24
|
+
Authentication Process
|
|
25
|
+
'''
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_service(scopes, service_account_name, auth_type='service_account', **kwargs):
|
|
29
|
+
"""Gets Google Drive service
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
scopes : list
|
|
33
|
+
List of strings representing the scopes one needs service for.
|
|
34
|
+
Example: ['https://www.googleapis.com/auth/drive.metadata.readonly',
|
|
35
|
+
'https://www.googleapis.com/auth/drive.file']
|
|
36
|
+
auth_type : str
|
|
37
|
+
Authorization type selected. It can be 'service_account' or 'oauth'
|
|
38
|
+
|
|
39
|
+
Returns
|
|
40
|
+
-------
|
|
41
|
+
resource
|
|
42
|
+
Google Drive service object
|
|
43
|
+
:param scopes: The scopes needed for the service
|
|
44
|
+
:param service_account_name: The name of the Google Sheets service account to be used for authorization
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
if auth_type == 'service_account':
|
|
48
|
+
# option that uses the credential process we already have for sheets_magic
|
|
49
|
+
# this method requires a folder to be shared with the service account email (as we do with our sheets)
|
|
50
|
+
|
|
51
|
+
# Load the configuration
|
|
52
|
+
config = load_and_parse_config(configurations='google_accounts', **kwargs)
|
|
53
|
+
gs_service_account = None
|
|
54
|
+
|
|
55
|
+
# Find the Google Sheets service account details in the configuration
|
|
56
|
+
google_accounts = config.get('google_accounts', [])
|
|
57
|
+
for gs_account in google_accounts:
|
|
58
|
+
if gs_account.get('name') == service_account_name:
|
|
59
|
+
gs_service_account = gs_account.get('service_account')
|
|
60
|
+
break
|
|
61
|
+
|
|
62
|
+
if not gs_service_account:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"Google Sheets service account '{service_account_name}' not found in configuration file.")
|
|
65
|
+
|
|
66
|
+
# creates a credentials file using the info in the env var GS_SERVICE_ACCOUNT
|
|
67
|
+
with open('credentials.json', 'w') as credential_file:
|
|
68
|
+
print(base64.b64decode(os.environ['GS_SERVICE_ACCOUNT']).decode(), file=credential_file)
|
|
69
|
+
|
|
70
|
+
# uses the file to create the scoped credentials
|
|
71
|
+
credentials = service_account.Credentials.from_service_account_file('credentials.json')
|
|
72
|
+
scoped_credentials = credentials.with_scopes(scopes)
|
|
73
|
+
os.remove('credentials.json')
|
|
74
|
+
|
|
75
|
+
elif auth_type == 'oauth':
|
|
76
|
+
# This option will prompt the user to authorize the script to use their identity. This allows the script to read
|
|
77
|
+
# and write to any folder the user has access to.
|
|
78
|
+
# The file token.pickle stores the user's access and refresh tokens. It's automatically created at the first time
|
|
79
|
+
# the user completes the authorization.
|
|
80
|
+
# It's important to notice everytime a new scope is needed, the authorization needs to be run again.
|
|
81
|
+
|
|
82
|
+
# initializing scoped_credentials
|
|
83
|
+
scoped_credentials = None
|
|
84
|
+
|
|
85
|
+
# if the token.pickle already exists, we can get credentials from it
|
|
86
|
+
if os.path.exists('token.pickle'):
|
|
87
|
+
with open('token.pickle', 'rb') as token:
|
|
88
|
+
scoped_credentials = pickle.load(token)
|
|
89
|
+
|
|
90
|
+
# If the file doesn't exist or is expired, we need to run the authorization flow
|
|
91
|
+
if not scoped_credentials or not scoped_credentials.valid:
|
|
92
|
+
|
|
93
|
+
if scoped_credentials and scoped_credentials.expired and scoped_credentials.refresh_token:
|
|
94
|
+
# refresh the credentials
|
|
95
|
+
scoped_credentials.refresh(Request())
|
|
96
|
+
else:
|
|
97
|
+
# run the authorization flow from the client secrets stored as a env var
|
|
98
|
+
with open('client_secrets.json', 'w') as credential_file:
|
|
99
|
+
print(base64.b64decode(os.environ['GS_CLIENT_SECRETS']).decode(), file=credential_file)
|
|
100
|
+
|
|
101
|
+
flow = InstalledAppFlow.from_client_secrets_file('client_secrets.json', scopes)
|
|
102
|
+
scoped_credentials = flow.run_local_server(port=3000)
|
|
103
|
+
|
|
104
|
+
os.remove('client_secrets.json')
|
|
105
|
+
|
|
106
|
+
# save the credentials for the next run
|
|
107
|
+
with open('token.pickle', 'wb') as token:
|
|
108
|
+
pickle.dump(scoped_credentials, token)
|
|
109
|
+
|
|
110
|
+
# return the built service object.
|
|
111
|
+
return build('drive', 'v3', credentials=scoped_credentials)
|
|
112
|
+
except HttpError as error:
|
|
113
|
+
print(f'An error occurred: {error}')
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
'''
|
|
118
|
+
Other functions
|
|
119
|
+
'''
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def list_folder_contents(service, folder_url, max_results=1000, show_trashed=False):
|
|
123
|
+
"""Gets files in a specific folder
|
|
124
|
+
Parameters
|
|
125
|
+
----------
|
|
126
|
+
service : resource
|
|
127
|
+
Google Drive resource object
|
|
128
|
+
folder_url : str
|
|
129
|
+
Folder URL
|
|
130
|
+
max_results : int
|
|
131
|
+
Number of results to be returned
|
|
132
|
+
show_trashed : bool
|
|
133
|
+
Flag whether files in the trash should be returned
|
|
134
|
+
|
|
135
|
+
Returns
|
|
136
|
+
-------
|
|
137
|
+
pd.DataFrame
|
|
138
|
+
Dataframe with file data
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
# get the folder id
|
|
142
|
+
folder_id = folder_url.rsplit('/', 1)[-1]
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
# get items from GDrive
|
|
146
|
+
results = service.files().list(q=f"'{folder_id}' in parents and trashed = {str(show_trashed).lower()}",
|
|
147
|
+
pageSize=max_results, orderBy='name',
|
|
148
|
+
includeItemsFromAllDrives=True, supportsAllDrives=True,
|
|
149
|
+
fields="nextPageToken, files(id, name, mimeType, size, parents, modifiedTime)").execute()
|
|
150
|
+
items = results.get('files', [])
|
|
151
|
+
|
|
152
|
+
# returns as a DF
|
|
153
|
+
df_temp = pd.DataFrame(items)
|
|
154
|
+
df_temp = df_temp.rename({'size': 'size_bytes'}, axis=1)
|
|
155
|
+
return df_temp
|
|
156
|
+
except HttpError as error:
|
|
157
|
+
print(f'An error occurred: {error}')
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def download_file(service, file_id, filepath):
|
|
162
|
+
"""Downloads a file from Google Drive
|
|
163
|
+
Parameters
|
|
164
|
+
----------
|
|
165
|
+
service : resource
|
|
166
|
+
Google Drive resource object
|
|
167
|
+
file_id : str
|
|
168
|
+
ID of the file to be downloaded
|
|
169
|
+
filepath : str
|
|
170
|
+
File path where the downloaded file should be saved. (i.e.: 'samples/test_file.txt')
|
|
171
|
+
|
|
172
|
+
Returns
|
|
173
|
+
-------
|
|
174
|
+
bool
|
|
175
|
+
Flag whether the file was successfully downloaded or not
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
# pylint: disable=maybe-no-member
|
|
180
|
+
request = service.files().get_media(fileId=file_id, supportsAllDrives=True)
|
|
181
|
+
file = io.BytesIO()
|
|
182
|
+
downloader = MediaIoBaseDownload(file, request)
|
|
183
|
+
done = False
|
|
184
|
+
while done is False:
|
|
185
|
+
status, done = downloader.next_chunk()
|
|
186
|
+
print(F'Download {int(status.progress() * 100)}.')
|
|
187
|
+
|
|
188
|
+
with open(filepath, "wb") as f:
|
|
189
|
+
f.write(file.getbuffer())
|
|
190
|
+
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
except HttpError as error:
|
|
194
|
+
print(F'An error occurred: {error}')
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def upload_file(service, folder_url, filepath):
|
|
199
|
+
"""Gets files in a specific folder
|
|
200
|
+
Parameters
|
|
201
|
+
----------
|
|
202
|
+
service : resource
|
|
203
|
+
Google Drive resource object
|
|
204
|
+
folder_url : str
|
|
205
|
+
Folder URL
|
|
206
|
+
filepath : str
|
|
207
|
+
Path of the file to be uploaded
|
|
208
|
+
|
|
209
|
+
Returns
|
|
210
|
+
-------
|
|
211
|
+
str
|
|
212
|
+
ID of the created file
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
# get the folder id
|
|
216
|
+
folder_id = folder_url.rsplit('/', 1)[-1]
|
|
217
|
+
|
|
218
|
+
# first, define file metadata, such as the name and the parent folder ID
|
|
219
|
+
filename = os.path.basename(filepath)
|
|
220
|
+
file_metadata = {
|
|
221
|
+
"name": filename,
|
|
222
|
+
"parents": [folder_id]
|
|
223
|
+
}
|
|
224
|
+
# upload
|
|
225
|
+
try:
|
|
226
|
+
media = MediaFileUpload(filepath, resumable=True)
|
|
227
|
+
file = service.files().create(body=file_metadata, media_body=media, fields='id',
|
|
228
|
+
supportsAllDrives=True).execute()
|
|
229
|
+
print(f'File created! - id: {file.get("id")}')
|
|
230
|
+
return file.get("id")
|
|
231
|
+
except HttpError as error:
|
|
232
|
+
print(f'An error occurred: {error}')
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def delete_file(service, file_id):
|
|
237
|
+
"""Permanently deletes a file owned by the user without moving it to the trash. If the file belongs to a shared drive
|
|
238
|
+
the user must be an organizer on the parent. If the target is a folder, all descendants owned by the user are also
|
|
239
|
+
deleted. In case the delete method is unsuccessful, tries to move the file to trash.
|
|
240
|
+
Parameters
|
|
241
|
+
----------
|
|
242
|
+
service : resource
|
|
243
|
+
Google Drive resource object
|
|
244
|
+
file_id : str
|
|
245
|
+
ID of the file to be deleted
|
|
246
|
+
|
|
247
|
+
Returns
|
|
248
|
+
-------
|
|
249
|
+
bool
|
|
250
|
+
Flag whether the file was successfuly deleted or not
|
|
251
|
+
"""
|
|
252
|
+
try:
|
|
253
|
+
try:
|
|
254
|
+
# delete method
|
|
255
|
+
service.files().delete(fileId=file_id, supportsAllDrives=True).execute()
|
|
256
|
+
print('File deleted successfully!')
|
|
257
|
+
return True
|
|
258
|
+
except:
|
|
259
|
+
# move to trash method
|
|
260
|
+
body = {'trashed': True}
|
|
261
|
+
service.files().update(fileId=file_id, body=body, supportsAllDrives=True).execute()
|
|
262
|
+
print('Not able to delete the file. Moved to trash instead.')
|
|
263
|
+
return True
|
|
264
|
+
except HttpError as error:
|
|
265
|
+
print(f'An error occurred: {error}')
|
|
266
|
+
return False
|
spellbook/sheet_magic.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import pygsheets
|
|
4
|
+
import base64
|
|
5
|
+
from spellbook.utils import load_and_parse_config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GSheetsHandler:
|
|
9
|
+
"""
|
|
10
|
+
:param gsheet_url: The URL of the target Google Sheet workbook
|
|
11
|
+
:param service_account_name: The name of the Google Sheets service account to be used for authorization
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, gsheet_url, service_account_name):
|
|
15
|
+
self._gsheet_url = gsheet_url
|
|
16
|
+
self._service_account_name = service_account_name
|
|
17
|
+
if os.path.isfile('spellbook_config.yaml'):
|
|
18
|
+
self._config_file = 'spellbook_config.yaml'
|
|
19
|
+
else:
|
|
20
|
+
self._config_file = os.path.expanduser('~')+'/'+'spellbook_config.yaml'
|
|
21
|
+
self._df_results = pd.DataFrame()
|
|
22
|
+
self._gc = None
|
|
23
|
+
self._sh = None
|
|
24
|
+
|
|
25
|
+
# Initialize Google Sheets authorization
|
|
26
|
+
self._authorize_google_sheets()
|
|
27
|
+
|
|
28
|
+
# Open the Google Spreadsheet using the URL
|
|
29
|
+
if self._gc:
|
|
30
|
+
self._sh = self._gc.open_by_url(self._gsheet_url)
|
|
31
|
+
|
|
32
|
+
def _authorize_google_sheets(self):
|
|
33
|
+
"""
|
|
34
|
+
Private method to handle Google Sheets authorization using credentials from the configuration file.
|
|
35
|
+
Loads the service account credentials, decodes them, and uses them for authorization.
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
# Load the configuration
|
|
39
|
+
config = load_and_parse_config(config_file_path=self._config_file, configurations='google_accounts')
|
|
40
|
+
gs_service_account = None
|
|
41
|
+
|
|
42
|
+
# Find the Google Sheets service account details in the configuration
|
|
43
|
+
google_accounts = config.get('google_accounts', [])
|
|
44
|
+
for gs_account in google_accounts:
|
|
45
|
+
if gs_account.get('name') == self._service_account_name:
|
|
46
|
+
gs_service_account = gs_account.get('service_account')
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
if not gs_service_account:
|
|
50
|
+
raise ValueError(f"Google Sheets service account '{self._service_account_name}' not found in configuration file.")
|
|
51
|
+
|
|
52
|
+
# Decode and write credentials from configuration to a temporary file
|
|
53
|
+
with open("credentials.json", "w") as credential_file:
|
|
54
|
+
print(base64.b64decode(gs_service_account).decode(), file=credential_file)
|
|
55
|
+
|
|
56
|
+
# Use the JSON file to authenticate on Google Sheets
|
|
57
|
+
self._gc = pygsheets.authorize(service_file="credentials.json")
|
|
58
|
+
os.remove("credentials.json")
|
|
59
|
+
except FileNotFoundError:
|
|
60
|
+
print("Config file not found. Please ensure 'spelbook_config.yaml' exists.")
|
|
61
|
+
except ValueError as e:
|
|
62
|
+
print(f'Error: {e}')
|
|
63
|
+
except Exception as e:
|
|
64
|
+
print(f'Could not load Google Sheets service account. Error: {e}')
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def delete_sheet(self, worksheet):
|
|
68
|
+
"""
|
|
69
|
+
Deletes the specified worksheet from the Google Sheet.
|
|
70
|
+
If the worksheet does not exist, an error message is printed.
|
|
71
|
+
:param worksheet: The name or index of the worksheet to be deleted
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
if str(worksheet).isdigit():
|
|
75
|
+
# Delete sheet by index
|
|
76
|
+
self._sh.del_worksheet(self._sh[int(worksheet)])
|
|
77
|
+
else:
|
|
78
|
+
# Delete sheet by title
|
|
79
|
+
self._sh.del_worksheet(self._sh.worksheet_by_title(worksheet))
|
|
80
|
+
print('Tab deleted')
|
|
81
|
+
except pygsheets.WorksheetNotFound as error:
|
|
82
|
+
print(f'Error while deleting sheet: {error}')
|
|
83
|
+
|
|
84
|
+
def create_sheet(self, worksheet):
|
|
85
|
+
"""
|
|
86
|
+
Creates a new worksheet in the Google Sheet with the specified name.
|
|
87
|
+
If a worksheet with the given name already exists, it will not create a duplicate.
|
|
88
|
+
:param worksheet: The name of the new worksheet to create
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
self._sh.add_worksheet(str(worksheet))
|
|
92
|
+
except Exception as error:
|
|
93
|
+
print(f'Error while creating sheet: {error}')
|
|
94
|
+
|
|
95
|
+
def get_sheet(self, worksheet):
|
|
96
|
+
"""
|
|
97
|
+
Retrieves the specified worksheet object from the Google Sheet.
|
|
98
|
+
The worksheet can be accessed either by its name or its index.
|
|
99
|
+
If the worksheet does not exist, an error message is printed.
|
|
100
|
+
:param worksheet: The name or index of the worksheet to retrieve
|
|
101
|
+
:return: The worksheet object or None if not found
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
if str(worksheet).isdigit():
|
|
105
|
+
return self._sh[int(worksheet)]
|
|
106
|
+
else:
|
|
107
|
+
return self._sh.worksheet_by_title(worksheet)
|
|
108
|
+
except pygsheets.WorksheetNotFound:
|
|
109
|
+
print(f'Sheet does not exist')
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def count_rows(self, worksheet, target_column=1, include_tailing_empty=False):
|
|
113
|
+
"""
|
|
114
|
+
Counts the number of rows in the specified target column of a worksheet.
|
|
115
|
+
:param worksheet: The name of the worksheet to read from
|
|
116
|
+
:param target_column: The column number to count rows in
|
|
117
|
+
:param include_tailing_empty: Whether to include empty trailing cells in the count
|
|
118
|
+
:return: The count of rows in the specified column
|
|
119
|
+
"""
|
|
120
|
+
wks = self.get_sheet(worksheet)
|
|
121
|
+
if wks:
|
|
122
|
+
return len(wks.get_col(col=target_column, returnas='matrix', include_tailing_empty=include_tailing_empty))
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
def clear_range(self, worksheet, clear_range_str=None):
|
|
126
|
+
"""
|
|
127
|
+
Clears the specified range or the entire worksheet in the Google Sheet.
|
|
128
|
+
:param worksheet: The name of the worksheet to clear
|
|
129
|
+
:param clear_range_str: The range to be cleared (e.g., 'A1:B2'). If None, the entire worksheet will be cleared
|
|
130
|
+
"""
|
|
131
|
+
wks = self.get_sheet(worksheet)
|
|
132
|
+
if wks:
|
|
133
|
+
if clear_range_str:
|
|
134
|
+
clear_range = clear_range_str.split(':')
|
|
135
|
+
wks.clear(clear_range[0], clear_range[1])
|
|
136
|
+
else:
|
|
137
|
+
wks.clear()
|
|
138
|
+
|
|
139
|
+
def write_to_sheet(self, input_dataframe, worksheet, starting_pos='A1', extend=True, fit=False, nan='', clear_ws=True, clear_range_str=None):
|
|
140
|
+
"""
|
|
141
|
+
Writes a pandas DataFrame to the specified worksheet in the Google Sheet.
|
|
142
|
+
If the worksheet does not exist, it will be created.
|
|
143
|
+
Optionally clears the worksheet or a specific range before writing.
|
|
144
|
+
:param input_dataframe: The pandas DataFrame to be written to the worksheet
|
|
145
|
+
:param worksheet: The worksheet to write to
|
|
146
|
+
:param starting_pos: The starting cell position to write the DataFrame (e.g., 'A1')
|
|
147
|
+
:param extend: Whether to extend the worksheet to fit the DataFrame if necessary
|
|
148
|
+
:param fit: Whether to resize the worksheet to fit the entire DataFrame
|
|
149
|
+
:param nan: The value to replace NaN entries with
|
|
150
|
+
:param clear_ws: Whether to clear the worksheet before writing the DataFrame
|
|
151
|
+
:param clear_range_str: The range to be cleared before writing if clear_ws is True
|
|
152
|
+
"""
|
|
153
|
+
wks = self.get_sheet(worksheet)
|
|
154
|
+
if not wks:
|
|
155
|
+
print('Creating new sheet')
|
|
156
|
+
self.create_sheet(worksheet)
|
|
157
|
+
wks = self.get_sheet(worksheet)
|
|
158
|
+
|
|
159
|
+
if clear_ws and wks:
|
|
160
|
+
self.clear_range(worksheet=worksheet, clear_range_str=clear_range_str)
|
|
161
|
+
|
|
162
|
+
if wks:
|
|
163
|
+
wks.set_dataframe(df=input_dataframe, start=starting_pos, extend=extend, fit=fit, nan=nan)
|
|
164
|
+
print('Write to sheet completed')
|
|
165
|
+
|
|
166
|
+
def read_sheet(self, worksheet, target_range_str=None):
|
|
167
|
+
"""
|
|
168
|
+
Reads data from the specified worksheet and returns it as a pandas DataFrame.
|
|
169
|
+
If the target range is provided, only that specific range is read.
|
|
170
|
+
:param worksheet: The name of the worksheet to read from
|
|
171
|
+
:param target_range_str: The target range to read from (e.g., 'A1:B2'). If None, the entire worksheet will be read
|
|
172
|
+
:return: A pandas DataFrame containing the data from the specified range or worksheet
|
|
173
|
+
"""
|
|
174
|
+
wks = self.get_sheet(worksheet)
|
|
175
|
+
if wks:
|
|
176
|
+
if target_range_str:
|
|
177
|
+
target_range = target_range_str.split(':')
|
|
178
|
+
return wks.get_as_df(start=target_range[0], end=target_range[1])
|
|
179
|
+
else:
|
|
180
|
+
return wks.get_as_df()
|
|
181
|
+
return pd.DataFrame()
|
spellbook/stats_magic.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import scipy.stats as stats
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from statsmodels.stats.multicomp import pairwise_tukeyhsd
|
|
4
|
+
import scikit_posthocs as sp
|
|
5
|
+
|
|
6
|
+
'''
|
|
7
|
+
Statistical tests
|
|
8
|
+
'''
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def shapiro_wilk_normality_test(df, group_column, values_column, alpha_level = 0.05, verbose = False):
|
|
12
|
+
results = []
|
|
13
|
+
|
|
14
|
+
if verbose:
|
|
15
|
+
print('Shapiro-Wilk Normality Test - H0: Samples follow a normal distribution')
|
|
16
|
+
|
|
17
|
+
# getting list of groups
|
|
18
|
+
list_groups = list(set(df[group_column]))
|
|
19
|
+
|
|
20
|
+
for column in list_groups:
|
|
21
|
+
# p value of the test
|
|
22
|
+
p_shapiro = stats.shapiro(df[df[group_column] == column][values_column].dropna()).pvalue
|
|
23
|
+
|
|
24
|
+
# printing results
|
|
25
|
+
if p_shapiro < alpha_level:
|
|
26
|
+
result = 'H0 rejected - non-normal distribution'
|
|
27
|
+
else:
|
|
28
|
+
result = 'H0 cannot be rejected - normal distribution'
|
|
29
|
+
|
|
30
|
+
if verbose:
|
|
31
|
+
print('Shapiro-Wilk Normality Test for: {} - p = {:.3f} - Result: {}'.format(column, p_shapiro, result))
|
|
32
|
+
|
|
33
|
+
results.append([p_shapiro, result])
|
|
34
|
+
|
|
35
|
+
return pd.DataFrame(results, columns=['p_value', 'result'])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def levene_variance_test(df, group_column, values_column, alpha_level = 0.05, center_method = 'mean', verbose = False):
|
|
39
|
+
'''
|
|
40
|
+
Three variations of Levene’s test are possible. The possibilities and their recommended usages are:
|
|
41
|
+
‘median’ : Recommended for skewed (non-normal) distributions
|
|
42
|
+
‘mean’ : Recommended for symmetric, moderate-tailed distributions.
|
|
43
|
+
‘trimmed’ : Recommended for heavy-tailed distributions.
|
|
44
|
+
'''
|
|
45
|
+
# getting list of groups
|
|
46
|
+
list_groups = list(set(df[group_column]))
|
|
47
|
+
list_of_lists = [df[df[group_column] == column][values_column].dropna() for column in list_groups]
|
|
48
|
+
|
|
49
|
+
results = []
|
|
50
|
+
|
|
51
|
+
if verbose:
|
|
52
|
+
print("Levene's Variance Test - H0: Samples come from populations with equal variances")
|
|
53
|
+
|
|
54
|
+
stat_levene, p_levene = stats.levene(*list_of_lists, center=center_method)
|
|
55
|
+
|
|
56
|
+
if p_levene < alpha_level:
|
|
57
|
+
result = 'H0 rejected - different variances'
|
|
58
|
+
else:
|
|
59
|
+
result = 'H0 cannot be rejected - same variance'
|
|
60
|
+
|
|
61
|
+
if verbose:
|
|
62
|
+
print('Levene Variance Test: p = {:.3f} - Result: {}'.format(p_levene, result))
|
|
63
|
+
|
|
64
|
+
results.append([p_levene, result])
|
|
65
|
+
|
|
66
|
+
return pd.DataFrame(results, columns=['p_value', 'result'])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def anova(df, group_column, values_column, alpha_level = 0.05, verbose = False):
|
|
70
|
+
# getting list of groups
|
|
71
|
+
list_groups = list(set(df[group_column]))
|
|
72
|
+
list_of_lists = [df[df[group_column] == column][values_column].dropna() for column in list_groups]
|
|
73
|
+
|
|
74
|
+
results = []
|
|
75
|
+
|
|
76
|
+
# for normal data, One Way ANOVA
|
|
77
|
+
f_anova, p_anova = stats.f_oneway(*list_of_lists)
|
|
78
|
+
|
|
79
|
+
if p_anova < alpha_level:
|
|
80
|
+
result = 'H0 rejected - The average values have statistically significant differences.'
|
|
81
|
+
else:
|
|
82
|
+
result = 'H0 cannot be rejected - The average values have NOT statistically significant differences.'
|
|
83
|
+
|
|
84
|
+
if verbose:
|
|
85
|
+
print("ANOVA - H0: Average values are the same")
|
|
86
|
+
print('F statistic = {:5.3f} and probability p = {:5.3f}'.format(f_anova, p_anova))
|
|
87
|
+
print('Result: {}'.format(result))
|
|
88
|
+
|
|
89
|
+
results.append([f_anova, p_anova, result])
|
|
90
|
+
|
|
91
|
+
return pd.DataFrame(results, columns=['f_statistic', 'p_value', 'result'])
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def tukey_test(df, group_column, values_column, alpha_level = 0.05, verbose = False):
|
|
95
|
+
tukey_table = pairwise_tukeyhsd(endog=df[values_column], groups=df[group_column], alpha=alpha_level)
|
|
96
|
+
if verbose:
|
|
97
|
+
print("Tukey's Test - H0: No significant difference has been observed for the pair")
|
|
98
|
+
print(tukey_table.summary())
|
|
99
|
+
|
|
100
|
+
return tukey_table
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def mood_median_test(df, group_column, values_column, alpha_level = 0.05, verbose = False):
|
|
104
|
+
# getting list of groups
|
|
105
|
+
list_groups = list(set(df[group_column]))
|
|
106
|
+
list_of_lists = [df[df[group_column] == column][values_column].dropna() for column in list_groups]
|
|
107
|
+
|
|
108
|
+
results = []
|
|
109
|
+
|
|
110
|
+
# for non-normal data, Mood's Median Test
|
|
111
|
+
f_mood, p_mood, med_mood, tbl_mood = stats.median_test(*list_of_lists)
|
|
112
|
+
|
|
113
|
+
if p_mood < alpha_level:
|
|
114
|
+
result = 'H0 rejected - The median values have statistically significant differences.'
|
|
115
|
+
else:
|
|
116
|
+
result = 'H0 cannot be rejected - The median values have NOT statistically significant differences.'
|
|
117
|
+
|
|
118
|
+
if verbose:
|
|
119
|
+
print("Mood's Median Test - H0: Median values are the same")
|
|
120
|
+
print("F statistic = {:5.3f} and probability p = {:5.3f}".format(f_mood, p_mood))
|
|
121
|
+
print('Result: {}'.format(result))
|
|
122
|
+
|
|
123
|
+
results.append([f_mood, p_mood, result])
|
|
124
|
+
|
|
125
|
+
return pd.DataFrame(results, columns=['f_statistic', 'p_value', 'result'])
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def dunn_test(df, group_column, values_column, alpha_level = 0.05, verbose = False):
|
|
129
|
+
# Dunn's test to check where is the difference
|
|
130
|
+
dunn_results = sp.posthoc_dunn(
|
|
131
|
+
a=df
|
|
132
|
+
, val_col=values_column
|
|
133
|
+
, group_col=group_column
|
|
134
|
+
, p_adjust='bonferroni'
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# clean retults
|
|
138
|
+
list_groups = list(set(df[group_column]))
|
|
139
|
+
results = [[a, b, dunn_results[a][b]] for idx, a in enumerate(list_groups) for b in list_groups[idx + 1:]]
|
|
140
|
+
|
|
141
|
+
results = pd.DataFrame(results, columns=['a', 'b', 'p_value'])
|
|
142
|
+
|
|
143
|
+
results['reject'] = (results['p_value'] < alpha_level)
|
|
144
|
+
|
|
145
|
+
if verbose:
|
|
146
|
+
print("Dunn's Test - H0: No significant difference has been observed for the pair")
|
|
147
|
+
print(results)
|
|
148
|
+
|
|
149
|
+
return results
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def t_welch_test(df, group_column, values_column, equal_variances = True, alpha_level = 0.05, verbose = False):
|
|
153
|
+
# getting list of groups
|
|
154
|
+
list_groups = list(set(df[group_column]))
|
|
155
|
+
|
|
156
|
+
if len(list_groups) == 2:
|
|
157
|
+
|
|
158
|
+
results = []
|
|
159
|
+
|
|
160
|
+
list_of_lists = [df[df[group_column] == column][values_column].dropna() for column in list_groups]
|
|
161
|
+
|
|
162
|
+
# T test for same variance / Welch’s t-test for different variances
|
|
163
|
+
f_ttest, p_ttest = stats.ttest_ind(*list_of_lists, equal_var=equal_variances)
|
|
164
|
+
|
|
165
|
+
if p_ttest < alpha_level:
|
|
166
|
+
result = 'H0 rejected - The average values have statistically significant differences.'
|
|
167
|
+
else:
|
|
168
|
+
result = 'H0 cannot be rejected - The average values have NOT statistically significant differences.'
|
|
169
|
+
|
|
170
|
+
if verbose:
|
|
171
|
+
print("T-Test / Welch's T-Test - H0: Averages values are the same")
|
|
172
|
+
print("F-statistic = {:5.3f} and p-value = {:5.3f}".format(f_ttest, p_ttest))
|
|
173
|
+
print('Result: {}'.format(result))
|
|
174
|
+
|
|
175
|
+
results.append([f_ttest, p_ttest, result])
|
|
176
|
+
|
|
177
|
+
return pd.DataFrame(results, columns=['f_statistic', 'p_value', 'result'])
|
|
178
|
+
|
|
179
|
+
else:
|
|
180
|
+
print("T-Test / Welch's T-Test: Error: This functions requires exactly 2 groups")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def mann_whitney_u_test(df, group_column, values_column, alpha_level = 0.05, verbose = False):
|
|
184
|
+
# getting list of groups
|
|
185
|
+
list_groups = list(set(df[group_column]))
|
|
186
|
+
|
|
187
|
+
if len(list_groups) == 2:
|
|
188
|
+
|
|
189
|
+
results = []
|
|
190
|
+
|
|
191
|
+
list_of_lists = [df[df[group_column] == column][values_column].dropna() for column in list_groups]
|
|
192
|
+
|
|
193
|
+
# performs the test
|
|
194
|
+
stat_mann, p_mann = stats.mannwhitneyu(*list_of_lists)
|
|
195
|
+
|
|
196
|
+
if p_mann < alpha_level:
|
|
197
|
+
result = 'H0 rejected - Different distributions.'
|
|
198
|
+
else:
|
|
199
|
+
result = 'H0 cannot be rejected - Same distribution.'
|
|
200
|
+
|
|
201
|
+
if verbose:
|
|
202
|
+
print("Mann-Whitney U Test - H0: Same Distributions")
|
|
203
|
+
print("Statistic = {:5.3f} and p-value = {:5.3f}".format(stat_mann, p_mann))
|
|
204
|
+
print('Result: {}'.format(result))
|
|
205
|
+
|
|
206
|
+
results.append([stat_mann, p_mann, result])
|
|
207
|
+
|
|
208
|
+
return pd.DataFrame(results, columns=['statistic', 'p_value', 'result'])
|
|
209
|
+
|
|
210
|
+
else:
|
|
211
|
+
print("Mann-Whitney U Test: Error: This functions requires exactly 2 groups")
|