otter-service-stdalone 1.0.0__py3-none-any.whl → 1.1.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.
@@ -1 +1 @@
1
- __version__ = "1.0.0"
1
+ __version__ = "1.1.1"
@@ -4,10 +4,12 @@ import tornado.web
4
4
  import tornado.auth
5
5
  import os
6
6
  import uuid
7
+ import tornado.websocket
7
8
  from otter_service_stdalone import fs_logging as log
8
9
  from otter_service_stdalone import user_auth as u_auth
9
10
  from otter_service_stdalone import grade_notebooks
10
11
  from zipfile import ZipFile, ZIP_DEFLATED
12
+ import queue
11
13
 
12
14
 
13
15
  __UPLOADS__ = "/tmp/uploads"
@@ -16,6 +18,53 @@ log_error = f'{os.environ.get("ENVIRONMENT")}-logs'
16
18
  log_http = f'{os.environ.get("ENVIRONMENT")}-http-error'
17
19
 
18
20
  authorization_states = {} # used to protect against cross-site request forgery attacks.
21
+ session_queues = {}
22
+ session_messages = {}
23
+ session_callbacks = {}
24
+
25
+
26
+ class WebSocketHandler(tornado.websocket.WebSocketHandler):
27
+ """This handles updates to the application from otter-grader
28
+ """
29
+ def open(self):
30
+ """if logged in, start a callback once a second to see if there messages for the
31
+ logged in user
32
+ """
33
+ if self.get_secure_cookie("user"):
34
+ user_id = self.get_secure_cookie("user").decode('utf-8')
35
+ if user_id not in session_callbacks:
36
+ session_callbacks[user_id] = tornado.ioloop.PeriodicCallback(lambda: self.send_results(user_id), 1000)
37
+ session_callbacks[user_id].start()
38
+
39
+ def on_message(self, message):
40
+ pass # No action needed on incoming message
41
+
42
+ def on_close(self):
43
+ """stop the periodic classback on close
44
+ """
45
+ if self.get_secure_cookie("user"):
46
+ user_id = self.get_secure_cookie("user").decode('utf-8')
47
+ if user_id in session_callbacks and session_callbacks[user_id].callback:
48
+ session_callbacks[user_id].stop()
49
+ session_callbacks.pop(user_id)
50
+
51
+ def send_results(self, user_id):
52
+ """if there are messages for this user, send them to application by submission.
53
+ We save the messages in the session so if refresh on client side they still
54
+ have the updates
55
+ """
56
+ try:
57
+ if user_id in session_queues:
58
+ user_queue_dict = session_queues[user_id]
59
+ user_messages_dict = session_messages[user_id]
60
+ if user_queue_dict:
61
+ for result_id, q in user_queue_dict.items():
62
+ if not q.empty():
63
+ while not q.empty():
64
+ user_messages_dict[result_id].append(q.get())
65
+ self.write_message({"messages": user_messages_dict})
66
+ except tornado.websocket.WebSocketClosedError:
67
+ log.write_logs("ws-error", "Web Socket Problem", "", "", log_error)
19
68
 
20
69
 
21
70
  class HealthHandler(tornado.web.RequestHandler):
@@ -136,27 +185,26 @@ class Download(BaseHandler):
136
185
  msg += "Please check the code or upload your notebooks "
137
186
  msg += "and autograder.zip for grading again."
138
187
  self.render("index.html", download_message=msg)
139
- elif not os.path.exists(f"{directory}/grading-logs.txt"):
188
+ elif not os.path.exists(f"{directory}/final_grades.csv"):
140
189
  m = "Download: Results Not Ready"
141
190
  log.write_logs(download_code, m, f"{download_code}", "debug", log_debug)
142
191
  msg = "The results of your download are not ready yet. "
143
192
  msg += "Please check back."
144
193
  self.render("index.html", download_message=msg, dcode=download_code)
145
194
  else:
146
- if not os.path.isfile(f"{directory}/final_grades.csv"):
147
- m = "Download: final_grades.csv does not exist"
148
- t = "Problem grading notebooks see stack trace"
149
- log.write_logs(download_code, m, t, "debug", log_debug)
150
- with open(f"{directory}/final_grades.csv", "a") as f:
151
- m = "There was a problem grading your notebooks. Please see grading-logs.txt"
152
- f.write(m)
153
- f.close()
154
195
  m = "Download Success: Creating results.zip"
155
196
  log.write_logs(download_code, m, "", "debug", log_debug)
156
197
  with ZipFile(f"{directory}/results.zip", 'w') as zipF:
157
- for file in ["final_grades.csv", "grading-logs.txt"]:
158
- if os.path.isfile(f"{directory}/{file}"):
159
- zipF.write(f"{directory}/{file}", file, compress_type=ZIP_DEFLATED)
198
+ final_file = "final_grades.csv"
199
+ if os.path.isfile(f"{directory}/{final_file}"):
200
+ zipF.write(f"{directory}/{final_file}", final_file, compress_type=ZIP_DEFLATED)
201
+ for filename in os.listdir(f"{directory}/grading-summaries"):
202
+ file_path = os.path.join(f"{directory}/grading-summaries", filename)
203
+ if os.path.isfile(file_path):
204
+ f_path = f"grading-summaries-do-not-distribute/{filename}"
205
+ zipF.write(f"{file_path}", f_path, compress_type=ZIP_DEFLATED)
206
+ read_me = os.path.join(os.path.dirname(__file__), "static_files", "README_DO_NOT_DISTRIBUTE.txt")
207
+ zipF.write(read_me, "README_DO_NOT_DISTRIBUTE.txt", compress_type=ZIP_DEFLATED)
160
208
 
161
209
  self.set_header('Content-Type', 'application/octet-stream')
162
210
  self.set_header("Content-Description", "File Transfer")
@@ -179,7 +227,7 @@ class Upload(BaseHandler):
179
227
 
180
228
  Args:
181
229
  tornado (tornado.web.RequestHandler): The upload request handler
182
- """
230
+ """
183
231
  @tornado.web.authenticated
184
232
  def get(self):
185
233
  # this just redirects to login and displays main page
@@ -189,6 +237,11 @@ class Upload(BaseHandler):
189
237
  async def post(self):
190
238
  """this handles the post request and asynchronously launches the grader
191
239
  """
240
+ user = self.get_current_user()
241
+ user_id = user.decode('utf-8')
242
+ if user_id not in session_queues:
243
+ session_queues[user_id] = {}
244
+ session_messages[user_id] = {}
192
245
  g = grade_notebooks.GradeNotebooks()
193
246
  files = self.request.files
194
247
  results_path = str(uuid.uuid4())
@@ -219,11 +272,13 @@ class Upload(BaseHandler):
219
272
  fh.write(notebooks['body'])
220
273
  m = "Step 3: Uploaded Files Written to Disk"
221
274
  log.write_logs(results_path, m, f"Results Code: {results_path}", "debug", log_debug)
222
- m = "Please save this code. You can retrieve your files by submitting this code "
223
- m += f"in the \"Results\" section to the right: {results_path}"
275
+ m = "Please save this code; it appears in the \"Notebook Grading Progress\" section below. You can "
276
+ m += f"retrieve your files by submitting this code in the \"Results\" section to the right: {results_path}"
224
277
  self.render("index.html", message=m)
225
278
  try:
226
- await g.grade(auto_p, notebooks_path, results_path)
279
+ session_queues[user_id][results_path] = queue.Queue()
280
+ session_messages[user_id][results_path] = []
281
+ await g.grade(auto_p, notebooks_path, results_path, session_queues[user_id].get(results_path))
227
282
  except Exception as e:
228
283
  log.write_logs(results_path, "Grading Problem", str(e), "error", log_error)
229
284
  else:
@@ -244,8 +299,10 @@ application = tornado.web.Application([
244
299
  (r"/login", LoginHandler),
245
300
  (r"/upload", Upload),
246
301
  (r"/download", Download),
302
+ (r"/update", WebSocketHandler),
247
303
  (r"/oauth_callback", GitHubOAuthHandler),
248
304
  (r"/otterhealth", HealthHandler),
305
+ (r"/scripts/(.*)", tornado.web.StaticFileHandler, {"path": os.path.join(os.path.dirname(__file__), "scripts")}),
249
306
  ], **settings, debug=False)
250
307
 
251
308
 
@@ -1,7 +1,7 @@
1
1
  import asyncio
2
- import async_timeout
3
2
  from otter_service_stdalone import fs_logging as log, upload_handle as uh
4
3
  import os
4
+ from otter.grade import main as grade
5
5
 
6
6
  log_debug = f'{os.environ.get("ENVIRONMENT")}-debug'
7
7
  log_count = f'{os.environ.get("ENVIRONMENT")}-count'
@@ -12,8 +12,7 @@ class GradeNotebooks():
12
12
  """The class contains the async grade method for executing
13
13
  otter grader as well as a function for logging the number of
14
14
  notebooks to be graded
15
- """
16
-
15
+ """
17
16
  def count_ipynb_files(self, directory, extension):
18
17
  """this count the files for logging purposes"""
19
18
  count = 0
@@ -22,7 +21,7 @@ class GradeNotebooks():
22
21
  count += 1
23
22
  return count
24
23
 
25
- async def grade(self, p, notebooks_path, results_id):
24
+ async def grade(self, p, notebooks_path, results_id, user_queue):
26
25
  """Calls otter grade asynchronously and writes the various log files
27
26
  and results of grading generating by otter-grader
28
27
 
@@ -44,61 +43,32 @@ class GradeNotebooks():
44
43
  "",
45
44
  "info",
46
45
  f'{os.environ.get("ENVIRONMENT")}-count')
47
- log.write_logs(results_id, "Step 5: Notebook Folder configured for grader",
46
+ log.write_logs(results_id, "Step 5: Notebook Folder configured and grading started",
48
47
  f"Notebook Folder: {notebook_folder}",
49
48
  "debug",
50
49
  log_debug)
51
- command = [
52
- 'otter', 'grade',
53
- '-n', 'grader',
54
- '-a', p,
55
- notebook_folder,
56
- "--ext", "ipynb",
57
- "--containers", "10",
58
- "--timeout", "15",
59
- "-o", notebook_folder,
60
- "-v"
61
- ]
62
- log.write_logs(results_id, f"Step 6: Grading Start: {notebook_folder}",
63
- " ".join(command),
64
- "debug",
65
- log_debug)
66
- process = await asyncio.create_subprocess_exec(
67
- *command,
68
- stdin=asyncio.subprocess.PIPE,
69
- stdout=asyncio.subprocess.PIPE,
70
- stderr=asyncio.subprocess.PIPE
71
- )
72
50
 
73
- # this is waiting for communication back from the process
74
- # some images are quite big and take some time to build the first
75
- # time through - like 20 min for otter-grader
76
- async with async_timeout.timeout(2000):
77
- stdout, stderr = await process.communicate()
51
+ await grade(
52
+ name='grader',
53
+ autograder=p,
54
+ paths=(notebook_folder,),
55
+ containers=10,
56
+ timeout=300,
57
+ ext="ipynb",
58
+ output_dir=notebook_folder,
59
+ result_queue=user_queue
60
+ )
61
+ user_queue.put("Results available for download")
78
62
 
79
- with open(f"{notebook_folder}/grading-output.txt", "w") as f:
80
- for line in stdout.decode().splitlines():
81
- f.write(line + "\n")
82
- log.write_logs(results_id, "Step 7: Grading: Finished: Write: grading-output.txt",
83
- f"{notebook_folder}/grading-output.txt",
84
- "debug",
85
- log_debug)
86
- with open(f"{notebook_folder}/grading-logs.txt", "w") as f:
87
- for line in stderr.decode().splitlines():
88
- f.write(line + "\n")
89
- log.write_logs(results_id, "Step 8: Grading: Finished: Write grading-logs.txt",
90
- f"{notebook_folder}/grading-logs.txt",
91
- "debug",
92
- log_debug)
93
- log.write_logs(results_id, f"Step 9: Grading: Finished: {notebook_folder}",
94
- " ".join(command),
95
- "debug",
96
- log_debug)
97
- log.write_logs(results_id, f"Grading: Finished: {notebook_folder}",
98
- " ".join(command),
99
- "info",
100
- log_error)
101
- return True
63
+ log.write_logs(results_id, "Step 6: Grading: Finished",
64
+ f"{notebook_folder}",
65
+ "debug",
66
+ log_debug)
67
+ log.write_logs(results_id, f"Grading: Finished: {notebook_folder}",
68
+ "",
69
+ "info",
70
+ log_error)
71
+ return True
102
72
  except asyncio.TimeoutError:
103
73
  raise Exception(f'Grading timed out for {notebook_folder}')
104
74
  except Exception as e:
@@ -4,6 +4,39 @@
4
4
  <title>Upload Form</title>
5
5
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
6
6
  <script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
7
+ <style>
8
+ /* Styles to make the div scrollable after a certain height */
9
+ #messages {
10
+ max-height: 400px;
11
+ overflow-y: auto; /* Enable vertical scrolling */
12
+ padding: 10px;
13
+ width: auto;
14
+ }
15
+ /* Styles for the collapsible div */
16
+ .collapsible {
17
+ border: 1px solid #ccc;
18
+ width: auto;
19
+ margin-bottom: 10px;
20
+ }
21
+
22
+ /* Styles for the header */
23
+ .header {
24
+ background-color: #f1f1f1;
25
+ padding: 10px;
26
+ cursor: pointer;
27
+ }
28
+
29
+ /* Styles for the content */
30
+ .content {
31
+ max-height: 300px;
32
+ overflow-y: auto; /* Enable vertical scrolling */
33
+ padding: 10px;
34
+ display: none;
35
+ }
36
+
37
+
38
+ </style>
39
+ <script src="/scripts/web_socket.js"></script>
7
40
  </head>
8
41
  <body>
9
42
  <section class="container">
@@ -14,7 +47,7 @@
14
47
  Cloudbank Otter Grading - Beta
15
48
  </h1>
16
49
  <h2 class="subtitle">
17
- Upload your notebooks, grade them, and download results.
50
+ Upload your notebooks, grade them, and download results.
18
51
  </h2>
19
52
  </div>
20
53
  </div>
@@ -84,15 +117,17 @@
84
117
  {% end %}
85
118
  </form>
86
119
  </div>
87
- </div>
120
+ </div>
121
+ <div id="messages">
122
+ <p class="title is-3">Notebook Grading Progress</p>
123
+ <p id="none-msg">No submissions are being graded</p>
124
+ </div>
88
125
  </div>
89
126
  </div>
90
127
  <div class="hero is-info welcome is-small">
91
128
  <div class="hero-body">
92
129
  <div class="container">
93
- <h2 class="subtitle">
94
- Questions? Problems? email: sean.smorris at berkeley.edu
95
- </h2>
130
+ <span class="has-text-right is-pulled-right">email: sean.smorris@berkeley.edu</span>
96
131
  </div>
97
132
  </div>
98
133
  </div>
@@ -0,0 +1,127 @@
1
+ /**
2
+ * This is main function of the script. It sets up the web sockets and handles messages
3
+ * coming from the application. The last line of the script calls this function
4
+ */
5
+ function connectWebSocket() {
6
+ var loc = window.location;
7
+ var wsStart = loc.protocol === "https:" ? "ws://" : "ws://";
8
+ var wsUrl = wsStart + loc.host + "/update";
9
+
10
+ var ws = new WebSocket(wsUrl);
11
+ submission_divs = {}
12
+ reconnectInterval = 3000;
13
+ ws.onopen = function() {
14
+ console.log("WebSocket is open now.");
15
+ reconnectInterval = 3000;
16
+ };
17
+
18
+ ws.onmessage = function(event) {
19
+ var messagesDiv = document.getElementById("messages");
20
+ var noneMsgP = document.getElementById("none-msg");
21
+ noneMsgP.style.display = "none"
22
+ json_msgs = JSON.parse(event.data)["messages"];
23
+ console.log(json_msgs)
24
+ Object.entries(json_msgs).forEach(([submission_key, messages], index) => {
25
+ if (!(submission_key in submission_divs)){
26
+ messagesDiv.appendChild(_setUpNewSubmission(submission_key, index, messages))
27
+ } else {
28
+ _updateSubmission(submission_key, index, messages)
29
+ }
30
+
31
+ messagesDiv.scrollTop = messagesDiv.scrollHeight;
32
+ });
33
+ };
34
+
35
+ ws.onclose = function() {
36
+ //if closes attempts re-connect every few seconds
37
+ console.log("WebSocket is closed now.");
38
+ setTimeout(connectWebSocket, 3000);
39
+ };
40
+
41
+ ws.onerror = function(error) {
42
+ console.error('WebSocket error:', error);
43
+ ws.close();
44
+ };
45
+ };
46
+
47
+ /**
48
+ * This function sets up the div that will report progress on the given submission
49
+ * in the application
50
+ * @param {*} submission_key doanload code - unique to submission
51
+ * @param {*} index - the submission number in this session for the user
52
+ * @param {*} messages - all the messages that need to be added to progress
53
+ */
54
+ function _setUpNewSubmission(submission_key, index, messages){
55
+ var newMessage = document.createElement("div");
56
+ newMessage.id = submission_key
57
+ var newMessageHeader = document.createElement("div");
58
+ var newMessageContent = document.createElement("div");
59
+ newMessageContent.id = "msg-" + submission_key
60
+ newMessageHeader.id = "header-" + submission_key
61
+ var toggleSign = document.createElement("span");
62
+ var submissionHeader = document.createElement("span");
63
+ submissionHeader.innerHTML = " Submission Progress: Submission #" + (index+1)
64
+ submissionHeader.id = "sub-header-" + submission_key
65
+ newMessage.className = "collapsible"
66
+ newMessageHeader.className = "header"
67
+ toggleSign.innerHTML = '<i class="fas fa-minus-circle"></i>';
68
+
69
+ newMessageHeader.appendChild(toggleSign)
70
+ newMessageHeader.appendChild(submissionHeader)
71
+ newMessageContent.className = "content"
72
+ newMessageContent.style.display = "block"
73
+ newMessage.appendChild(newMessageHeader)
74
+ newMessage.appendChild(newMessageContent)
75
+ submission_divs[submission_key] = newMessage
76
+ const li = document.createElement('li');
77
+ li.textContent = "Download Code: " + submission_key;
78
+ newMessageContent.appendChild(li);
79
+ for (const m of messages) {
80
+ const li = document.createElement('li');
81
+ li.textContent = m;
82
+ newMessageContent.appendChild(li);
83
+ }
84
+ // Toggle the visibility of the scrollable content
85
+ newMessageHeader.addEventListener('click', () => {
86
+ if (newMessageContent.style.display == 'none') {
87
+ newMessageContent.style.display = 'block';
88
+ toggleSign.innerHTML = '<i class="fas fa-minus-circle"></i>';
89
+ } else {
90
+ newMessageContent.style.display = 'none';
91
+ toggleSign.innerHTML = '<i class="fas fa-plus-circle"></i>';
92
+ }
93
+ });
94
+ return newMessage;
95
+ }
96
+
97
+ /**
98
+ * This updates the progress div for this submission
99
+ *
100
+ * @param {*} submission_key doanload code - unique to submission
101
+ * @param {*} index - the submission number in this session for the user
102
+ * @param {*} messages - all the messages that need to be added to progress
103
+ */
104
+ function _updateSubmission(submission_key, index, messages){
105
+ var newMessageContent = document.getElementById("msg-" + submission_key);
106
+ var items = newMessageContent.getElementsByTagName("li");
107
+ var msgs = []
108
+ for (var i = 0; i < items.length; ++i) {
109
+ msgs.push(items[i].textContent)
110
+ }
111
+ for (const m of messages) {
112
+ if(!(msgs.includes(m))){
113
+ const li = document.createElement('li');
114
+ li.textContent = m;
115
+ newMessageContent.appendChild(li);
116
+ }
117
+ }
118
+ if(messages.includes("Results available for download")){
119
+ var header = document.getElementById("header-" + submission_key)
120
+ header.style.backgroundColor = '#7BAE37';
121
+ var subheader = document.getElementById("sub-header-" + submission_key)
122
+ subheader.innerHTML = " Submission Progress: Submission #" + (index+1) + "</br>Download Code: " + submission_key;
123
+ }
124
+ }
125
+
126
+
127
+ connectWebSocket()
@@ -0,0 +1,9 @@
1
+ Please do not distribute the grade summaries in the folder, grading-summaries-do-not-distribute!
2
+
3
+ These summaries contain the answers to all questions, including the hidden tests
4
+ run by the grader.
5
+
6
+ We have included these files so you can see what errors were made by a student
7
+ on their notebook without having to re-run the notebook yourself.
8
+
9
+ Thanks!
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: otter_service_stdalone
3
- Version: 1.0.0
3
+ Version: 1.1.1
4
4
  Summary: Grading Service for Instructors using Otter Grader
5
5
  Home-page: https://github.com/sean-morris/otter-service-stdalone
6
6
  Author: Sean Morris
@@ -1,19 +1,21 @@
1
- otter_service_stdalone/__init__.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
1
+ otter_service_stdalone/__init__.py,sha256=q8_5C0f-8mHWNb6mMw02zlYPnEGXBqvOmP3z0CEwZKM,22
2
2
  otter_service_stdalone/access_sops_keys.py,sha256=nboU5aZ84Elrm5vO0dMgpIF5LLcnecpNAwpxKvj6DvU,2129
3
- otter_service_stdalone/app.py,sha256=QaIV3_YfGyFVHv1RQLWzsbNe-msX0siYz2R2uKWlwis,10546
3
+ otter_service_stdalone/app.py,sha256=NCfmpjb1FePUCeAqcGzcYScfdgY4yQH3YJ-qSAA5UYI,13346
4
4
  otter_service_stdalone/fs_logging.py,sha256=IKFZkc5TmpI6G3vTYFAU9jDjQ-GT5aRxk8kdZ0h0kJE,2390
5
- otter_service_stdalone/grade_notebooks.py,sha256=qXFtUjKP2I_oaoP5LwnnVbiUxpwDXOzYGt6whcCg6MI,4559
6
- otter_service_stdalone/index.html,sha256=QbSQs31OZhWlCQFE5vvJOlNh-JHEJ3PZPgR4GukzrCA,6032
5
+ otter_service_stdalone/grade_notebooks.py,sha256=9pi4F4y5GCXf3kEQf8hCIJyFGOHx1zb8WZd2s8TqCuU,2945
6
+ otter_service_stdalone/index.html,sha256=-2dvU97tzBi-Ew8CKmAeB3dXNOgyvNZtw_QFt7nfwr0,6895
7
7
  otter_service_stdalone/upload_handle.py,sha256=PbpQEyUIPKercJ9hegKwvxHBvSc9uylhIfwjvHybjs0,5061
8
8
  otter_service_stdalone/user_auth.py,sha256=L9Kfj1BsQttAteHhRn71IUY8WX9nvBy3MXVGq1yjTtE,4253
9
+ otter_service_stdalone/scripts/web_socket.js,sha256=cV2RN1rCXzvv5-4SjamSsO8sgAuHgXemsXI3kK40oog,4943
9
10
  otter_service_stdalone/secrets/gh_key.dev.yaml,sha256=ORUVDu8SDcv0OE2ThwROppeg7y8oLkJJbPTCMn0s5l0,1138
10
11
  otter_service_stdalone/secrets/gh_key.local.yaml,sha256=NtPXXyGf1iSgJ9Oa2ahvIEf_fcmflB3dwd3LWyCgxis,1138
11
12
  otter_service_stdalone/secrets/gh_key.prod.yaml,sha256=6vgLqHzDp8cVAOJlpSXGDTUjSI6EyCb6f1-SSVG2rqw,1138
12
13
  otter_service_stdalone/secrets/gh_key.staging.yaml,sha256=cKVqj4dcwuz2LhXwMNQy_1skF8XCVQOX2diXNjAFJXg,1138
14
+ otter_service_stdalone/static_files/README_DO_NOT_DISTRIBUTE.txt,sha256=eMqBa1du1u0c07fuG3Eu9DDHuixRTFEbiQwrlvAnL1Y,353
13
15
  otter_service_stdalone/static_templates/403.html,sha256=7eO3XQsEkl4nF8PEeFkLwCzGBfdZ3kkkeu_Kgpgbh0k,1440
14
16
  otter_service_stdalone/static_templates/500.html,sha256=t6DeEMp8piSWyBToHb_JpTrw3GStAHFrozlmeuXyamg,1421
15
- otter_service_stdalone-1.0.0.dist-info/METADATA,sha256=WPq0akitm20hWEP0ghCmTiXhyqLcu1nP-FIv2VWacRw,1467
16
- otter_service_stdalone-1.0.0.dist-info/WHEEL,sha256=Z4pYXqR_rTB7OWNDYFOm1qRk0RX6GFP2o8LgvP453Hk,91
17
- otter_service_stdalone-1.0.0.dist-info/entry_points.txt,sha256=cx447chuIEl8ly9jEoF5-2xNhaKsWcIMDdhUMH_00qQ,75
18
- otter_service_stdalone-1.0.0.dist-info/top_level.txt,sha256=6UP22fD4OhbLt23E01v8Kvjn44vPRbyTIg_GqMYL-Ng,23
19
- otter_service_stdalone-1.0.0.dist-info/RECORD,,
17
+ otter_service_stdalone-1.1.1.dist-info/METADATA,sha256=7C9gTB5jMeSmzl-uDu4prX5reXWjd6s5HxVM1i1p3GE,1467
18
+ otter_service_stdalone-1.1.1.dist-info/WHEEL,sha256=Wyh-_nZ0DJYolHNn1_hMa4lM7uDedD_RGVwbmTjyItk,91
19
+ otter_service_stdalone-1.1.1.dist-info/entry_points.txt,sha256=cx447chuIEl8ly9jEoF5-2xNhaKsWcIMDdhUMH_00qQ,75
20
+ otter_service_stdalone-1.1.1.dist-info/top_level.txt,sha256=6UP22fD4OhbLt23E01v8Kvjn44vPRbyTIg_GqMYL-Ng,23
21
+ otter_service_stdalone-1.1.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (70.3.0)
2
+ Generator: setuptools (71.1.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5