UbuntuOneFilesIntegration
Practical Ubuntu One Files Integration - Instructors: Michael Terry
1 === ChanServ changed the topic of #ubuntu-classroom to: Welcome to the Ubuntu Classroom - https://wiki.ubuntu.com/Classroom || Support in #ubuntu || Upcoming Schedule: http://is.gd/8rtIi || Questions in #ubuntu-classroom-chat || Event: App Developer Week - Current Session: Practical Ubuntu One Files Integration - Instructors: mterry
2 [18:00] <ClassBot> Logs for this session will be available at http://irclogs.ubuntu.com/2011/09/09/%23ubuntu-classroom.html following the conclusion of the session.
3 [18:01] <dpm> talking of which, next up mterry will be talking about connecting your apps to the cloud with ubuntu one :)
4 [18:01] <mterry> Hello everybody! Thanks njpatel!
5 [18:01] <mterry> So I'm Michael Terry, an Ubuntu developer as well as the developer of Deja Dup, a backup program
6 [18:02] <mterry> I recently added support for Ubuntu One to my program, and I thought I'd share how that went, and some simple examples of how to connect to Ubuntu One Files
7 [18:02] <mterry> I have lots of notes for this session here: https://wiki.ubuntu.com/mterry/UbuntuOneFilesNotes11.10
8 [18:02] <mterry> Which you may be interested in going through as I talk
9 [18:02] <mterry> Please ask questions any old time
10 [18:03] <mterry> So, for this session (and for my purposes with Deja Dup), I only needed simple file functionality
11 [18:03] <mterry> get, put, list, delete basically
12 [18:03] <mterry> So we'll go through each of those basic ideas to help anyone else that's interested in integrating do so easily
13 [18:03] <mterry> We'll use Python, since that's most convenient
14 [18:04] <mterry> So let's create together a simple python script that can do basic file operations with U1
15 [18:04] <mterry> You'll need an updated ubuntuone-couch package from Ubuntu 11.10
16 [18:04] <mterry> I've backported it in my PPA
17 [18:04] <mterry> For this sessoin
18 [18:04] <mterry> So if you want to play along at home, but are stuck on Ubuntu 11.04, please do the following:
19 [18:05] <mterry> sudo add-apt-repository ppa:mterry/ppa2
20 [18:05] <mterry> sudo apt-get update
21 [18:05] <mterry> sudo apt-get upgrade
22 [18:05] <mterry> That will give you a new ubuntuone-couch
23 [18:05] <mterry> So to start, let's write a super simple Python script that can just accept an argument
24 [18:05] <mterry> ===
25 [18:05] <mterry> #!/usr/bin/python
26 [18:05] <mterry> import sys
27 [18:05] <mterry> if len(sys.argv) <= 1:
28 [18:05] <mterry> print "Need more arguments"
29 [18:05] <mterry> sys.exit(1)
30 [18:05] <mterry> print sys.argv[1:]
31 [18:05] <mterry> ===
32 [18:05] <mterry> Very basic. We'll augment this with more in a second
33 [18:06] <mterry> Save that as u1file.py
34 [18:06] <mterry> And open a terminal in that same directory
35 [18:06] <mterry> The first thing you have to do when interacting with U1 is make sure the user is logged in
36 [18:06] <mterry> There is a helper library for that in ubuntuone.platform.credentials
37 [18:06] <mterry> It's designed to work with Twisted and be asynchronous
38 [18:07] <mterry> But we just want simple synchronous behavior for now
39 [18:07] <mterry> So I'll show you a function that will fake synchronousity by opening an event loop and waiting for login to finish
40 [18:07] <mterry> ===
41 [18:07] <mterry> #!/usr/bin/python
42 [18:07] <mterry> import sys
43 [18:07] <mterry> _login_success = False
44 [18:07] <mterry> def login():
45 [18:07] <mterry> from gobject import MainLoop
46 [18:07] <mterry> from dbus.mainloop.glib import DBusGMainLoop
47 [18:07] <mterry> from ubuntuone.platform.credentials import CredentialsManagementTool
48 [18:07] <mterry> global _login_success
49 [18:07] <mterry> _login_success = False
50 [18:07] <mterry> DBusGMainLoop(set_as_default=True)
51 [18:07] <mterry> loop = MainLoop()
52 [18:07] <mterry> def quit(result):
53 [18:07] <mterry> global _login_success
54 [18:08] <mterry> loop.quit()
55 [18:08] <mterry> if result:
56 [18:08] <mterry> _login_success = True
57 [18:08] <mterry> cd = CredentialsManagementTool()
58 [18:08] <mterry> d = cd.login()
59 [18:08] <mterry> d.addCallbacks(quit)
60 [18:08] <mterry> loop.run()
61 [18:08] <mterry> if not _login_success:
62 [18:08] <mterry> sys.exit(1)
63 [18:08] <mterry> if len(sys.argv) <= 1:
64 [18:08] <mterry> print "Need more arguments"
65 [18:08] <mterry> sys.exit(1)
66 [18:08] <mterry> if sys.argv[1] == "login":
67 [18:08] <mterry> login()
68 [18:08] <mterry> ===
69 [18:08] <mterry> This may be easier to see on the wiki page https://wiki.ubuntu.com/mterry/UbuntuOneFilesNotes11.10#Logging_In
70 [18:08] <mterry> The important bit is from ubuntuone.platform.credentials import CredentialsManagementTool
71 [18:08] <mterry> followed by
72 [18:08] <mterry> cd = CredentialsManagementTool()
73 [18:08] <mterry> d = cd.login()
74 [18:08] <mterry> The rest is just wrappers to support the event loop
75 [18:08] <mterry> And to support calling "python u1file.py login"
76 [18:09] <mterry> So try running that now, and you should see a neat little U1 login screen
77 [18:09] <mterry> Unless... You've already used U1, in which case, nothing happens (because login() doesn't do anything in that case)
78 [18:09] <mterry> So let's add a logout function for testing purposes
79 [18:09] <mterry> ===
80 [18:09] <mterry> def logout():
81 [18:09] <mterry> from gobject import MainLoop
82 [18:09] <mterry> from dbus.mainloop.glib import DBusGMainLoop
83 [18:09] <mterry> from ubuntuone.platform.credentials import CredentialsManagementTool
84 [18:09] <mterry> DBusGMainLoop(set_as_default=True)
85 [18:09] <mterry> loop = MainLoop()
86 [18:09] <mterry> def quit(result):
87 [18:09] <mterry> loop.quit()
88 [18:09] <mterry> cd = CredentialsManagementTool()
89 [18:09] <mterry> d = cd.clear_credentials()
90 [18:09] <mterry> d.addCallbacks(quit)
91 [18:09] <mterry> loop.run()
92 [18:09] <mterry> if sys.argv[1] == "logout":
93 [18:10] <mterry> logout()
94 [18:10] <mterry> ===
95 [18:10] <mterry> Now add that to your script and you can call "python u1file.py logout" to go back to a clean slate
96 [18:10] <mterry> OK. So we have a skeleton script that can talk to U1, but it doesn't do anything yet!
97 [18:10] <mterry> Let's upload a file
98 [18:10] <mterry> Oh, whoops
99 [18:10] <mterry> First, we have to make sure we create a volume
100 [18:10] <mterry> In U1-speak, a volume is a folder that can be synchronized between the user's computers
101 [18:11] <mterry> By default, new volumes are not synchronized anywhere
102 [18:11] <mterry> But let's create a testing volume so that we can upload files without screwing anything up
103 [18:11] <mterry> Note that the "Ubuntu One" volume always exists
104 [18:11] <mterry> Creating a volume is a simple enough call:
105 [18:11] <mterry> ===
106 [18:11] <mterry> def create_volume(path):
107 [18:11] <mterry> import ubuntuone.couch.auth as auth
108 [18:11] <mterry> import urllib
109 [18:11] <mterry> base = "https://one.ubuntu.com/api/file_storage/v1/volumes/~/"
110 [18:11] <mterry> auth.request(base + urllib.quote(path), http_method="PUT")
111 [18:11] <mterry> if sys.argv[1] == "create-volume":
112 [18:11] <mterry> login()
113 [18:12] <mterry> create_volume(sys.argv[2])
114 [18:12] <mterry> ===
115 [18:12] <mterry> You'll see that we make a single PUT request to a specially crafted URL
116 [18:12] <mterry> There is no error handling in my snippets of code. I'll get into how to handle errors at the end
117 [18:12] <mterry> Add that to your u1file.py, and now you can call "python u1file.py create-volume testing"
118 [18:12] <mterry> If you open http://one.ubuntu.com/files/ you should be able to see the new volume
119 [18:13] <mterry> Congratulations if so!
120 [18:13] <mterry> You'll also note that I included a call to login() before creating the volume
121 [18:13] <mterry> This was to ensure that the user was logged in first
122 [18:13] <mterry> You'll also note that I made this weird auth.request call
123 [18:14] <mterry> This is a wrapper function provided by ubuntuone-couch that handles the OAuth signature required by U1 to securely identify the user
124 [18:14] <mterry> This is why you had to log in first
125 [18:14] <mterry> And the 11.10 version has some important fixes, which is why I backported it for this session
126 [18:14] <mterry> OK, *now* let's upload a file
127 [18:14] <mterry> (any questions?)
128 [18:14] <mterry> Uploading is a two-step process
129 [18:15] <mterry> First, we tell the server we want to create a new file
130 [18:15] <mterry> Then the server tells us a URL path to upload the contents to
131 [18:15] <mterry> I'll give you the code then we can talk about it
132 [18:15] <mterry> ===
133 [18:15] <mterry> def put(local, remote):
134 [18:15] <mterry> import json
135 [18:15] <mterry> import ubuntuone.couch.auth as auth
136 [18:15] <mterry> import mimetypes
137 [18:15] <mterry> import urllib
138 [18:15] <mterry> # Create remote path (which contains volume path)
139 [18:15] <mterry> base = "https://one.ubuntu.com/api/file_storage/v1/~/"
140 [18:15] <mterry> answer = auth.request(base + urllib.quote(remote),
141 [18:15] <mterry> http_method="PUT",
142 [18:15] <mterry> request_body='{"kind":"file"}')
143 [18:15] <mterry> node = json.loads(answer[1])
144 [18:15] <mterry> # Read info about local file
145 [18:16] <mterry> data = bytearray(open(local, 'rb').read())
146 [18:16] <mterry> size = len(data)
147 [18:16] <mterry> content_type = mimetypes.guess_type(local)[0]
148 [18:16] <mterry> content_type = content_type or 'application/octet-stream'
149 [18:16] <mterry> headers = {"Content-Length": str(size),
150 [18:16] <mterry> "Content-Type": content_type}
151 [18:16] <mterry> # Upload content of local file to content_path from original response
152 [18:16] <mterry> base = "https://files.one.ubuntu.com"
153 [18:16] <mterry> url = base + urllib.quote(node.get('content_path'), safe="/~")
154 [18:16] <mterry> auth.request(url, http_method="PUT",
155 [18:16] <mterry> headers=headers, request_body=data)
156 [18:16] <mterry> if sys.argv[1] == "put":
157 [18:16] <mterry> login()
158 [18:16] <mterry> put(sys.argv[2], sys.argv[3])
159 [18:16] <mterry> ===
160 [18:16] <mterry> There are three parts to this
161 [18:16] <mterry> First is the request to create a file
162 [18:16] <mterry> We give a URL path and PUT a specially crafted message "{'kind':'file'}"
163 [18:16] <mterry> Then, we read the local content
164 [18:16] <mterry> And push it to where the server told us to
165 [18:16] <mterry> (this is the "content_path" bit)
166 [18:16] <mterry> The response from the server (and the specially crafted message we gave it) is called JSON
167 [18:17] <mterry> It's a special format for encoding data structures as strings
168 [18:17] <mterry> Looks very Python-y
169 [18:17] <mterry> The 'json' module has support for reading and writing it
170 [18:17] <mterry> As you can see
171 [18:17] <mterry> We also use a different base URL for uploading the content
172 [18:17] <mterry> We use "files.one.ubuntu.com"
173 [18:18] <mterry> So now, let's try this new code out:
174 [18:18] <mterry> "python u1file.py put u1file.py testing/u1file.py"
175 [18:18] <mterry> This will upload our script to the new testing volume we created
176 [18:18] <mterry> Again, you can visit the U1 page in your browser and refresh it to see if it was created
177 [18:19] <mterry> If so, congrats!
178 [18:19] <mterry> Also note that we had to specify the content length and content type
179 [18:19] <mterry> These are mandatory
180 [18:19] <mterry> I calculated both in my example (using the mimetypes module)
181 [18:19] <mterry> But if you already know the mimetype, you can skip that bit of course
182 [18:20] <mterry> OK, let's try downloading the script we just uploaded
183 [18:20] <mterry> This is very similar, but uses GET requests instead of PUT ones
184 [18:20] <mterry> Again, two step process
185 [18:20] <mterry> We first get the metadata about the file, which tells us the content_path
186 [18:20] <mterry> And then we get the content
187 [18:20] <mterry> ===
188 [18:20] <mterry> def get(remote, local):
189 [18:20] <mterry> import json
190 [18:20] <mterry> import ubuntuone.couch.auth as auth
191 [18:20] <mterry> import urllib
192 [18:20] <mterry> # Request metadata
193 [18:20] <mterry> base = "https://one.ubuntu.com/api/file_storage/v1/~/"
194 [18:20] <mterry> answer = auth.request(base + urllib.quote(remote))
195 [18:20] <mterry> node = json.loads(answer[1])
196 [18:20] <mterry> # Request content
197 [18:21] <mterry> base = "https://files.one.ubuntu.com"
198 [18:21] <mterry> url = base + urllib.quote(node.get('content_path'), safe="/~")
199 [18:21] <mterry> answer = auth.request(url)
200 [18:21] <mterry> f = open(local, 'wb')
201 [18:21] <mterry> f.write(answer[1])
202 [18:21] <mterry> if sys.argv[1] == "get":
203 [18:21] <mterry> login()
204 [18:21] <mterry> get(sys.argv[2], sys.argv[3])
205 [18:21] <mterry> ===
206 [18:21] <mterry> Nothing ground breaking there
207 [18:21] <mterry> Again, we hit files.one.ubuntu.com for the content
208 [18:21] <mterry> And again, there is no error checking here
209 [18:21] <mterry> We'll get to that later
210 [18:21] <mterry> Let's try to download that script we uploaded
211 [18:21] <mterry> "python u1file.py get testing/u1file.py /tmp/u1file.py"
212 [18:22] <mterry> This will put it in /tmp/u1file.py
213 [18:22] <mterry> Now let's see what we downloaded
214 [18:22] <mterry> "less /tmp/u1file.py"
215 [18:22] <mterry> It should look right
216 [18:22] <mterry> So we can create volumes, upload, and download files
217 [18:23] <mterry> Big things left to do are list files, query metadata, and delete files
218 [18:23] <mterry> Let's start with listing
219 [18:23] <mterry> ===
220 [18:23] <mterry> def get_children(path):
221 [18:23] <mterry> import json
222 [18:23] <mterry> import ubuntuone.couch.auth as auth
223 [18:23] <mterry> import urllib
224 [18:23] <mterry> # Request children metadata
225 [18:23] <mterry> base = "https://one.ubuntu.com/api/file_storage/v1/~/"
226 [18:23] <mterry> url = base + urllib.quote(path) + "?include_children=true"
227 [18:23] <mterry> answer = auth.request(url)
228 [18:23] <mterry> # Create file list out of json data
229 [18:23] <mterry> filelist = []
230 [18:23] <mterry> node = json.loads(answer[1])
231 [18:23] <mterry> if node.get('has_children') == True:
232 [18:23] <mterry> for child in node.get('children'):
233 [18:23] <mterry> child_path = urllib.unquote(child.get('path')).lstrip('/')
234 [18:23] <mterry> filelist += [child_path]
235 [18:23] <mterry> print filelist
236 [18:23] <mterry> if sys.argv[1] == "list":
237 [18:23] <mterry> login()
238 [18:23] <mterry> get_children(sys.argv[2])
239 [18:23] <mterry> ===
240 [18:23] <mterry> This is very similar to downloading a file
241 [18:23] <mterry> But we add "?include_children=true" to the end of the request URL
242 [18:24] <mterry> Then we grab the list of children from the JSON data returned
243 [18:25] <mterry> black_puppydog has noted that my ubuntuone-couch backport has a bug preventing it from working right
244 [18:25] <mterry> I will prepare a new package
245 [18:25] <mterry> But you can fix it by doing the following
246 [18:26] <mterry> sudo gedit /usr/share/pyshared/ubuntuone-couch/ubuntuone/couch/auth.py
247 [18:26] <mterry> Search for ", disable_ssl_certificate_validation=True" near the bottom
248 [18:26] <mterry> And remove it
249 [18:26] <mterry> Sorry, I really thought I had tested with that
250 [18:27] <mterry> I've uploaded a fixed package, but it will take a few minutes to build
251 [18:27] <mterry> So to download the complete file we've got so far...
252 [18:27] <mterry> grab it here: https://wiki.ubuntu.com/mterry/UbuntuOneFilesNotes11.10?action=AttachFile&do=view&target=6.py
253 [18:28] <mterry> I'll give everyone a few seconds to catch up
254 [18:28] <mterry> Save that 6.py file as u1file.py
255 [18:28] <mterry> And do the following commands to get to the same state:
256 [18:28] <mterry> python u1file.py login
257 [18:28] <mterry> python u1file.py create-volume testing
258 [18:29] <mterry> python u1file.py put u1file.py testing/u1file.py
259 [18:29] <mterry> python u1file.py get testing/u1file.py /tmp/u1file.py
260 [18:29] <mterry> python u1file.py list testing
261 [18:29] <mterry> Really sorry about that
262 [18:30] <mterry> Note that if you are working on a project that needs to work in 11.04 but you still want this functionality
263 [18:30] <mterry> You can just locally make a copy of ubuntuone-couch's auth.py file and use it in your project (as long as the license is compatible of course)
264 [18:31] <mterry> OK, I'm going to wait just a moment longer to let people catch up and re-read the file now that it will actually work when they run it
265 [18:31] <mterry> So when you run "python u1file.py list testing" you should get a list of all the files you put there
266 [18:32] <mterry> Which I expect will just be the one u1file.py file
267 [18:32] <mterry> So now, let's see if we can't get a bit more info about that file
268 [18:32] <mterry> Sometimes you'll want to query file metadata
269 [18:32] <mterry> This is very much like downloading
270 [18:32] <mterry> But without getting the actual contents
271 [18:32] <mterry> ===
272 [18:32] <mterry> def query(path):
273 [18:32] <mterry> import json
274 [18:32] <mterry> import ubuntuone.couch.auth as auth
275 [18:32] <mterry> import urllib
276 [18:32] <mterry> # Request metadata
277 [18:32] <mterry> base = "https://one.ubuntu.com/api/file_storage/v1/~/"
278 [18:32] <mterry> url = base + urllib.quote(path)
279 [18:32] <mterry> answer = auth.request(url)
280 [18:33] <mterry> node = json.loads(answer[1])
281 [18:33] <mterry> # Print interesting info
282 [18:33] <mterry> print 'Size:', node.get('size')
283 [18:33] <mterry> if sys.argv[1] == "query":
284 [18:33] <mterry> login()
285 [18:33] <mterry> query(sys.argv[2])
286 [18:33] <mterry> ===
287 [18:33] <mterry> Adding that to your file will let you call "python u1file.py query testing/u1file.py"
288 [18:33] <mterry> You should see the size in bytes
289 [18:33] <mterry> There is a bit more metadata available (try inserting a "print node" in there to see it all)
290 [18:33] <mterry> And the last big file operation we'll cover is the easiest
291 [18:34] <mterry> Deleting files
292 [18:34] <mterry> ===
293 [18:34] <mterry> def delete(path):
294 [18:34] <mterry> import ubuntuone.couch.auth as auth
295 [18:34] <mterry> import urllib
296 [18:34] <mterry> base = "https://one.ubuntu.com/api/file_storage/v1/~/"
297 [18:34] <mterry> auth.request(base + urllib.quote(path), http_method="DELETE")
298 [18:34] <mterry> if sys.argv[1] == "delete":
299 [18:34] <mterry> login()
300 [18:34] <mterry> delete(sys.argv[2])
301 [18:34] <mterry> ===
302 [18:34] <mterry> That's simple. Merely an HTTP DELETE request to the metadata URL
303 [18:34] <mterry> This covers the basic file operations you'd want to do
304 [18:34] <mterry> I promised I'd talk about error handling
305 [18:34] <mterry> So behind the scenes, this is all done using HTTP
306 [18:35] <mterry> And the responses you get back from the server are all in HTTP
307 [18:35] <mterry> So it makes sense that to check what kind of response you got, you'd use HTTP status codes
308 [18:35] <mterry> You may be familiar with these
309 [18:35] <mterry> To look at a status code, with the above examples, you'd do something like:
310 [18:36] <mterry> answer = auth.request(...)
311 [18:36] <mterry> status = int(answer[0].get('status'))
312 [18:36] <mterry> answer is a tuple of 2
313 [18:36] <mterry> The first bit is HTTP headers
314 [18:36] <mterry> The second is the HTTP body
315 [18:36] <mterry> So we're asking for the 'status' HTTP header here
316 [18:36] <mterry> Any number in the 200s is an "operation succeeded" message
317 [18:36] <mterry> There are a few important status codes to be aware of
318 [18:36] <mterry> 400 is "permission denied"
319 [18:37] <mterry> 404 is "file not found"
320 [18:37] <mterry> 503 is "servers busy, please try again in a bit"
321 [18:37] <mterry> 507 is "out of space"
322 [18:37] <mterry> You may also just receive a boring old 500 status
323 [18:37] <mterry> This is like an "internal error" message
324 [18:37] <mterry> Which isn't very helpful, but usually you are also given an Oops ID to go with it
325 [18:38] <mterry> oops_id = answer[0].get('x-oops-id')
326 [18:38] <mterry> If you give this to the U1 server folks, they can tell you what happened and fix the bug
327 [18:38] <mterry> So if you're going to print a message for the user, include that so that when they report the bug, you'll have the Oops-ID to hand over
328 [18:39] <ClassBot> black_puppydog asked: how about checksums? this is needed for example in dejadup, right?
329 [18:40] <mterry> One piece of metadata is "hash"
330 [18:40] <mterry> That the server will give you
331 [18:40] <mterry> I actually have not used that, so I don't know what checksum algorithm it uses
332 [18:41] <mterry> But you can also just download the file and see (which is what Deja Dup does)
333 [18:41] <mterry> See https://one.ubuntu.com/developer/files/store_files/cloud/
334 [18:41] <mterry> For a list of other metadata pieces you can get from the server
335 [18:41] <mterry> That also has other useful info. It's the official documentation for this stuff
336 [18:42] <mterry> If anyone is interested, the Deja Dup code is actually in duplicity, a command line tool that Deja Dup is a wrapper for
337 [18:42] <mterry> http://bazaar.launchpad.net/~duplicity-team/duplicity/0.6-series/view/head:/duplicity/backends/u1backend.py
338 [18:42] <mterry> That's real code in use right now
339 [18:43] <mterry> If you ever have a problem playing with this stuff, the folks in #ubuntuone are very helpful
340 [18:43] <mterry> With Oops that you run into or whatever
341 [18:44] <mterry> And that's all I have! I'll hang around for questions if there are any
342 [18:46] <ClassBot> black_puppydog asked: this file you used here, shouldn't that be some sort of library?
343 [18:46] <mterry> black_puppydog, yeah, it very well could be
344 [18:46] <mterry> You mean, some sort of library supported by the U1 folks to make this all easier?
345 [18:46] <mterry> Well... They've already provided a lot of the code around it. I think their intention is to focus on providing the best generic API (the web HTTP one) that all sorts of devices and languages can use.
346 [18:47] <mterry> I think they'd be happy to see an awesome Python wrapper library, but I don't think they want to maintain and promote one such library at the expense of others
347 [18:47] <mterry> This is close, it would just need much better error handling and such
348 [18:47] <mterry> But I also don't want to maintain it :)
349 [18:48] <mterry> But really, it's not *that* much code. A bit boiler plate, true
350 [18:48] <mterry> ubuntuone-couch takes care of most of the icky parts that are hard to do well (OAuth authentication)
351 [18:49] <mterry> Most languages have REST and OAuth libraries that can be used in conjunction to talk to the servers
352 [18:50] <ClassBot> There are 10 minutes remaining in the current session.
353 [18:51] <mterry> black_puppydog makes a good point in the chat channel. The duplicity code has better error handling than I've presented. So it may be a better jumping-off point to just steal wholesale than the script we've built here
354 [18:52] <mterry> Note that it is licensed GPL-2+
355 [18:52] <mterry> So if that's not appropriate, maybe just whip something similar up yourself
MeetingLogs/appdevweek1109/UbuntuOneFilesIntegration (last edited 2011-09-12 15:37:34 by bl18-252-24)