Skip to content

Commit ec211a5

Browse files
authored
[DA-1400] Rewrite client PPI validation tool for Python 3. (#1492)
* Reorganize new style client tools. * More setup of re-org client tools * Rewrite PPI data check tool in new tools * Increment version and add client tool. * linter fixes
1 parent 9917a1f commit ec211a5

File tree

12 files changed

+309
-119
lines changed

12 files changed

+309
-119
lines changed

rdr_service/client/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Tools"""

rdr_service/client_cli/rdr.py renamed to rdr_service/client/__main__.py

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#! /usr/bin/env python
22
#
3-
# RDR cli tool launcher
3+
# RDR tool launcher
44
#
55

66
# pylint: disable=superfluous-parens
@@ -11,25 +11,32 @@
1111
import re
1212
import sys
1313

14-
lib_paths = ["../service_libs", "service_libs"]
15-
import_path = "service_libs"
16-
1714

1815
def _grep_prop(filename, prop_name):
1916
"""
20-
Look for property in file
21-
:param filename: path to file and file name.
22-
:param prop_name: property to search for in file.
23-
:return: property value or None.
24-
"""
17+
Look for property in file
18+
:param filename: path to file and file name.
19+
:param prop_name: property to search for in file.
20+
:return: property value or None.
21+
"""
2522
fdata = open(filename, "r").read()
2623
obj = re.search("^{0} = ['|\"](.+)['|\"]$".format(prop_name), fdata, re.MULTILINE)
2724
if obj:
2825
return obj.group(1)
2926
return None
3027

28+
def _run_tool(lib_paths, import_path):
29+
"""
30+
Run the tools from the given path.
31+
"""
32+
# We need to run from the `rdr_service` directory, save the current directory
33+
cwd = os.path.abspath(os.curdir)
34+
if not cwd.endswith('rdr_service'):
35+
tmp_cwd = os.path.join(cwd, 'rdr_service')
36+
if not os.path.exists(tmp_cwd):
37+
raise FileNotFoundError('Unable to locate "rdr_service" directory.')
38+
os.chdir(tmp_cwd)
3139

32-
def run():
3340
args = copy.deepcopy(sys.argv)
3441

3542
show_usage = False
@@ -50,15 +57,16 @@ def run():
5057
lp = os.path.join(os.curdir, lib_path)
5158

5259
if not lp:
53-
print("ERROR: service libs path not found, aborting.")
60+
print("ERROR: tool library path not found, aborting.")
61+
os.chdir(cwd)
5462
exit(1)
5563

5664
command_names = list()
5765

5866
libs = glob.glob(os.path.join(lp, "*.py"))
5967
for lib in libs:
60-
mod_cmd = _grep_prop(lib, "mod_cmd")
61-
mod_desc = _grep_prop(lib, "mod_desc")
68+
mod_cmd = _grep_prop(lib, "tool_cmd")
69+
mod_desc = _grep_prop(lib, "tool_desc")
6270
if not mod_cmd:
6371
continue
6472

@@ -71,18 +79,33 @@ def run():
7179
mod = importlib.import_module("{0}.{1}".format(import_path, mod_name))
7280
exit_code = mod.run()
7381
print("finished.")
82+
os.chdir(cwd)
7483
return exit_code
7584

7685
if show_usage:
77-
print("\nusage: rdr.py command [-h|--help] [args]\n\navailable commands:")
86+
if 'rclient' in sys.argv[0]:
87+
print("\nusage: rclient command [-h|--help] [args]\n\navailable commands:")
88+
else:
89+
print("\nusage: python -m client command [-h|--help] [args]\n\navailable commands:")
7890

7991
command_names.sort()
8092
for gn in command_names:
8193
print(gn)
8294

8395
print("")
8496

97+
os.chdir(cwd)
98+
99+
100+
def run_client():
101+
"""
102+
User Client Tools
103+
"""
104+
lib_paths = ["rdr_service/client/client_libs", "client/client_libs", "../client_libs", "client_libs",
105+
"../../client_libs"]
106+
import_path = "rdr_service.client.client_libs"
107+
_run_tool(lib_paths, import_path)
108+
85109

86110
# --- Main Program Call ---
87-
if __name__ == "__main__":
88-
sys.exit(run())
111+
sys.exit(run_client())

rdr_service/service_libs/_program_template.py renamed to rdr_service/client/client_libs/_program_template.py

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#! /bin/env python
22
#
3-
# Template for RDR python program.
3+
# Template for RDR tool python program.
44
#
55

66
import argparse
@@ -10,47 +10,53 @@
1010
import logging
1111
import sys
1212

13-
from rdr_service.service_libs import GCPProcessContext
1413
from rdr_service.services.system_utils import setup_logging, setup_i18n
14+
from rdr_service.tools.tool_libs import GCPProcessContext
1515

1616
_logger = logging.getLogger("rdr_logger")
1717

18-
# mod_cmd and mod_desc name are required.
19-
mod_cmd = "template"
20-
mod_desc = "put program description here for help"
18+
# Tool_cmd and tool_desc name are required.
19+
# Remember to add/update bash completion in 'tool_lib/tools.bash'
20+
tool_cmd = "template"
21+
tool_desc = "put client tool help description here"
2122

2223

23-
class ProgramTemplateClass(object):
24-
def __init__(self, args):
24+
class ClientProgramTemplateClass(object):
25+
def __init__(self, args, gcp_env):
26+
"""
27+
:param args: command line arguments.
28+
:param gcp_env: gcp environment information, see: gcp_initialize().
29+
"""
2530
self.args = args
31+
self.gcp_env = gcp_env
2632

2733
def run(self):
2834
"""
29-
Main program process
30-
:return: Exit code value
31-
"""
32-
# TODO: write program main process here after setting 'mod_cmd' and 'mod_desc'...
35+
Main program process
36+
:return: Exit code value
37+
"""
38+
# TODO: write program main process here after setting 'tool_cmd' and 'tool_desc'...
3339
return 0
3440

3541

3642
def run():
3743
# Set global debug value and setup application logging.
3844
setup_logging(
39-
_logger, mod_cmd, "--debug" in sys.argv, "{0}.log".format(mod_cmd) if "--log-file" in sys.argv else None
45+
_logger, tool_cmd, "--debug" in sys.argv, "{0}.log".format(tool_cmd) if "--log-file" in sys.argv else None
4046
)
4147
setup_i18n()
4248

4349
# Setup program arguments.
44-
parser = argparse.ArgumentParser(prog=mod_cmd, description=mod_desc)
50+
parser = argparse.ArgumentParser(prog=tool_cmd, description=tool_desc)
4551
parser.add_argument("--debug", help="Enable debug output", default=False, action="store_true") # noqa
4652
parser.add_argument("--log-file", help="write output to a log file", default=False, action="store_true") # noqa
4753
parser.add_argument("--project", help="gcp project name", default="localhost") # noqa
4854
parser.add_argument("--account", help="pmi-ops account", default=None) # noqa
4955
parser.add_argument("--service-account", help="gcp iam service account", default=None) # noqa
5056
args = parser.parse_args()
5157

52-
with GCPProcessContext(mod_cmd, args.project, args.account, args.service_account):
53-
process = ProgramTemplateClass(args)
58+
with GCPProcessContext(tool_cmd, args.project, args.account, args.service_account) as gcp_env:
59+
process = ClientProgramTemplateClass(args, gcp_env)
5460
exit_code = process.run()
5561
return exit_code
5662

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
#! /bin/env python
2+
#
3+
# Template for RDR tool python program.
4+
#
5+
import argparse
6+
import logging
7+
import re
8+
import sys
9+
10+
from rdr_service.code_constants import EMAIL_QUESTION_CODE as EQC, LOGIN_PHONE_NUMBER_QUESTION_CODE as PNQC
11+
from rdr_service.services.gcp_utils import gcp_make_auth_header
12+
from rdr_service.services.system_utils import make_api_request
13+
from rdr_service.services.system_utils import setup_logging, setup_i18n
14+
from rdr_service.tools.tool_libs import GCPProcessContext
15+
16+
_logger = logging.getLogger("rdr_logger")
17+
18+
# Tool_cmd and tool_desc name are required.
19+
# Remember to add/update bash completion in 'tool_lib/tools.bash'
20+
tool_cmd = "ppi-check"
21+
tool_desc = "check participant ppi data in rdr"
22+
23+
24+
class CheckPPIDataClass(object):
25+
def __init__(self, args, gcp_env):
26+
"""
27+
:param args: command line arguments.
28+
:param gcp_env: gcp environment information, see: gcp_initialize().
29+
"""
30+
self.args = args
31+
self.gcp_env = gcp_env
32+
33+
def check_ppi_data(self):
34+
"""
35+
Fetch and process spreadsheet, then call CheckPpiData for results
36+
:param client: Client object
37+
:param args: program arguments
38+
"""
39+
# See if we have filter criteria
40+
if not self.args.email and not self.args.phone:
41+
do_filter = False
42+
else:
43+
do_filter = True
44+
45+
if not self.args.phone:
46+
self.args.phone = list()
47+
if not self.args.email:
48+
self.args.email = list()
49+
50+
csv_data = self.fetch_csv_data()
51+
ppi_data = dict()
52+
53+
# iterate over each data column, convert them into a dict.
54+
for column in range(0, len(csv_data[0]) - 1):
55+
row_dict = self.convert_csv_column_to_dict(csv_data, column)
56+
email = row_dict[EQC] if EQC in row_dict else None
57+
phone_no = row_dict[PNQC] if PNQC in row_dict else None
58+
59+
if do_filter is False or (email in self.args.email or phone_no in self.args.phone):
60+
# prioritize using email value over phone number for key
61+
key = email if email else phone_no
62+
ppi_data[key] = row_dict
63+
64+
if len(ppi_data) == 0:
65+
_logger.error("No participants matched filter criteria. aborting.")
66+
return
67+
68+
host = f'{self.gcp_env.project}.appspot.com'
69+
data = {"ppi_data": ppi_data}
70+
71+
headers = gcp_make_auth_header()
72+
code, resp = make_api_request(host, '/rdr/v1/CheckPpiData', headers=headers, json_data=data, req_type="POST")
73+
74+
if code != 200:
75+
_logger.error(f'API request failed. {code}: {resp}')
76+
return
77+
78+
self.log_ppi_results(resp["ppi_results"])
79+
80+
def fetch_csv_data(self):
81+
"""
82+
Download a google doc spreadsheet in CSV format
83+
:return: A list object with rows from spreadsheet
84+
"""
85+
host = 'docs.google.com'
86+
path = f'spreadsheets/d/{self.args.sheet_id}/export?format=csv&' + \
87+
f'id={self.args.sheet_id}s&gid={self.args.sheet_gid}'
88+
89+
code, resp = make_api_request(host, path, ret_type='text')
90+
if code != 200:
91+
_logger.error(f'Error fetching https://{host}{path}. {code}: {resp}')
92+
return
93+
94+
resp = resp.replace('\r', '')
95+
96+
csv_data = list()
97+
for row in resp.split('\n'):
98+
csv_data.append(row.split(','))
99+
100+
return csv_data
101+
102+
def convert_csv_column_to_dict(self, csv_data, column):
103+
"""
104+
Return a dictionary object with keys from the first column and values from the specified
105+
column.
106+
:param csv_data: File-like CSV text downloaded from Google spreadsheets. (See main doc.)
107+
:return: dict of fields and values for given column
108+
"""
109+
results = dict()
110+
111+
for row in csv_data:
112+
key = row[0]
113+
data = row[1:][column]
114+
115+
if data:
116+
if key not in results:
117+
results[key] = data.strip() if data else ""
118+
else:
119+
# append multiple choice questions
120+
results[key] += "|{0}".format(data.strip())
121+
122+
return results
123+
124+
def log_ppi_results(self, data):
125+
"""
126+
Formats and logs the validation results. See CheckPpiDataApi for response format details.
127+
"""
128+
clr = self.gcp_env.terminal_colors
129+
_logger.info(clr.fmt(''))
130+
_logger.info('Results:')
131+
_logger.info('=' * 110)
132+
133+
total = 0
134+
errors = 0
135+
for email, results in data.items():
136+
tests_count, errors_count = results["tests_count"], results["errors_count"]
137+
errors += errors_count
138+
total += tests_count
139+
log_lines = [
140+
clr.fmt(f" {email}: {tests_count} tests, {errors_count} errors",
141+
clr.fg_bright_green if errors_count == 0 else clr.fg_bright_red)
142+
]
143+
for message in results["error_messages"]:
144+
# Convert braces and unicode indicator to quotes for better readability
145+
message = re.sub("\['", '"', message)
146+
message = re.sub("'\]", '"', message)
147+
while ' ' in message:
148+
message = message.replace(' ', ' ')
149+
log_lines += ["\n " + message]
150+
_logger.info("".join(log_lines))
151+
_logger.info('=' * 110)
152+
_logger.info(f"Completed {total} tests across {len(data)} participants with {errors} errors.")
153+
154+
def run(self):
155+
"""
156+
Main program process
157+
:return: Exit code value
158+
"""
159+
self.check_ppi_data()
160+
return 0
161+
162+
163+
def run():
164+
# Set global debug value and setup application logging.
165+
setup_logging(
166+
_logger, tool_cmd, "--debug" in sys.argv, "{0}.log".format(tool_cmd) if "--log-file" in sys.argv else None
167+
)
168+
setup_i18n()
169+
170+
# Setup program arguments.
171+
parser = argparse.ArgumentParser(prog=tool_cmd, description=tool_desc)
172+
parser.add_argument("--debug", help="enable debug output", default=False, action="store_true") # noqa
173+
parser.add_argument("--log-file", help="write output to a log file", default=False, action="store_true") # noqa
174+
parser.add_argument("--project", help="gcp project name", default="localhost") # noqa
175+
parser.add_argument("--account", help="pmi-ops account", default=None) # noqa
176+
parser.add_argument("--service-account", help="gcp iam service account", default=None) # noqa
177+
178+
parser.add_argument("--sheet-id",
179+
help='google spreadsheet doc id, after the "/d/" in the URL. the doc must be public.') # noqa
180+
parser.add_argument("--sheet-gid", help='google spreadsheet sheet id, after "gid=" in the url.') # noqa
181+
parser.add_argument("--email", help=("only validate the given e-mail(s). Validate all by default. "
182+
"this flag may be repeated to specify multiple e-mails."), action="append") # noqa
183+
parser.add_argument("--phone", help=("only validate the given phone number. "
184+
"this flag may be repeated to specify multiple phone numbers."), action="append") # noqa
185+
args = parser.parse_args()
186+
187+
with GCPProcessContext(tool_cmd, args.project, args.account, args.service_account) as gcp_env:
188+
process = CheckPPIDataClass(args, gcp_env)
189+
exit_code = process.run()
190+
return exit_code
191+
192+
193+
# --- Main Program Call ---
194+
if __name__ == "__main__":
195+
sys.exit(run())

0 commit comments

Comments
 (0)