Merge lp:~statik/txaws/here-have-some-s4 into lp:~txawsteam/txaws/trunk
- here-have-some-s4
- Merge into trunk
Status: | Work in progress |
---|---|
Proposed branch: | lp:~statik/txaws/here-have-some-s4 |
Merge into: | lp:~txawsteam/txaws/trunk |
Diff against target: | None lines |
To merge this branch: | bzr merge lp:~statik/txaws/here-have-some-s4 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Original txAWS Team | Pending | ||
Review via email: mp+10388@code.launchpad.net |
Commit message
Description of the change
Robert Collins (lifeless) wrote : | # |
Wow, code explosion. Uhm, This perhaps would be easier to review in
parts...
Firstly, the module should be txaws.storage.
storage.
Secondly, copyright headers. txaws does a much leaner one;
# Copyright (C) 2009 $PUT_YOUR_
# Licenced under the txaws licence available at /LICENSE in the txaws
source.
Please use that - it prevents skew between different modules.
See txaws/ec2/
Thirdly, I'm not sure what python versions we're aiming for. I note that
the simulator code depends on 'with', which carries an implicit version
requirement. Please document the version that the simulator supports
both in /README and perhaps the docstring for the simulator.
On Wed, 2009-08-19 at 14:45 +0000, Elliot Murphy wrote:
> === modified file 'README'
> --- README 2009-04-26 08:32:36 +0000
> +++ README 2009-08-19 14:36:56 +0000
> @@ -7,6 +7,10 @@
>
> * The epsilon python package (python-epsilon on Debian or similar
> systems)
>
> +* The S4 test server has a dependency on boto (python-boto) on Debian
> or similar)
> + This dependency should go away in favor of using txaws
> infrastructure (s4 was
> + originally developed separately from txaws)
This is a problem :). I'd much rather see code land without having a
boto dependency at any point: boto is rather ugly, and the code will
likely be a lot nicer right from the get-go if we don't have it.
> === added file 'txaws/s4/README'
see above - the location doesn't fit with the txaws source layout. This
is a storage server module (contrast with txaws.storage.
Having a README in a python module is odd. The content would be better
put in the __init__.py's docstring, so that pydoctor etc can show it.
> --- txaws/s4/README 1970-01-01 00:00:00 +0000
> +++ txaws/s4/README 2009-08-19 14:36:56 +0000
> @@ -0,0 +1,30 @@
> +S4 - a S3 storage system stub
> +======
> +
> +the server comes with some sample scripts so you can see how to use
> it.
> +
> +Using twistd
> +------------
> +
> +to start: ./start-s4.sh
> +to stop: ./stop-s4.sh
> +
> +the sample S4.tac defaults to port 8080. if you want to change that
> you can create your own S4.tac.
Given that this is a twisted process, it would be nice for the docs to
say
'to start: twistd -FOO -BAR -BAZ' rather than referring to a shell
script which by its nature won't work on windows.
>
> === added directory 'txaws/s4/contrib'
> === added file 'txaws/
> --- txaws/s4/
> +++ txaws/s4/
....
What is this file for? how is it used? It looks like a lot of
duplication with existing code in txaws.
The different (C) terms means it will need to be mentioned in some way
at the top level portion.
I suspect we need to add an AUTHORS file too.
> === added file 'txaws/s4/s4.py'
> ...+if __name__ == "__main__":
> + root = Root()
> + site = server.Site(root)
> + reactor.
> + reactor.run()
I've skipped most of this file pending the boto dependency being
removed. But I thought I'd mention that this fragment above is highl...
Elliot Murphy (statik) wrote : | # |
Thanks a lot for the quick review. The code is very much in the state
it was being used internally, and I think your comments all make sense
and will improve the code. I differ on the license header thing - I
explicitly chose not to copy the existing indirect way of specifying
the license. You'd need to go back to the copyright holder to change
the license anyway, so specifying the license that way is not a good
idea IMO.
Just to set expectations, I don't expect to have time to remove the
boto dependency or work on the more involved changes requested before
Karmic ships. Duncan asked about the code and I got it published in
the spirit of jfdi; now that it is free to the world in this branch
I'm back to more pressing Karmic related hacking. I think it's
entirely reasonable for you to want the boto dependency dropped before
it's merged, I just want to be up front and explain that this branch
will probably sit for a couple of months before lucio or I or
Christian will be able to give it that level of attention. I'd
actually like to kill the whole contrib directory too.
--
Elliot Murphy
On Aug 19, 2009, at 9:45 PM, Robert Collins
<email address hidden> wrote:
> Wow, code explosion. Uhm, This perhaps would be easier to review in
> parts...
>
> Firstly, the module should be txaws.storage.
> storage.
>
> Secondly, copyright headers. txaws does a much leaner one;
>
> # Copyright (C) 2009 $PUT_YOUR_
> # Licenced under the txaws licence available at /LICENSE in the txaws
> source.
>
> Please use that - it prevents skew between different modules.
>
> See txaws/ec2/
>
>
> Thirdly, I'm not sure what python versions we're aiming for. I note
> that
> the simulator code depends on 'with', which carries an implicit
> version
> requirement. Please document the version that the simulator supports
> both in /README and perhaps the docstring for the simulator.
>
>
>
> On Wed, 2009-08-19 at 14:45 +0000, Elliot Murphy wrote:
>> === modified file 'README'
>> --- README 2009-04-26 08:32:36 +0000
>> +++ README 2009-08-19 14:36:56 +0000
>> @@ -7,6 +7,10 @@
>>
>> * The epsilon python package (python-epsilon on Debian or similar
>> systems)
>>
>> +* The S4 test server has a dependency on boto (python-boto) on
>> Debian
>> or similar)
>> + This dependency should go away in favor of using txaws
>> infrastructure (s4 was
>> + originally developed separately from txaws)
>
> This is a problem :). I'd much rather see code land without having a
> boto dependency at any point: boto is rather ugly, and the code will
> likely be a lot nicer right from the get-go if we don't have it.
>
>
>> === added file 'txaws/s4/README'
>
> see above - the location doesn't fit with the txaws source layout.
> This
> is a storage server module (contrast with txaws.storage.
>
> Having a README in a python module is odd. The content would be better
> put in the __init__.py's docstring, so that pydoctor etc can show it.
>
>
>> --- txaws/s4/README 1970-01-01 00:00:00 +0000
>> +++ txaws/s4/README 2009-08-19 14:36:56 +0000
>> @@ -0,0 +1,30 @@
>> +S4 - a S3...
Robert Collins (lifeless) wrote : | # |
On Thu, 2009-08-20 at 03:36 +0000, Elliot Murphy wrote:
> Thanks a lot for the quick review. The code is very much in the state
> it was being used internally, and I think your comments all make sense
> and will improve the code. I differ on the license header thing - I
> explicitly chose not to copy the existing indirect way of specifying
> the license. You'd need to go back to the copyright holder to change
> the license anyway, so specifying the license that way is not a good
> idea IMO.
Licensing is important - while we build free/open source software on the
hack of using copyright to ensure the right to copy :). I think you'll
find all the existing code in txaws uses the pithy approach; the work as
a whole is whats licensed : the centralisation isn't to make /changing/
the license easy - its to make auditing, checking and reading code
easier. Debian for instance, wants to be sure that all relevant licences
are listed; having the licence duplicated in many source files makes
that harder. There is also a DRY aspect to it.
If you believe there is a risk that the code won't be properly protected
(from what - the project licence is MIT - essentially public domain)
then we should certainly investigate that further. Otherwise, I don't
see what is gained by having the same text duplicated in each file, and
really think the shorter reference is much more pleasant. (When I first
joined the project, I'm not even sure there _was_ a license :)).
> Just to set expectations, ...o be up front and explain that this branch
> will probably sit for a couple of months before lucio or I or
> Christian will be able to give it that level of attention. I'd
> actually like to kill the whole contrib directory too.
> --
That's fine with me too - now its out there its possible for someone to
stand up and clean it up too.
-Rob
Jamu Kakar (jkakar) wrote : | # |
There's been no movement on this for quite some time, so I'm going to
mark it as 'Work in Progress'.
Unmerged revisions
- 9. By Elliot Murphy
-
Dropping start-s4 and stop-s4, those aren't really a good fit for
including directly into txaws. - 8. By Elliot Murphy
-
Contributing the initial version of S4 (previously unpublished, used
internally by Ubuntu One for testing since 2008).
Preview Diff
1 | === modified file 'README' | |||
2 | --- README 2009-04-26 08:32:36 +0000 | |||
3 | +++ README 2009-08-19 14:36:56 +0000 | |||
4 | @@ -7,6 +7,10 @@ | |||
5 | 7 | 7 | ||
6 | 8 | * The epsilon python package (python-epsilon on Debian or similar systems) | 8 | * The epsilon python package (python-epsilon on Debian or similar systems) |
7 | 9 | 9 | ||
8 | 10 | * The S4 test server has a dependency on boto (python-boto) on Debian or similar) | ||
9 | 11 | This dependency should go away in favor of using txaws infrastructure (s4 was | ||
10 | 12 | originally developed separately from txaws) | ||
11 | 13 | |||
12 | 10 | Things present here | 14 | Things present here |
13 | 11 | ------------------- | 15 | ------------------- |
14 | 12 | 16 | ||
15 | 13 | 17 | ||
16 | === added directory 'txaws/s4' | |||
17 | === added file 'txaws/s4/README' | |||
18 | --- txaws/s4/README 1970-01-01 00:00:00 +0000 | |||
19 | +++ txaws/s4/README 2009-08-19 14:36:56 +0000 | |||
20 | @@ -0,0 +1,30 @@ | |||
21 | 1 | S4 - a S3 storage system stub | ||
22 | 2 | ============================= | ||
23 | 3 | |||
24 | 4 | the server comes with some sample scripts so you can see how to use it. | ||
25 | 5 | |||
26 | 6 | Using twistd | ||
27 | 7 | ------------ | ||
28 | 8 | |||
29 | 9 | to start: ./start-s4.sh | ||
30 | 10 | to stop: ./stop-s4.sh | ||
31 | 11 | |||
32 | 12 | the sample S4.tac defaults to port 8080. if you want to change that you can create your own S4.tac. | ||
33 | 13 | |||
34 | 14 | For tests or inside another script | ||
35 | 15 | ---------------------------------- | ||
36 | 16 | |||
37 | 17 | see s4.tests.test_S4.S4TestBase | ||
38 | 18 | |||
39 | 19 | all tests run in a random unused port. | ||
40 | 20 | |||
41 | 21 | |||
42 | 22 | |||
43 | 23 | Notes: | ||
44 | 24 | ====== | ||
45 | 25 | Based on twisted | ||
46 | 26 | Storage is in memory | ||
47 | 27 | Its not optimal by any means, its just for testing other code. | ||
48 | 28 | For now, it just implements REST put and GET | ||
49 | 29 | it comes with a default /test/ bucket already created and a /size/ bucket with virtual objects the size of its name (ie, /size/100 == "0"*100) | ||
50 | 30 | |||
51 | 0 | 31 | ||
52 | === added file 'txaws/s4/S4.tac' | |||
53 | --- txaws/s4/S4.tac 1970-01-01 00:00:00 +0000 | |||
54 | +++ txaws/s4/S4.tac 2009-08-19 14:36:56 +0000 | |||
55 | @@ -0,0 +1,74 @@ | |||
56 | 1 | # -*- python -*- | ||
57 | 2 | # Copyright 2008-2009 Canonical Ltd. | ||
58 | 3 | # Permission is hereby granted, free of charge, to any person obtaining | ||
59 | 4 | # a copy of this software and associated documentation files (the | ||
60 | 5 | # "Software"), to deal in the Software without restriction, including | ||
61 | 6 | # without limitation the rights to use, copy, modify, merge, publish, | ||
62 | 7 | # distribute, sublicense, and/or sell copies of the Software, and to | ||
63 | 8 | # permit persons to whom the Software is furnished to do so, subject to | ||
64 | 9 | # the following conditions: | ||
65 | 10 | # | ||
66 | 11 | # The above copyright notice and this permission notice shall be | ||
67 | 12 | # included in all copies or substantial portions of the Software. | ||
68 | 13 | # | ||
69 | 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | ||
70 | 15 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | ||
71 | 16 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | ||
72 | 17 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE | ||
73 | 18 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | ||
74 | 19 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | ||
75 | 20 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | ||
76 | 21 | |||
77 | 22 | from __future__ import with_statement | ||
78 | 23 | |||
79 | 24 | import os | ||
80 | 25 | import logging | ||
81 | 26 | from optparse import OptionParser | ||
82 | 27 | |||
83 | 28 | import twisted.web.server | ||
84 | 29 | from twisted.internet import reactor | ||
85 | 30 | from twisted.application import internet, service | ||
86 | 31 | |||
87 | 32 | from utils import get_arbitrary_port | ||
88 | 33 | from ubuntuone.config import config | ||
89 | 34 | |||
90 | 35 | logger = logging.getLogger("UbuntuOne.S4") | ||
91 | 36 | logger.setLevel(config.general.log_level) | ||
92 | 37 | log_folder = config.general.log_folder | ||
93 | 38 | log_filename = config.s_four.log_filename | ||
94 | 39 | if log_folder is not None and log_filename is not None: | ||
95 | 40 | if not os.access(log_folder, os.F_OK): | ||
96 | 41 | os.mkdir(log_folder) | ||
97 | 42 | s = logging.FileHandler(os.path.join(log_folder, log_filename)) | ||
98 | 43 | else: | ||
99 | 44 | s = logging.StreamHandler(sys.stderr) | ||
100 | 45 | s.setFormatter(logging.Formatter(config.general.log_format)) | ||
101 | 46 | logger.addHandler(s) | ||
102 | 47 | |||
103 | 48 | from s4 import s4 | ||
104 | 49 | |||
105 | 50 | if config.s_four.storagepath: | ||
106 | 51 | storedir = os.path.join(config.root, config.s_four.storagepath) | ||
107 | 52 | else: | ||
108 | 53 | storedir = os.path.join(config.root, "tmp", "s4storage") | ||
109 | 54 | if not os.path.exists(storedir): | ||
110 | 55 | logger.debug("creating S4 storage directory %s" % storedir) | ||
111 | 56 | os.mkdir(storedir) | ||
112 | 57 | application = service.Application('s4') | ||
113 | 58 | root = s4.Root(storagedir=storedir) | ||
114 | 59 | # make sure "the bucket" is created | ||
115 | 60 | root._add_bucket(config.api_server.s3_bucket) | ||
116 | 61 | site = twisted.web.server.Site(root) | ||
117 | 62 | |||
118 | 63 | port = os.getenv('S4PORT', config.aws_s3.port) | ||
119 | 64 | if port: | ||
120 | 65 | port = int(port) | ||
121 | 66 | # we test again in case the initial value was the "0" as a string | ||
122 | 67 | if not port: | ||
123 | 68 | port = get_arbitrary_port() | ||
124 | 69 | |||
125 | 70 | with open(os.path.join(config.root, "tmp", "s4.port"), "w") as s4pf: | ||
126 | 71 | s4pf.write("%d\n" % port) | ||
127 | 72 | |||
128 | 73 | internet.TCPServer(port, site).setServiceParent( | ||
129 | 74 | service.IServiceCollection(application)) | ||
130 | 0 | 75 | ||
131 | === added file 'txaws/s4/__init__.py' | |||
132 | --- txaws/s4/__init__.py 1970-01-01 00:00:00 +0000 | |||
133 | +++ txaws/s4/__init__.py 2009-08-19 14:36:56 +0000 | |||
134 | @@ -0,0 +1,1 @@ | |||
135 | 1 | """ S4 - a S3 storage system stub """ | ||
136 | 0 | 2 | ||
137 | === added directory 'txaws/s4/contrib' | |||
138 | === added file 'txaws/s4/contrib/S3.py' | |||
139 | --- txaws/s4/contrib/S3.py 1970-01-01 00:00:00 +0000 | |||
140 | +++ txaws/s4/contrib/S3.py 2009-08-19 14:36:56 +0000 | |||
141 | @@ -0,0 +1,627 @@ | |||
142 | 1 | #!/usr/bin/env python | ||
143 | 2 | |||
144 | 3 | # This software code is made available "AS IS" without warranties of any | ||
145 | 4 | # kind. You may copy, display, modify and redistribute the software | ||
146 | 5 | # code either by itself or as incorporated into your code; provided that | ||
147 | 6 | # you do not remove any proprietary notices. Your use of this software | ||
148 | 7 | # code is at your own risk and you waive any claim against Amazon | ||
149 | 8 | # Digital Services, Inc. or its affiliates with respect to your use of | ||
150 | 9 | # this software code. (c) 2006-2007 Amazon Digital Services, Inc. or its | ||
151 | 10 | # affiliates. | ||
152 | 11 | |||
153 | 12 | import base64 | ||
154 | 13 | import hmac | ||
155 | 14 | import httplib | ||
156 | 15 | import re | ||
157 | 16 | import sha | ||
158 | 17 | import sys | ||
159 | 18 | import time | ||
160 | 19 | import urllib | ||
161 | 20 | import urlparse | ||
162 | 21 | import xml.sax | ||
163 | 22 | |||
164 | 23 | DEFAULT_HOST = 's3.amazonaws.com' | ||
165 | 24 | PORTS_BY_SECURITY = { True: 443, False: 80 } | ||
166 | 25 | METADATA_PREFIX = 'x-amz-meta-' | ||
167 | 26 | AMAZON_HEADER_PREFIX = 'x-amz-' | ||
168 | 27 | |||
169 | 28 | # generates the aws canonical string for the given parameters | ||
170 | 29 | def canonical_string(method, bucket="", key="", query_args={}, headers={}, expires=None): | ||
171 | 30 | interesting_headers = {} | ||
172 | 31 | for header_key in headers: | ||
173 | 32 | lk = header_key.lower() | ||
174 | 33 | if lk in ['content-md5', 'content-type', 'date'] or lk.startswith(AMAZON_HEADER_PREFIX): | ||
175 | 34 | interesting_headers[lk] = headers[header_key].strip() | ||
176 | 35 | |||
177 | 36 | # these keys get empty strings if they don't exist | ||
178 | 37 | if not interesting_headers.has_key('content-type'): | ||
179 | 38 | interesting_headers['content-type'] = '' | ||
180 | 39 | if not interesting_headers.has_key('content-md5'): | ||
181 | 40 | interesting_headers['content-md5'] = '' | ||
182 | 41 | |||
183 | 42 | # just in case someone used this. it's not necessary in this lib. | ||
184 | 43 | if interesting_headers.has_key('x-amz-date'): | ||
185 | 44 | interesting_headers['date'] = '' | ||
186 | 45 | |||
187 | 46 | # if you're using expires for query string auth, then it trumps date | ||
188 | 47 | # (and x-amz-date) | ||
189 | 48 | if expires: | ||
190 | 49 | interesting_headers['date'] = str(expires) | ||
191 | 50 | |||
192 | 51 | sorted_header_keys = interesting_headers.keys() | ||
193 | 52 | sorted_header_keys.sort() | ||
194 | 53 | |||
195 | 54 | buf = "%s\n" % method | ||
196 | 55 | for header_key in sorted_header_keys: | ||
197 | 56 | if header_key.startswith(AMAZON_HEADER_PREFIX): | ||
198 | 57 | buf += "%s:%s\n" % (header_key, interesting_headers[header_key]) | ||
199 | 58 | else: | ||
200 | 59 | buf += "%s\n" % interesting_headers[header_key] | ||
201 | 60 | |||
202 | 61 | # append the bucket if it exists | ||
203 | 62 | if bucket != "": | ||
204 | 63 | buf += "/%s" % bucket | ||
205 | 64 | |||
206 | 65 | # add the key. even if it doesn't exist, add the slash | ||
207 | 66 | buf += "/%s" % urllib.quote_plus(key) | ||
208 | 67 | |||
209 | 68 | # handle special query string arguments | ||
210 | 69 | |||
211 | 70 | if query_args.has_key("acl"): | ||
212 | 71 | buf += "?acl" | ||
213 | 72 | elif query_args.has_key("torrent"): | ||
214 | 73 | buf += "?torrent" | ||
215 | 74 | elif query_args.has_key("logging"): | ||
216 | 75 | buf += "?logging" | ||
217 | 76 | elif query_args.has_key("location"): | ||
218 | 77 | buf += "?location" | ||
219 | 78 | |||
220 | 79 | return buf | ||
221 | 80 | |||
222 | 81 | # computes the base64'ed hmac-sha hash of the canonical string and the secret | ||
223 | 82 | # access key, optionally urlencoding the result | ||
224 | 83 | def encode(aws_secret_access_key, str, urlencode=False): | ||
225 | 84 | b64_hmac = base64.encodestring(hmac.new(aws_secret_access_key, str, sha).digest()).strip() | ||
226 | 85 | if urlencode: | ||
227 | 86 | return urllib.quote_plus(b64_hmac) | ||
228 | 87 | else: | ||
229 | 88 | return b64_hmac | ||
230 | 89 | |||
231 | 90 | def merge_meta(headers, metadata): | ||
232 | 91 | final_headers = headers.copy() | ||
233 | 92 | for k in metadata.keys(): | ||
234 | 93 | final_headers[METADATA_PREFIX + k] = metadata[k] | ||
235 | 94 | |||
236 | 95 | return final_headers | ||
237 | 96 | |||
238 | 97 | # builds the query arg string | ||
239 | 98 | def query_args_hash_to_string(query_args): | ||
240 | 99 | query_string = "" | ||
241 | 100 | pairs = [] | ||
242 | 101 | for k, v in query_args.items(): | ||
243 | 102 | piece = k | ||
244 | 103 | if v != None: | ||
245 | 104 | piece += "=%s" % urllib.quote_plus(str(v)) | ||
246 | 105 | pairs.append(piece) | ||
247 | 106 | |||
248 | 107 | return '&'.join(pairs) | ||
249 | 108 | |||
250 | 109 | |||
251 | 110 | class CallingFormat: | ||
252 | 111 | PATH = 1 | ||
253 | 112 | SUBDOMAIN = 2 | ||
254 | 113 | VANITY = 3 | ||
255 | 114 | |||
256 | 115 | def build_url_base(protocol, server, port, bucket, calling_format): | ||
257 | 116 | url_base = '%s://' % protocol | ||
258 | 117 | |||
259 | 118 | if bucket == '': | ||
260 | 119 | url_base += server | ||
261 | 120 | elif calling_format == CallingFormat.SUBDOMAIN: | ||
262 | 121 | url_base += "%s.%s" % (bucket, server) | ||
263 | 122 | elif calling_format == CallingFormat.VANITY: | ||
264 | 123 | url_base += bucket | ||
265 | 124 | else: | ||
266 | 125 | url_base += server | ||
267 | 126 | |||
268 | 127 | url_base += ":%s" % port | ||
269 | 128 | |||
270 | 129 | if (bucket != '') and (calling_format == CallingFormat.PATH): | ||
271 | 130 | url_base += "/%s" % bucket | ||
272 | 131 | |||
273 | 132 | return url_base | ||
274 | 133 | |||
275 | 134 | build_url_base = staticmethod(build_url_base) | ||
276 | 135 | |||
277 | 136 | |||
278 | 137 | |||
279 | 138 | class Location: | ||
280 | 139 | DEFAULT = None | ||
281 | 140 | EU = 'EU' | ||
282 | 141 | |||
283 | 142 | |||
284 | 143 | |||
285 | 144 | class AWSAuthConnection: | ||
286 | 145 | def __init__(self, aws_access_key_id, aws_secret_access_key, is_secure=True, | ||
287 | 146 | server=DEFAULT_HOST, port=None, calling_format=CallingFormat.SUBDOMAIN): | ||
288 | 147 | |||
289 | 148 | if not port: | ||
290 | 149 | port = PORTS_BY_SECURITY[is_secure] | ||
291 | 150 | |||
292 | 151 | self.aws_access_key_id = aws_access_key_id | ||
293 | 152 | self.aws_secret_access_key = aws_secret_access_key | ||
294 | 153 | self.is_secure = is_secure | ||
295 | 154 | self.server = server | ||
296 | 155 | self.port = port | ||
297 | 156 | self.calling_format = calling_format | ||
298 | 157 | |||
299 | 158 | def create_bucket(self, bucket, headers={}): | ||
300 | 159 | return Response(self._make_request('PUT', bucket, '', {}, headers)) | ||
301 | 160 | |||
302 | 161 | def create_located_bucket(self, bucket, location=Location.DEFAULT, headers={}): | ||
303 | 162 | if location == Location.DEFAULT: | ||
304 | 163 | body = "" | ||
305 | 164 | else: | ||
306 | 165 | body = "<CreateBucketConstraint><LocationConstraint>" + \ | ||
307 | 166 | location + \ | ||
308 | 167 | "</LocationConstraint></CreateBucketConstraint>" | ||
309 | 168 | return Response(self._make_request('PUT', bucket, '', {}, headers, body)) | ||
310 | 169 | |||
311 | 170 | def check_bucket_exists(self, bucket): | ||
312 | 171 | return self._make_request('HEAD', bucket, '', {}, {}) | ||
313 | 172 | |||
314 | 173 | def list_bucket(self, bucket, options={}, headers={}): | ||
315 | 174 | return ListBucketResponse(self._make_request('GET', bucket, '', options, headers)) | ||
316 | 175 | |||
317 | 176 | def delete_bucket(self, bucket, headers={}): | ||
318 | 177 | return Response(self._make_request('DELETE', bucket, '', {}, headers)) | ||
319 | 178 | |||
320 | 179 | def put(self, bucket, key, object, headers={}): | ||
321 | 180 | if not isinstance(object, S3Object): | ||
322 | 181 | object = S3Object(object) | ||
323 | 182 | |||
324 | 183 | return Response( | ||
325 | 184 | self._make_request( | ||
326 | 185 | 'PUT', | ||
327 | 186 | bucket, | ||
328 | 187 | key, | ||
329 | 188 | {}, | ||
330 | 189 | headers, | ||
331 | 190 | object.data, | ||
332 | 191 | object.metadata)) | ||
333 | 192 | |||
334 | 193 | def get(self, bucket, key, headers={}): | ||
335 | 194 | return GetResponse( | ||
336 | 195 | self._make_request('GET', bucket, key, {}, headers)) | ||
337 | 196 | |||
338 | 197 | def delete(self, bucket, key, headers={}): | ||
339 | 198 | return Response( | ||
340 | 199 | self._make_request('DELETE', bucket, key, {}, headers)) | ||
341 | 200 | |||
342 | 201 | def get_bucket_logging(self, bucket, headers={}): | ||
343 | 202 | return GetResponse(self._make_request('GET', bucket, '', { 'logging': None }, headers)) | ||
344 | 203 | |||
345 | 204 | def put_bucket_logging(self, bucket, logging_xml_doc, headers={}): | ||
346 | 205 | return Response(self._make_request('PUT', bucket, '', { 'logging': None }, headers, logging_xml_doc)) | ||
347 | 206 | |||
348 | 207 | def get_bucket_acl(self, bucket, headers={}): | ||
349 | 208 | return self.get_acl(bucket, '', headers) | ||
350 | 209 | |||
351 | 210 | def get_acl(self, bucket, key, headers={}): | ||
352 | 211 | return GetResponse( | ||
353 | 212 | self._make_request('GET', bucket, key, { 'acl': None }, headers)) | ||
354 | 213 | |||
355 | 214 | def put_bucket_acl(self, bucket, acl_xml_document, headers={}): | ||
356 | 215 | return self.put_acl(bucket, '', acl_xml_document, headers) | ||
357 | 216 | |||
358 | 217 | def put_acl(self, bucket, key, acl_xml_document, headers={}): | ||
359 | 218 | return Response( | ||
360 | 219 | self._make_request( | ||
361 | 220 | 'PUT', | ||
362 | 221 | bucket, | ||
363 | 222 | key, | ||
364 | 223 | { 'acl': None }, | ||
365 | 224 | headers, | ||
366 | 225 | acl_xml_document)) | ||
367 | 226 | |||
368 | 227 | def list_all_my_buckets(self, headers={}): | ||
369 | 228 | return ListAllMyBucketsResponse(self._make_request('GET', '', '', {}, headers)) | ||
370 | 229 | |||
371 | 230 | def get_bucket_location(self, bucket): | ||
372 | 231 | return LocationResponse(self._make_request('GET', bucket, '', {'location' : None})) | ||
373 | 232 | |||
374 | 233 | # end public methods | ||
375 | 234 | |||
376 | 235 | def _make_request(self, method, bucket='', key='', query_args={}, headers={}, data='', metadata={}): | ||
377 | 236 | |||
378 | 237 | server = '' | ||
379 | 238 | if bucket == '': | ||
380 | 239 | server = self.server | ||
381 | 240 | elif self.calling_format == CallingFormat.SUBDOMAIN: | ||
382 | 241 | server = "%s.%s" % (bucket, self.server) | ||
383 | 242 | elif self.calling_format == CallingFormat.VANITY: | ||
384 | 243 | server = bucket | ||
385 | 244 | else: | ||
386 | 245 | server = self.server | ||
387 | 246 | |||
388 | 247 | path = '' | ||
389 | 248 | |||
390 | 249 | if (bucket != '') and (self.calling_format == CallingFormat.PATH): | ||
391 | 250 | path += "/%s" % bucket | ||
392 | 251 | |||
393 | 252 | # add the slash after the bucket regardless | ||
394 | 253 | # the key will be appended if it is non-empty | ||
395 | 254 | path += "/%s" % urllib.quote_plus(key) | ||
396 | 255 | |||
397 | 256 | |||
398 | 257 | # build the path_argument string | ||
399 | 258 | # add the ? in all cases since | ||
400 | 259 | # signature and credentials follow path args | ||
401 | 260 | if len(query_args): | ||
402 | 261 | path += "?" + query_args_hash_to_string(query_args) | ||
403 | 262 | |||
404 | 263 | is_secure = self.is_secure | ||
405 | 264 | host = "%s:%d" % (server, self.port) | ||
406 | 265 | while True: | ||
407 | 266 | if (is_secure): | ||
408 | 267 | connection = httplib.HTTPSConnection(host) | ||
409 | 268 | else: | ||
410 | 269 | connection = httplib.HTTPConnection(host) | ||
411 | 270 | |||
412 | 271 | final_headers = merge_meta(headers, metadata); | ||
413 | 272 | # add auth header | ||
414 | 273 | self._add_aws_auth_header(final_headers, method, bucket, key, query_args) | ||
415 | 274 | |||
416 | 275 | connection.request(method, path, data, final_headers) | ||
417 | 276 | resp = connection.getresponse() | ||
418 | 277 | if resp.status < 300 or resp.status >= 400: | ||
419 | 278 | return resp | ||
420 | 279 | # handle redirect | ||
421 | 280 | location = resp.getheader('location') | ||
422 | 281 | if not location: | ||
423 | 282 | return resp | ||
424 | 283 | # (close connection) | ||
425 | 284 | resp.read() | ||
426 | 285 | scheme, host, path, params, query, fragment \ | ||
427 | 286 | = urlparse.urlparse(location) | ||
428 | 287 | if scheme == "http": is_secure = True | ||
429 | 288 | elif scheme == "https": is_secure = False | ||
430 | 289 | else: raise invalidURL("Not http/https: " + location) | ||
431 | 290 | if query: path += "?" + query | ||
432 | 291 | # retry with redirect | ||
433 | 292 | |||
434 | 293 | def _add_aws_auth_header(self, headers, method, bucket, key, query_args): | ||
435 | 294 | if not headers.has_key('Date'): | ||
436 | 295 | headers['Date'] = time.strftime("%a, %d %b %Y %X GMT", time.gmtime()) | ||
437 | 296 | |||
438 | 297 | c_string = canonical_string(method, bucket, key, query_args, headers) | ||
439 | 298 | headers['Authorization'] = \ | ||
440 | 299 | "AWS %s:%s" % (self.aws_access_key_id, encode(self.aws_secret_access_key, c_string)) | ||
441 | 300 | |||
442 | 301 | |||
443 | 302 | class QueryStringAuthGenerator: | ||
444 | 303 | # by default, expire in 1 minute | ||
445 | 304 | DEFAULT_EXPIRES_IN = 60 | ||
446 | 305 | |||
447 | 306 | def __init__(self, aws_access_key_id, aws_secret_access_key, is_secure=True, | ||
448 | 307 | server=DEFAULT_HOST, port=None, calling_format=CallingFormat.SUBDOMAIN): | ||
449 | 308 | |||
450 | 309 | if not port: | ||
451 | 310 | port = PORTS_BY_SECURITY[is_secure] | ||
452 | 311 | |||
453 | 312 | self.aws_access_key_id = aws_access_key_id | ||
454 | 313 | self.aws_secret_access_key = aws_secret_access_key | ||
455 | 314 | if (is_secure): | ||
456 | 315 | self.protocol = 'https' | ||
457 | 316 | else: | ||
458 | 317 | self.protocol = 'http' | ||
459 | 318 | |||
460 | 319 | self.is_secure = is_secure | ||
461 | 320 | self.server = server | ||
462 | 321 | self.port = port | ||
463 | 322 | self.calling_format = calling_format | ||
464 | 323 | self.__expires_in = QueryStringAuthGenerator.DEFAULT_EXPIRES_IN | ||
465 | 324 | self.__expires = None | ||
466 | 325 | |||
467 | 326 | # for backwards compatibility with older versions | ||
468 | 327 | self.server_name = "%s:%s" % (self.server, self.port) | ||
469 | 328 | |||
470 | 329 | def set_expires_in(self, expires_in): | ||
471 | 330 | self.__expires_in = expires_in | ||
472 | 331 | self.__expires = None | ||
473 | 332 | |||
474 | 333 | def set_expires(self, expires): | ||
475 | 334 | self.__expires = expires | ||
476 | 335 | self.__expires_in = None | ||
477 | 336 | |||
478 | 337 | def create_bucket(self, bucket, headers={}): | ||
479 | 338 | return self.generate_url('PUT', bucket, '', {}, headers) | ||
480 | 339 | |||
481 | 340 | def list_bucket(self, bucket, options={}, headers={}): | ||
482 | 341 | return self.generate_url('GET', bucket, '', options, headers) | ||
483 | 342 | |||
484 | 343 | def delete_bucket(self, bucket, headers={}): | ||
485 | 344 | return self.generate_url('DELETE', bucket, '', {}, headers) | ||
486 | 345 | |||
487 | 346 | def put(self, bucket, key, object, headers={}): | ||
488 | 347 | if not isinstance(object, S3Object): | ||
489 | 348 | object = S3Object(object) | ||
490 | 349 | |||
491 | 350 | return self.generate_url( | ||
492 | 351 | 'PUT', | ||
493 | 352 | bucket, | ||
494 | 353 | key, | ||
495 | 354 | {}, | ||
496 | 355 | merge_meta(headers, object.metadata)) | ||
497 | 356 | |||
498 | 357 | def get(self, bucket, key, headers={}): | ||
499 | 358 | return self.generate_url('GET', bucket, key, {}, headers) | ||
500 | 359 | |||
501 | 360 | def delete(self, bucket, key, headers={}): | ||
502 | 361 | return self.generate_url('DELETE', bucket, key, {}, headers) | ||
503 | 362 | |||
504 | 363 | def get_bucket_logging(self, bucket, headers={}): | ||
505 | 364 | return self.generate_url('GET', bucket, '', { 'logging': None }, headers) | ||
506 | 365 | |||
507 | 366 | def put_bucket_logging(self, bucket, logging_xml_doc, headers={}): | ||
508 | 367 | return self.generate_url('PUT', bucket, '', { 'logging': None }, headers) | ||
509 | 368 | |||
510 | 369 | def get_bucket_acl(self, bucket, headers={}): | ||
511 | 370 | return self.get_acl(bucket, '', headers) | ||
512 | 371 | |||
513 | 372 | def get_acl(self, bucket, key='', headers={}): | ||
514 | 373 | return self.generate_url('GET', bucket, key, { 'acl': None }, headers) | ||
515 | 374 | |||
516 | 375 | def put_bucket_acl(self, bucket, acl_xml_document, headers={}): | ||
517 | 376 | return self.put_acl(bucket, '', acl_xml_document, headers) | ||
518 | 377 | |||
519 | 378 | # don't really care what the doc is here. | ||
520 | 379 | def put_acl(self, bucket, key, acl_xml_document, headers={}): | ||
521 | 380 | return self.generate_url('PUT', bucket, key, { 'acl': None }, headers) | ||
522 | 381 | |||
523 | 382 | def list_all_my_buckets(self, headers={}): | ||
524 | 383 | return self.generate_url('GET', '', '', {}, headers) | ||
525 | 384 | |||
526 | 385 | def make_bare_url(self, bucket, key=''): | ||
527 | 386 | full_url = self.generate_url(self, bucket, key) | ||
528 | 387 | return full_url[:full_url.index('?')] | ||
529 | 388 | |||
530 | 389 | def generate_url(self, method, bucket='', key='', query_args={}, headers={}): | ||
531 | 390 | expires = 0 | ||
532 | 391 | if self.__expires_in != None: | ||
533 | 392 | expires = int(time.time() + self.__expires_in) | ||
534 | 393 | elif self.__expires != None: | ||
535 | 394 | expires = int(self.__expires) | ||
536 | 395 | else: | ||
537 | 396 | raise "Invalid expires state" | ||
538 | 397 | |||
539 | 398 | canonical_str = canonical_string(method, bucket, key, query_args, headers, expires) | ||
540 | 399 | encoded_canonical = encode(self.aws_secret_access_key, canonical_str) | ||
541 | 400 | |||
542 | 401 | url = CallingFormat.build_url_base(self.protocol, self.server, self.port, bucket, self.calling_format) | ||
543 | 402 | |||
544 | 403 | url += "/%s" % urllib.quote_plus(key) | ||
545 | 404 | |||
546 | 405 | query_args['Signature'] = encoded_canonical | ||
547 | 406 | query_args['Expires'] = expires | ||
548 | 407 | query_args['AWSAccessKeyId'] = self.aws_access_key_id | ||
549 | 408 | |||
550 | 409 | url += "?%s" % query_args_hash_to_string(query_args) | ||
551 | 410 | |||
552 | 411 | return url | ||
553 | 412 | |||
554 | 413 | |||
555 | 414 | class S3Object: | ||
556 | 415 | def __init__(self, data, metadata={}): | ||
557 | 416 | self.data = data | ||
558 | 417 | self.metadata = metadata | ||
559 | 418 | |||
560 | 419 | class Owner: | ||
561 | 420 | def __init__(self, id='', display_name=''): | ||
562 | 421 | self.id = id | ||
563 | 422 | self.display_name = display_name | ||
564 | 423 | |||
565 | 424 | class ListEntry: | ||
566 | 425 | def __init__(self, key='', last_modified=None, etag='', size=0, storage_class='', owner=None): | ||
567 | 426 | self.key = key | ||
568 | 427 | self.last_modified = last_modified | ||
569 | 428 | self.etag = etag | ||
570 | 429 | self.size = size | ||
571 | 430 | self.storage_class = storage_class | ||
572 | 431 | self.owner = owner | ||
573 | 432 | |||
574 | 433 | class CommonPrefixEntry: | ||
575 | 434 | def __init(self, prefix=''): | ||
576 | 435 | self.prefix = prefix | ||
577 | 436 | |||
578 | 437 | class Bucket: | ||
579 | 438 | def __init__(self, name='', creation_date=''): | ||
580 | 439 | self.name = name | ||
581 | 440 | self.creation_date = creation_date | ||
582 | 441 | |||
583 | 442 | class Response: | ||
584 | 443 | def __init__(self, http_response): | ||
585 | 444 | self.http_response = http_response | ||
586 | 445 | # you have to do this read, even if you don't expect a body. | ||
587 | 446 | # otherwise, the next request fails. | ||
588 | 447 | self.body = http_response.read() | ||
589 | 448 | if http_response.status >= 300 and self.body: | ||
590 | 449 | self.message = self.body | ||
591 | 450 | else: | ||
592 | 451 | self.message = "%03d %s" % (http_response.status, http_response.reason) | ||
593 | 452 | |||
594 | 453 | |||
595 | 454 | |||
596 | 455 | class ListBucketResponse(Response): | ||
597 | 456 | def __init__(self, http_response): | ||
598 | 457 | Response.__init__(self, http_response) | ||
599 | 458 | if http_response.status < 300: | ||
600 | 459 | handler = ListBucketHandler() | ||
601 | 460 | xml.sax.parseString(self.body, handler) | ||
602 | 461 | self.entries = handler.entries | ||
603 | 462 | self.common_prefixes = handler.common_prefixes | ||
604 | 463 | self.name = handler.name | ||
605 | 464 | self.marker = handler.marker | ||
606 | 465 | self.prefix = handler.prefix | ||
607 | 466 | self.is_truncated = handler.is_truncated | ||
608 | 467 | self.delimiter = handler.delimiter | ||
609 | 468 | self.max_keys = handler.max_keys | ||
610 | 469 | self.next_marker = handler.next_marker | ||
611 | 470 | else: | ||
612 | 471 | self.entries = [] | ||
613 | 472 | |||
614 | 473 | class ListAllMyBucketsResponse(Response): | ||
615 | 474 | def __init__(self, http_response): | ||
616 | 475 | Response.__init__(self, http_response) | ||
617 | 476 | if http_response.status < 300: | ||
618 | 477 | handler = ListAllMyBucketsHandler() | ||
619 | 478 | xml.sax.parseString(self.body, handler) | ||
620 | 479 | self.entries = handler.entries | ||
621 | 480 | else: | ||
622 | 481 | self.entries = [] | ||
623 | 482 | |||
624 | 483 | class GetResponse(Response): | ||
625 | 484 | def __init__(self, http_response): | ||
626 | 485 | Response.__init__(self, http_response) | ||
627 | 486 | response_headers = http_response.msg # older pythons don't have getheaders | ||
628 | 487 | metadata = self.get_aws_metadata(response_headers) | ||
629 | 488 | self.object = S3Object(self.body, metadata) | ||
630 | 489 | |||
631 | 490 | def get_aws_metadata(self, headers): | ||
632 | 491 | metadata = {} | ||
633 | 492 | for hkey in headers.keys(): | ||
634 | 493 | if hkey.lower().startswith(METADATA_PREFIX): | ||
635 | 494 | metadata[hkey[len(METADATA_PREFIX):]] = headers[hkey] | ||
636 | 495 | del headers[hkey] | ||
637 | 496 | |||
638 | 497 | return metadata | ||
639 | 498 | |||
640 | 499 | class LocationResponse(Response): | ||
641 | 500 | def __init__(self, http_response): | ||
642 | 501 | Response.__init__(self, http_response) | ||
643 | 502 | if http_response.status < 300: | ||
644 | 503 | handler = LocationHandler() | ||
645 | 504 | xml.sax.parseString(self.body, handler) | ||
646 | 505 | self.location = handler.location | ||
647 | 506 | |||
648 | 507 | class ListBucketHandler(xml.sax.ContentHandler): | ||
649 | 508 | def __init__(self): | ||
650 | 509 | self.entries = [] | ||
651 | 510 | self.curr_entry = None | ||
652 | 511 | self.curr_text = '' | ||
653 | 512 | self.common_prefixes = [] | ||
654 | 513 | self.curr_common_prefix = None | ||
655 | 514 | self.name = '' | ||
656 | 515 | self.marker = '' | ||
657 | 516 | self.prefix = '' | ||
658 | 517 | self.is_truncated = False | ||
659 | 518 | self.delimiter = '' | ||
660 | 519 | self.max_keys = 0 | ||
661 | 520 | self.next_marker = '' | ||
662 | 521 | self.is_echoed_prefix_set = False | ||
663 | 522 | |||
664 | 523 | def startElement(self, name, attrs): | ||
665 | 524 | if name == 'Contents': | ||
666 | 525 | self.curr_entry = ListEntry() | ||
667 | 526 | elif name == 'Owner': | ||
668 | 527 | self.curr_entry.owner = Owner() | ||
669 | 528 | elif name == 'CommonPrefixes': | ||
670 | 529 | self.curr_common_prefix = CommonPrefixEntry() | ||
671 | 530 | |||
672 | 531 | |||
673 | 532 | def endElement(self, name): | ||
674 | 533 | if name == 'Contents': | ||
675 | 534 | self.entries.append(self.curr_entry) | ||
676 | 535 | elif name == 'CommonPrefixes': | ||
677 | 536 | self.common_prefixes.append(self.curr_common_prefix) | ||
678 | 537 | elif name == 'Key': | ||
679 | 538 | self.curr_entry.key = self.curr_text | ||
680 | 539 | elif name == 'LastModified': | ||
681 | 540 | self.curr_entry.last_modified = self.curr_text | ||
682 | 541 | elif name == 'ETag': | ||
683 | 542 | self.curr_entry.etag = self.curr_text | ||
684 | 543 | elif name == 'Size': | ||
685 | 544 | self.curr_entry.size = int(self.curr_text) | ||
686 | 545 | elif name == 'ID': | ||
687 | 546 | self.curr_entry.owner.id = self.curr_text | ||
688 | 547 | elif name == 'DisplayName': | ||
689 | 548 | self.curr_entry.owner.display_name = self.curr_text | ||
690 | 549 | elif name == 'StorageClass': | ||
691 | 550 | self.curr_entry.storage_class = self.curr_text | ||
692 | 551 | elif name == 'Name': | ||
693 | 552 | self.name = self.curr_text | ||
694 | 553 | elif name == 'Prefix' and self.is_echoed_prefix_set: | ||
695 | 554 | self.curr_common_prefix.prefix = self.curr_text | ||
696 | 555 | elif name == 'Prefix': | ||
697 | 556 | self.prefix = self.curr_text | ||
698 | 557 | self.is_echoed_prefix_set = True | ||
699 | 558 | elif name == 'Marker': | ||
700 | 559 | self.marker = self.curr_text | ||
701 | 560 | elif name == 'IsTruncated': | ||
702 | 561 | self.is_truncated = self.curr_text == 'true' | ||
703 | 562 | elif name == 'Delimiter': | ||
704 | 563 | self.delimiter = self.curr_text | ||
705 | 564 | elif name == 'MaxKeys': | ||
706 | 565 | self.max_keys = int(self.curr_text) | ||
707 | 566 | elif name == 'NextMarker': | ||
708 | 567 | self.next_marker = self.curr_text | ||
709 | 568 | |||
710 | 569 | self.curr_text = '' | ||
711 | 570 | |||
712 | 571 | def characters(self, content): | ||
713 | 572 | self.curr_text += content | ||
714 | 573 | |||
715 | 574 | |||
716 | 575 | class ListAllMyBucketsHandler(xml.sax.ContentHandler): | ||
717 | 576 | def __init__(self): | ||
718 | 577 | self.entries = [] | ||
719 | 578 | self.curr_entry = None | ||
720 | 579 | self.curr_text = '' | ||
721 | 580 | |||
722 | 581 | def startElement(self, name, attrs): | ||
723 | 582 | if name == 'Bucket': | ||
724 | 583 | self.curr_entry = Bucket() | ||
725 | 584 | |||
726 | 585 | def endElement(self, name): | ||
727 | 586 | if name == 'Name': | ||
728 | 587 | self.curr_entry.name = self.curr_text | ||
729 | 588 | elif name == 'CreationDate': | ||
730 | 589 | self.curr_entry.creation_date = self.curr_text | ||
731 | 590 | elif name == 'Bucket': | ||
732 | 591 | self.entries.append(self.curr_entry) | ||
733 | 592 | |||
734 | 593 | def characters(self, content): | ||
735 | 594 | self.curr_text = content | ||
736 | 595 | |||
737 | 596 | |||
738 | 597 | class LocationHandler(xml.sax.ContentHandler): | ||
739 | 598 | def __init__(self): | ||
740 | 599 | self.location = None | ||
741 | 600 | self.state = 'init' | ||
742 | 601 | |||
743 | 602 | def startElement(self, name, attrs): | ||
744 | 603 | if self.state == 'init': | ||
745 | 604 | if name == 'LocationConstraint': | ||
746 | 605 | self.state = 'tag_location' | ||
747 | 606 | self.location = '' | ||
748 | 607 | else: self.state = 'bad' | ||
749 | 608 | else: self.state = 'bad' | ||
750 | 609 | |||
751 | 610 | def endElement(self, name): | ||
752 | 611 | if self.state == 'tag_location' and name == 'LocationConstraint': | ||
753 | 612 | self.state = 'done' | ||
754 | 613 | else: self.state = 'bad' | ||
755 | 614 | |||
756 | 615 | def characters(self, content): | ||
757 | 616 | if self.state == 'tag_location': | ||
758 | 617 | self.location += content | ||
759 | 618 | |||
760 | 619 | if __name__=="__main__": | ||
761 | 620 | keys = raw_input("Enter access and secret key (separated by a space): ") | ||
762 | 621 | access_key, secret_key = keys.split(" ") | ||
763 | 622 | s3 = AWSAuthConnection(access_key, secret_key) | ||
764 | 623 | bucket = "test_s3_lib" | ||
765 | 624 | m = s3.put(bucket, "sample", "hola mundo", {"Content-Type":"text/lame"}) | ||
766 | 625 | print m.http_response.status, m.http_response.reason | ||
767 | 626 | print m.http_response.getheaders() | ||
768 | 627 | print m.body | ||
769 | 0 | 628 | ||
770 | === added file 'txaws/s4/contrib/__init__.py' | |||
771 | === added file 'txaws/s4/s4.py' | |||
772 | --- txaws/s4/s4.py 1970-01-01 00:00:00 +0000 | |||
773 | +++ txaws/s4/s4.py 2009-08-19 14:36:56 +0000 | |||
774 | @@ -0,0 +1,742 @@ | |||
775 | 1 | # Copyright 2009 Canonical Ltd. | ||
776 | 2 | # | ||
777 | 3 | # Permission is hereby granted, free of charge, to any person obtaining | ||
778 | 4 | # a copy of this software and associated documentation files (the | ||
779 | 5 | # "Software"), to deal in the Software without restriction, including | ||
780 | 6 | # without limitation the rights to use, copy, modify, merge, publish, | ||
781 | 7 | # distribute, sublicense, and/or sell copies of the Software, and to | ||
782 | 8 | # permit persons to whom the Software is furnished to do so, subject to | ||
783 | 9 | # the following conditions: | ||
784 | 10 | # | ||
785 | 11 | # The above copyright notice and this permission notice shall be | ||
786 | 12 | # included in all copies or substantial portions of the Software. | ||
787 | 13 | # | ||
788 | 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | ||
789 | 15 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | ||
790 | 16 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | ||
791 | 17 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE | ||
792 | 18 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | ||
793 | 19 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | ||
794 | 20 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | ||
795 | 21 | |||
796 | 22 | """ S4 - a S3 storage system stub | ||
797 | 23 | |||
798 | 24 | This module implementes a stub for Amazons S3 storage system. | ||
799 | 25 | |||
800 | 26 | Not all functionality is provided, just enough to test the client. | ||
801 | 27 | |||
802 | 28 | """ | ||
803 | 29 | from __future__ import with_statement | ||
804 | 30 | |||
805 | 31 | import os | ||
806 | 32 | import hmac | ||
807 | 33 | import time | ||
808 | 34 | import base64 | ||
809 | 35 | import logging | ||
810 | 36 | import hashlib | ||
811 | 37 | from urllib import urlencode | ||
812 | 38 | |||
813 | 39 | # cPickle would be faster here, but working around its relative | ||
814 | 40 | # imports issues for this module requires extra hacking | ||
815 | 41 | import pickle | ||
816 | 42 | |||
817 | 43 | from boto.utils import canonical_string as canonical_path_string | ||
818 | 44 | # pylint: disable-msg=W0611 | ||
819 | 45 | from boto.s3.connection import OrdinaryCallingFormat as CallingFormat | ||
820 | 46 | |||
821 | 47 | from twisted.web import server, resource, error, http | ||
822 | 48 | from twisted.internet import reactor, interfaces | ||
823 | 49 | # pylint and zope dont work | ||
824 | 50 | # pylint: disable-msg=E0611 | ||
825 | 51 | # pylint: disable-msg=F0401 | ||
826 | 52 | from zope.interface import implements | ||
827 | 53 | |||
828 | 54 | # xml namespace response header required | ||
829 | 55 | XMLNS = "http://s3.amazonaws.com/doc/2006-03-01" | ||
830 | 56 | |||
831 | 57 | AWS_DEFAULT_ACCESS_KEY_ID = 'aws_key' | ||
832 | 58 | AWS_DEFAULT_SECRET_ACCESS_KEY = 'aws_secret' | ||
833 | 59 | AMAZON_HEADER_PREFIX = 'x-amz-' | ||
834 | 60 | AMAZON_META_PREFIX = "x-amz-meta-" | ||
835 | 61 | |||
836 | 62 | BLOCK_SIZE = 2**16 | ||
837 | 63 | |||
838 | 64 | S4_STATE_FILE = ".s4_state" | ||
839 | 65 | |||
840 | 66 | logger = logging.getLogger('UbuntuOne.S4') | ||
841 | 67 | |||
842 | 68 | # pylint: disable-msg=W0403 | ||
843 | 69 | import s4_xml | ||
844 | 70 | |||
845 | 71 | class S4StorageException(Exception): | ||
846 | 72 | """ exception raised when S4 backend store runs into trouble """ | ||
847 | 73 | |||
848 | 74 | class FakeContent(object): | ||
849 | 75 | """A content that can be accesed by slicing but will never exists in memory | ||
850 | 76 | """ | ||
851 | 77 | def __init__(self, char, size): | ||
852 | 78 | """Create the content as char*size.""" | ||
853 | 79 | self.char = char | ||
854 | 80 | self.size = size | ||
855 | 81 | |||
856 | 82 | def __getitem__(self, slice): | ||
857 | 83 | """Get a piece of the content.""" | ||
858 | 84 | size = min(slice.stop, self.size) - slice.start | ||
859 | 85 | return self.char*size | ||
860 | 86 | |||
861 | 87 | def hexdigest(self): | ||
862 | 88 | """Send a fake hexdigest. For big contents this takes too much time | ||
863 | 89 | to calculate, so we just fake it.""" | ||
864 | 90 | block_size = BLOCK_SIZE | ||
865 | 91 | start = 0 | ||
866 | 92 | data = self[start:start+block_size] | ||
867 | 93 | md5calc = hashlib.md5() | ||
868 | 94 | md5calc.update(data) | ||
869 | 95 | return md5calc.hexdigest() | ||
870 | 96 | |||
871 | 97 | def __len__(self): | ||
872 | 98 | """The size.""" | ||
873 | 99 | return self.size | ||
874 | 100 | |||
875 | 101 | class ContentProducer(object): | ||
876 | 102 | """A content producer used to stream big data.""" | ||
877 | 103 | implements(interfaces.IPullProducer) | ||
878 | 104 | |||
879 | 105 | def __init__(self, request, content, buffer_size=BLOCK_SIZE): | ||
880 | 106 | """Create a producer for request, that produces content.""" | ||
881 | 107 | self.request = request | ||
882 | 108 | self.content = content | ||
883 | 109 | self.buffer_size = buffer_size | ||
884 | 110 | self.position = 0 | ||
885 | 111 | self.paused = False | ||
886 | 112 | |||
887 | 113 | def startProducing(self): | ||
888 | 114 | """IPullProducer api.""" | ||
889 | 115 | self.request.registerProducer(self, streaming=False) | ||
890 | 116 | |||
891 | 117 | def finished(self): | ||
892 | 118 | """Called to finish the request after producing.""" | ||
893 | 119 | self.request.unregisterProducer() | ||
894 | 120 | self.request.finish() | ||
895 | 121 | |||
896 | 122 | def resumeProducing(self): | ||
897 | 123 | """IPullProducer api.""" | ||
898 | 124 | if self.position > len(self.content): | ||
899 | 125 | self.finished() | ||
900 | 126 | return | ||
901 | 127 | |||
902 | 128 | data = self.content[self.position:self.position+self.buffer_size] | ||
903 | 129 | |||
904 | 130 | self.position += self.buffer_size | ||
905 | 131 | self.request.write(data) | ||
906 | 132 | |||
907 | 133 | def stopProducing(self): | ||
908 | 134 | """IPullProducer api.""" | ||
909 | 135 | pass | ||
910 | 136 | |||
911 | 137 | |||
912 | 138 | def canonical_string(method, bucket="", key="", query_args=None, headers=None): | ||
913 | 139 | """ compatibility S3 canonical string calculator for cases where passing in | ||
914 | 140 | a bucket name, key anme and a hash of query args is easier than using an | ||
915 | 141 | S3 path """ | ||
916 | 142 | path = [] | ||
917 | 143 | if bucket: | ||
918 | 144 | path.append("/%s" % bucket) | ||
919 | 145 | path.append("/%s" % key) | ||
920 | 146 | if query_args: | ||
921 | 147 | path.append("?%s" % urlencode(query_args)) | ||
922 | 148 | path = "".join(path) | ||
923 | 149 | if headers is None: | ||
924 | 150 | headers = {} | ||
925 | 151 | return canonical_path_string(method=method, path=path, headers=headers) | ||
926 | 152 | |||
927 | 153 | def encode(secret_key, data): | ||
928 | 154 | """base64encoded digest of data using secret_key""" | ||
929 | 155 | encoded = hmac.new(secret_key, data, hashlib.sha1).digest() | ||
930 | 156 | return base64.encodestring(encoded).strip() | ||
931 | 157 | |||
932 | 158 | def parse_range_header(range): | ||
933 | 159 | """modeled after twisted.web.static.File._parseRangeHeader()""" | ||
934 | 160 | if '=' in range: | ||
935 | 161 | type, value = range.split('=', 1) | ||
936 | 162 | else: | ||
937 | 163 | raise ValueError("Invalid range header, no '='") | ||
938 | 164 | if type != 'bytes': | ||
939 | 165 | raise ValueError("Invalid range header, must be a 'bytes' range") | ||
940 | 166 | raw_ranges = [bytes.strip() for bytes in value.split(',')] | ||
941 | 167 | ranges = [] | ||
942 | 168 | for current_range in raw_ranges: | ||
943 | 169 | if '-' not in current_range: | ||
944 | 170 | raise ValueError("Illegal byte range: %r" % current_range) | ||
945 | 171 | begin, end = current_range.split('-') | ||
946 | 172 | if begin: | ||
947 | 173 | begin = int(begin) | ||
948 | 174 | else: | ||
949 | 175 | begin = None | ||
950 | 176 | if end: | ||
951 | 177 | end = int(end) | ||
952 | 178 | else: | ||
953 | 179 | end = None | ||
954 | 180 | ranges.append((begin, end)) | ||
955 | 181 | return ranges | ||
956 | 182 | |||
957 | 183 | class _ListResult(resource.Resource): | ||
958 | 184 | """ base class for bulding lists of amazon results """ | ||
959 | 185 | isLeaf = True | ||
960 | 186 | def __init__(self): | ||
961 | 187 | resource.Resource.__init__(self) | ||
962 | 188 | def add_headers(self, request, content): | ||
963 | 189 | """ add standard headers to an amazon list result page reply """ | ||
964 | 190 | request.setHeader("x-amz-id-2", str(request)) | ||
965 | 191 | request.setHeader("x-amz-request-id", str(request)) | ||
966 | 192 | request.setHeader("Content-Type", "text/xml") | ||
967 | 193 | request.setHeader("Content-Length", str(len(content))) | ||
968 | 194 | |||
969 | 195 | |||
970 | 196 | class ListAllMyBucketsResult(_ListResult): | ||
971 | 197 | """ builds the result for list all buckets call """ | ||
972 | 198 | def __init__(self, buckets, owner=None): | ||
973 | 199 | _ListResult.__init__(self) | ||
974 | 200 | self.buckets = buckets | ||
975 | 201 | if owner: | ||
976 | 202 | self.owner = owner | ||
977 | 203 | else: | ||
978 | 204 | self.owner = dict(id = 0, name = "fakeuser") | ||
979 | 205 | |||
980 | 206 | def render_GET(self, request): | ||
981 | 207 | """ render request for a GET listing """ | ||
982 | 208 | lambr = s4_xml.ListAllMyBucketsResult(self.owner, self.buckets) | ||
983 | 209 | content = s4_xml.to_XML(lambr) | ||
984 | 210 | self.add_headers(request, content) | ||
985 | 211 | return content | ||
986 | 212 | |||
987 | 213 | class ListBucketResult(_ListResult): | ||
988 | 214 | """ encapsulates a list of items in a bucket """ | ||
989 | 215 | def __init__(self, bucket): | ||
990 | 216 | _ListResult.__init__(self) | ||
991 | 217 | self.bucket = bucket | ||
992 | 218 | |||
993 | 219 | def render_GET(self, request): | ||
994 | 220 | """ Render response for a GET listing """ | ||
995 | 221 | # pylint: disable-msg=W0631 | ||
996 | 222 | children = self.bucket.bucket_children.copy() | ||
997 | 223 | prefix = request.args.get("prefix", "") | ||
998 | 224 | if prefix: | ||
999 | 225 | children = dict([x for x in children.iteritems() | ||
1000 | 226 | if x[0].startswith(prefix[0])]) | ||
1001 | 227 | maxkeys = request.args.get("max-keys", 0) | ||
1002 | 228 | if maxkeys: | ||
1003 | 229 | maxkeys = int(maxkeys[0]) | ||
1004 | 230 | ck = children.keys()[:maxkeys] | ||
1005 | 231 | children = dict([x for x in children.iteritems() if x[0] in ck]) | ||
1006 | 232 | lbr = s4_xml.ListBucketResult(self.bucket, children) | ||
1007 | 233 | s4_xml.add_props(lbr, Prefix=prefix, MaxKeys=maxkeys) | ||
1008 | 234 | content = s4_xml.to_XML(lbr) | ||
1009 | 235 | self.add_headers(request, content) | ||
1010 | 236 | return content | ||
1011 | 237 | |||
1012 | 238 | class BasicS3Object(object): | ||
1013 | 239 | """ Basic S3 object class that takes care of contents and properties """ | ||
1014 | 240 | owner_id = 0 | ||
1015 | 241 | owner = "fakeuser" | ||
1016 | 242 | |||
1017 | 243 | def __init__(self, name, contents, content_type="binary/octect-stream", | ||
1018 | 244 | content_md5=None): | ||
1019 | 245 | self.name = name | ||
1020 | 246 | self.content_type = content_type | ||
1021 | 247 | self.contents = contents | ||
1022 | 248 | if content_md5: | ||
1023 | 249 | if isinstance(content_md5, str): | ||
1024 | 250 | self._etag = content_md5 | ||
1025 | 251 | else: | ||
1026 | 252 | self._etag = content_md5.hexdigest() | ||
1027 | 253 | else: | ||
1028 | 254 | self._etag = hashlib.md5(contents).hexdigest() | ||
1029 | 255 | self._date = time.asctime() | ||
1030 | 256 | self._meta = {} | ||
1031 | 257 | |||
1032 | 258 | def __getstate__(self): | ||
1033 | 259 | d = self.__dict__.copy() | ||
1034 | 260 | del d["children"] | ||
1035 | 261 | return d | ||
1036 | 262 | |||
1037 | 263 | def get_etag(self): | ||
1038 | 264 | " build an ETag value. Extra quites are mandated by standards " | ||
1039 | 265 | return '"%s"' % self._etag | ||
1040 | 266 | def set_date(self, datestr): | ||
1041 | 267 | """ set the object's time """ | ||
1042 | 268 | self._date = datestr | ||
1043 | 269 | def get_date(self): | ||
1044 | 270 | """ get the object's time """ | ||
1045 | 271 | return self._date | ||
1046 | 272 | def get_size(self): | ||
1047 | 273 | """ returns size of object's contents """ | ||
1048 | 274 | return len(self.contents) | ||
1049 | 275 | def get_owner(self): | ||
1050 | 276 | """ query object's owner """ | ||
1051 | 277 | return self.owner | ||
1052 | 278 | def get_owner_id(self): | ||
1053 | 279 | """ query object's owner id """ | ||
1054 | 280 | return self.owner_id | ||
1055 | 281 | def set_meta(self, name, val): | ||
1056 | 282 | """ set metadata value for object """ | ||
1057 | 283 | m = self._meta.setdefault(name, []) | ||
1058 | 284 | m.append(val) | ||
1059 | 285 | def iter_meta(self): | ||
1060 | 286 | """ iterate over object's metadata """ | ||
1061 | 287 | for k, vals in self._meta.iteritems(): | ||
1062 | 288 | for v in vals: | ||
1063 | 289 | yield k, v | ||
1064 | 290 | def delete(self): | ||
1065 | 291 | """ clear storage used by object """ | ||
1066 | 292 | self.contents = None | ||
1067 | 293 | |||
1068 | 294 | class S3Object(BasicS3Object, resource.Resource): | ||
1069 | 295 | """ Storage Object | ||
1070 | 296 | This objects store the data and metadata | ||
1071 | 297 | """ | ||
1072 | 298 | isLeaf = True | ||
1073 | 299 | |||
1074 | 300 | def __init__(self, *args, **kw): | ||
1075 | 301 | BasicS3Object.__init__(self, *args, **kw) | ||
1076 | 302 | resource.Resource.__init__(self) | ||
1077 | 303 | |||
1078 | 304 | def _render(self, request): | ||
1079 | 305 | """render the response for a GET or HEAD request on this object""" | ||
1080 | 306 | request.setHeader("x-amz-id-2", str(request)) | ||
1081 | 307 | request.setHeader("x-amz-request-id", str(request)) | ||
1082 | 308 | request.setHeader("Content-Type", self.content_type) | ||
1083 | 309 | request.setHeader("ETag", self._etag) | ||
1084 | 310 | for k, v in self.iter_meta(): | ||
1085 | 311 | request.setHeader("%s%s" % (AMAZON_META_PREFIX, k), v) | ||
1086 | 312 | range = request.getHeader("Range") | ||
1087 | 313 | size = len(self.contents) | ||
1088 | 314 | if request.method == 'HEAD': | ||
1089 | 315 | request.setHeader("Content-Length", size) | ||
1090 | 316 | return "" | ||
1091 | 317 | if range: | ||
1092 | 318 | ranges = parse_range_header(range) | ||
1093 | 319 | length = 0 | ||
1094 | 320 | if len(ranges)==1: | ||
1095 | 321 | begin, end = ranges[0] | ||
1096 | 322 | if begin is None: | ||
1097 | 323 | request.setResponseCode( | ||
1098 | 324 | http.REQUESTED_RANGE_NOT_SATISFIABLE) | ||
1099 | 325 | return '' | ||
1100 | 326 | if not end: | ||
1101 | 327 | end = size | ||
1102 | 328 | elif end < size: | ||
1103 | 329 | end += 1 | ||
1104 | 330 | if begin >= size: | ||
1105 | 331 | request.setResponseCode( | ||
1106 | 332 | http.REQUESTED_RANGE_NOT_SATISFIABLE) | ||
1107 | 333 | request.setHeader( | ||
1108 | 334 | 'content-range', 'bytes */%d' % size) | ||
1109 | 335 | return '' | ||
1110 | 336 | else: | ||
1111 | 337 | request.setHeader( | ||
1112 | 338 | 'content-range', | ||
1113 | 339 | 'bytes %d-%d/%d' % (begin, end-1, size)) | ||
1114 | 340 | length = (end - begin) | ||
1115 | 341 | request.setHeader("Content-Length", length) | ||
1116 | 342 | request.setResponseCode(http.PARTIAL_CONTENT) | ||
1117 | 343 | contents = self.contents[begin:end] | ||
1118 | 344 | else: | ||
1119 | 345 | # multiple ranges should be returned in a multipart response | ||
1120 | 346 | request.setResponseCode( | ||
1121 | 347 | http.REQUESTED_RANGE_NOT_SATISFIABLE) | ||
1122 | 348 | return '' | ||
1123 | 349 | |||
1124 | 350 | else: | ||
1125 | 351 | request.setHeader("Content-Length", str(size)) | ||
1126 | 352 | contents = self.contents | ||
1127 | 353 | |||
1128 | 354 | producer = ContentProducer(request, contents) | ||
1129 | 355 | producer.startProducing() | ||
1130 | 356 | return server.NOT_DONE_YET | ||
1131 | 357 | render_GET = _render | ||
1132 | 358 | render_HEAD = _render | ||
1133 | 359 | |||
1134 | 360 | class UploadS3Object(resource.Resource): | ||
1135 | 361 | """ Class for handling uploads | ||
1136 | 362 | |||
1137 | 363 | It handles the render_PUT method to update the bucket with the data | ||
1138 | 364 | """ | ||
1139 | 365 | isLeaf = True | ||
1140 | 366 | def __init__(self, bucket, name): | ||
1141 | 367 | resource.Resource.__init__(self) | ||
1142 | 368 | self.bucket = bucket | ||
1143 | 369 | self.name = name | ||
1144 | 370 | |||
1145 | 371 | def render_PUT(self, request): | ||
1146 | 372 | """accept the incoming data for a PUT request""" | ||
1147 | 373 | data = request.content.read() | ||
1148 | 374 | content_type = request.getHeader("Content-Type") | ||
1149 | 375 | content_md5 = request.getHeader("Content-MD5") | ||
1150 | 376 | if content_md5: # check if the data is good | ||
1151 | 377 | header_md5 = base64.decodestring(content_md5) | ||
1152 | 378 | data_md5 = hashlib.md5(data) | ||
1153 | 379 | assert (data_md5.digest() == header_md5), "md5 check failed!" | ||
1154 | 380 | content_md5 = data_md5 | ||
1155 | 381 | child = S3Object(self.name, data, content_type, content_md5) | ||
1156 | 382 | date = request.getHeader("Date") | ||
1157 | 383 | if not date: | ||
1158 | 384 | date = time.ctime() | ||
1159 | 385 | child.set_date(date) | ||
1160 | 386 | for k, v in request.getAllHeaders().items(): | ||
1161 | 387 | if k.startswith(AMAZON_META_PREFIX): | ||
1162 | 388 | child.set_meta(k[len(AMAZON_META_PREFIX):], v) | ||
1163 | 389 | self.bucket.bucket_children[ self.name ] = child | ||
1164 | 390 | request.setHeader("ETag", child.get_etag()) | ||
1165 | 391 | logger.debug("created object bucket=%s name=%s size=%d" % ( | ||
1166 | 392 | self.bucket, self.name, len(data))) | ||
1167 | 393 | return "" | ||
1168 | 394 | |||
1169 | 395 | |||
1170 | 396 | class EmptyPage(resource.Resource): | ||
1171 | 397 | """ return Ok/empty document """ | ||
1172 | 398 | isLeaf = True | ||
1173 | 399 | def __init__(self, retcode=http.OK, headers=None, body=""): | ||
1174 | 400 | resource.Resource.__init__(self) | ||
1175 | 401 | self._retcode = retcode | ||
1176 | 402 | self._headers = headers | ||
1177 | 403 | self._body = body | ||
1178 | 404 | |||
1179 | 405 | def render(self, request): | ||
1180 | 406 | """ override the render method to return an empty document """ | ||
1181 | 407 | request.setHeader("x-amz-id-2", str(request)) | ||
1182 | 408 | request.setHeader("x-amz-request-id", str(request)) | ||
1183 | 409 | request.setHeader("Content-Type", "text/html") | ||
1184 | 410 | request.setHeader("Connection", "close") | ||
1185 | 411 | if self._headers: | ||
1186 | 412 | for h, v in self._headers.items(): | ||
1187 | 413 | request.setHeader(h, v) | ||
1188 | 414 | request.setResponseCode(self._retcode) | ||
1189 | 415 | return self._body | ||
1190 | 416 | |||
1191 | 417 | def ErrorPage(http_code, code, message, path, with_body=True): | ||
1192 | 418 | """ helper function that renders an Amazon error response xml page """ | ||
1193 | 419 | err = s4_xml.AmazonError(code, message, path) | ||
1194 | 420 | body = s4_xml.to_XML(err) | ||
1195 | 421 | body_size = str(len(body)) | ||
1196 | 422 | if not with_body: | ||
1197 | 423 | body = "" | ||
1198 | 424 | logger.info("returning error page %s [%s]%s for %s" % ( | ||
1199 | 425 | http_code, code, message, path)) | ||
1200 | 426 | return EmptyPage(http_code, headers={ | ||
1201 | 427 | "Content-Type": "text/xml", | ||
1202 | 428 | "Content-Length": body_size, | ||
1203 | 429 | }, body=body) | ||
1204 | 430 | |||
1205 | 431 | # pylint: disable-msg=C0321 | ||
1206 | 432 | class Bucket(resource.Resource): | ||
1207 | 433 | """ Storage Bucket | ||
1208 | 434 | |||
1209 | 435 | Buckets hold objects with data and receive uploads in case of PUT | ||
1210 | 436 | """ | ||
1211 | 437 | def __init__(self, name): | ||
1212 | 438 | resource.Resource.__init__(self) | ||
1213 | 439 | # cant use children, resource already has that name | ||
1214 | 440 | # and it would work as a cache | ||
1215 | 441 | self.bucket_children = {} | ||
1216 | 442 | self._name = name | ||
1217 | 443 | self._date = time.time() | ||
1218 | 444 | |||
1219 | 445 | def get_name(self): | ||
1220 | 446 | """ returns this bucket's name """ | ||
1221 | 447 | return self._name | ||
1222 | 448 | def __len__(self): | ||
1223 | 449 | """ returns how many objects are in this bucket """ | ||
1224 | 450 | return len(self.bucket_children) | ||
1225 | 451 | def iter_children(self): | ||
1226 | 452 | """ iterator that returns each children objects """ | ||
1227 | 453 | for (key, val) in self.bucket_children.iteritems(): | ||
1228 | 454 | yield key, val | ||
1229 | 455 | def delete(self): | ||
1230 | 456 | """ clean up internal state to prepare bucket for deletion """ | ||
1231 | 457 | pass | ||
1232 | 458 | def _get_state_file(self, rootdir, check=True): | ||
1233 | 459 | """ builds the pathname of the state file """ | ||
1234 | 460 | state_file = os.path.join(rootdir, "%s%s" % (self._name, S4_STATE_FILE)) | ||
1235 | 461 | if check and not os.path.exists(state_file): | ||
1236 | 462 | return None | ||
1237 | 463 | return state_file | ||
1238 | 464 | def _save(self, rootdir): | ||
1239 | 465 | """ saves the state of a bucket """ | ||
1240 | 466 | state_file = self._get_state_file(rootdir, check=False) | ||
1241 | 467 | data = dict( | ||
1242 | 468 | name = self._name, | ||
1243 | 469 | date = self._date, | ||
1244 | 470 | objects = dict([ x for x in self.bucket_children.iteritems() ]) | ||
1245 | 471 | ) | ||
1246 | 472 | with open(state_file, "wb") as state_fd: | ||
1247 | 473 | pickle.dump(data, state_fd) | ||
1248 | 474 | logger.debug("saved bucket '%s' in file '%s'" % ( | ||
1249 | 475 | self._name, state_file)) | ||
1250 | 476 | return | ||
1251 | 477 | def _load(self, rootdir): | ||
1252 | 478 | """ loads a saved bucket state """ | ||
1253 | 479 | state_file = self._get_state_file(rootdir) | ||
1254 | 480 | if not state_file: | ||
1255 | 481 | return | ||
1256 | 482 | with open(state_file, "rb") as state_fd: | ||
1257 | 483 | data = pickle.load(state_fd) | ||
1258 | 484 | assert (self._name == data["name"]), \ | ||
1259 | 485 | "can not load bucket with different name" | ||
1260 | 486 | self._date = data["date"] | ||
1261 | 487 | self.bucket_children = data["objects"] | ||
1262 | 488 | return | ||
1263 | 489 | |||
1264 | 490 | def getChild(self, name, request): | ||
1265 | 491 | """get the next object down the chain""" | ||
1266 | 492 | # avoid recursion into the key names | ||
1267 | 493 | # (which can contain / as a valid char!) | ||
1268 | 494 | if name and request.postpath: | ||
1269 | 495 | name = os.path.join(*((name,)+tuple(request.postpath))) | ||
1270 | 496 | assert (name), "Wrong call stack for name='%s'" % (name,) | ||
1271 | 497 | if request.method == "PUT": | ||
1272 | 498 | child = UploadS3Object(self, name) | ||
1273 | 499 | elif request.method in ("GET", "HEAD") : | ||
1274 | 500 | child = self.bucket_children.get(name, None) | ||
1275 | 501 | elif request.method == "DELETE": | ||
1276 | 502 | child = self.bucket_children.get(name, None) | ||
1277 | 503 | if child is None: # delete unknown object | ||
1278 | 504 | return EmptyPage(http.NO_CONTENT) | ||
1279 | 505 | child.delete() | ||
1280 | 506 | del self.bucket_children[name] | ||
1281 | 507 | return EmptyPage(http.NO_CONTENT) | ||
1282 | 508 | else: | ||
1283 | 509 | logger.error("UNHANDLED request method %s" % request.method) | ||
1284 | 510 | return ErrorPage(http.BAD_REQUEST, "BadRequest", | ||
1285 | 511 | "Your '%s' request is invalid" % request.method, | ||
1286 | 512 | request.path) | ||
1287 | 513 | if child is None: | ||
1288 | 514 | return ErrorPage(http.NOT_FOUND, "NoSuchKey", | ||
1289 | 515 | "The specified key does not exist.", | ||
1290 | 516 | request.path, with_body=(request.method!="HEAD")) | ||
1291 | 517 | return child | ||
1292 | 518 | |||
1293 | 519 | class DiscardBucket(Bucket): | ||
1294 | 520 | """A bucket that will just discard all data as it arrives.""" | ||
1295 | 521 | |||
1296 | 522 | def getChild(self, name, request): | ||
1297 | 523 | """accept uploads and discard them.""" | ||
1298 | 524 | if request.method == "PUT": | ||
1299 | 525 | return self | ||
1300 | 526 | else: | ||
1301 | 527 | return ErrorPage(http.NOT_FOUND, "NoSuchKey", | ||
1302 | 528 | "The specified key does not exist.", | ||
1303 | 529 | request.path) | ||
1304 | 530 | |||
1305 | 531 | def render_PUT(self, request): | ||
1306 | 532 | """accept the incoming data for a PUT request""" | ||
1307 | 533 | # we need to compute a correct md5/etag to send back to the client | ||
1308 | 534 | etag = hashlib.md5() | ||
1309 | 535 | # this loop should be deadlocking with the client code that writes the | ||
1310 | 536 | # data. But render put doesnt get called until the streamer has | ||
1311 | 537 | # put all the that. The python mem usage is constant. And it works. | ||
1312 | 538 | while True: | ||
1313 | 539 | data = request.content.read(BLOCK_SIZE) | ||
1314 | 540 | if not data: | ||
1315 | 541 | break | ||
1316 | 542 | etag.update(data) | ||
1317 | 543 | request.setHeader("ETag", '"%s"' % etag.hexdigest()) | ||
1318 | 544 | return "" | ||
1319 | 545 | |||
1320 | 546 | class SizeBucket(Bucket): | ||
1321 | 547 | """ SizeBucket | ||
1322 | 548 | |||
1323 | 549 | Fakes contents and always returns an object with size = int(objname) | ||
1324 | 550 | """ | ||
1325 | 551 | |||
1326 | 552 | def getChild(self, name, request): | ||
1327 | 553 | """get the next object down the chain""" | ||
1328 | 554 | try: | ||
1329 | 555 | fake = FakeContent("0", int(name)) | ||
1330 | 556 | o = S3Object(name, fake, "text/plain", fake.hexdigest()) | ||
1331 | 557 | return o | ||
1332 | 558 | except ValueError: | ||
1333 | 559 | return "this buckets requires integer named objects" | ||
1334 | 560 | |||
1335 | 561 | |||
1336 | 562 | class Root(resource.Resource): | ||
1337 | 563 | """ Site Root | ||
1338 | 564 | |||
1339 | 565 | handles all the requests. | ||
1340 | 566 | on initialization it configures some default buckets | ||
1341 | 567 | """ | ||
1342 | 568 | owner_id = 0 | ||
1343 | 569 | owner = "fakeuser" | ||
1344 | 570 | |||
1345 | 571 | def __init__(self, storagedir=None, allow_default_access=True): | ||
1346 | 572 | resource.Resource.__init__(self) | ||
1347 | 573 | |||
1348 | 574 | self.auth = {} | ||
1349 | 575 | if allow_default_access: | ||
1350 | 576 | self.auth[ AWS_DEFAULT_ACCESS_KEY_ID ] = \ | ||
1351 | 577 | AWS_DEFAULT_SECRET_ACCESS_KEY | ||
1352 | 578 | self.fail_next = {} | ||
1353 | 579 | self.buckets = dict( | ||
1354 | 580 | size = SizeBucket("size"), | ||
1355 | 581 | discard = DiscardBucket("discard")) | ||
1356 | 582 | |||
1357 | 583 | self._rootdir = storagedir | ||
1358 | 584 | if self._rootdir: | ||
1359 | 585 | self._load() | ||
1360 | 586 | |||
1361 | 587 | def _add_bucket(self, name): | ||
1362 | 588 | """ create a new bucket """ | ||
1363 | 589 | if self.buckets.has_key(name): | ||
1364 | 590 | return self.buckets[name] | ||
1365 | 591 | bucket = Bucket(name) | ||
1366 | 592 | self.buckets[name] = bucket | ||
1367 | 593 | if self._rootdir: | ||
1368 | 594 | bucket._save(self._rootdir) | ||
1369 | 595 | self._save() | ||
1370 | 596 | return bucket | ||
1371 | 597 | |||
1372 | 598 | def _get_state_file(self, check=True): | ||
1373 | 599 | """ locate the saved state file on disk """ | ||
1374 | 600 | assert self._rootdir, "S4 storage has not been initialized" | ||
1375 | 601 | state_file = os.path.join(self._rootdir, S4_STATE_FILE) | ||
1376 | 602 | if check and not os.path.exists(state_file): | ||
1377 | 603 | return None | ||
1378 | 604 | return state_file | ||
1379 | 605 | def _load(self): | ||
1380 | 606 | "load a saved bucket list state from disk " | ||
1381 | 607 | state_file = self._get_state_file() | ||
1382 | 608 | if not state_file: | ||
1383 | 609 | return | ||
1384 | 610 | data = dict(buckets=[]) | ||
1385 | 611 | with open(state_file, "rb") as state_fd: | ||
1386 | 612 | data = pickle.load(state_fd) | ||
1387 | 613 | self.owner_id = data["owner_id"] | ||
1388 | 614 | self.owner = data["owner"] | ||
1389 | 615 | for bucket_name in data["buckets"]: | ||
1390 | 616 | bucket = Bucket(bucket_name) | ||
1391 | 617 | bucket._load(self._rootdir) | ||
1392 | 618 | self.buckets[bucket_name] = bucket | ||
1393 | 619 | self._save(with_buckets=False) | ||
1394 | 620 | return | ||
1395 | 621 | def _save(self, with_buckets=True): | ||
1396 | 622 | """ save current state to disk """ | ||
1397 | 623 | state_file = self._get_state_file(check=False) | ||
1398 | 624 | data = dict( | ||
1399 | 625 | owner = self.owner, | ||
1400 | 626 | owner_id = self.owner_id, | ||
1401 | 627 | buckets = [ x for x in self.buckets.keys() | ||
1402 | 628 | if x not in ("size", "discard")], | ||
1403 | 629 | ) | ||
1404 | 630 | with open(state_file, "wb") as state_fd: | ||
1405 | 631 | pickle.dump(data, state_fd) | ||
1406 | 632 | logger.debug("saved state file %s" % state_file) | ||
1407 | 633 | if not with_buckets: | ||
1408 | 634 | return | ||
1409 | 635 | for bucket_name in data["buckets"]: | ||
1410 | 636 | bucket = self.buckets[bucket_name] | ||
1411 | 637 | bucket._save(self._rootdir) | ||
1412 | 638 | return | ||
1413 | 639 | def fail_next_put(self, error=http.INTERNAL_SERVER_ERROR, | ||
1414 | 640 | message="Internal Server Error"): | ||
1415 | 641 | """ | ||
1416 | 642 | Force next PUT request to return an error | ||
1417 | 643 | """ | ||
1418 | 644 | logger.debug("will fail next put with %d (%s)", error, message) | ||
1419 | 645 | self.fail_next['PUT'] = error, message | ||
1420 | 646 | |||
1421 | 647 | def fail_next_get(self, error=http.INTERNAL_SERVER_ERROR, | ||
1422 | 648 | message="Internal Server Error"): | ||
1423 | 649 | """ | ||
1424 | 650 | Force next GET request to return an error | ||
1425 | 651 | """ | ||
1426 | 652 | logger.debug("will fail next get with %d (%s)", error, message) | ||
1427 | 653 | self.fail_next['GET'] = error, message | ||
1428 | 654 | |||
1429 | 655 | def getChild(self, name, request): | ||
1430 | 656 | """get the next object down the resource path""" | ||
1431 | 657 | if not self.check_auth( request ): | ||
1432 | 658 | return ErrorPage(http.FORBIDDEN, "InvalidSecurity", | ||
1433 | 659 | "The provided security credentials are not valid.", | ||
1434 | 660 | request.path) | ||
1435 | 661 | if request.method in self.fail_next: | ||
1436 | 662 | err, message = self.fail_next.pop(request.method) | ||
1437 | 663 | return error.ErrorPage(err, message, message) | ||
1438 | 664 | if request.path == "/" and request.method == "GET": | ||
1439 | 665 | # this is a getallbuckets call | ||
1440 | 666 | return ListAllMyBucketsResult(self.buckets.values()) | ||
1441 | 667 | |||
1442 | 668 | # need to record when things change and save bucket state | ||
1443 | 669 | if self._rootdir and name and request.method in ("PUT", "DELETE"): | ||
1444 | 670 | def save_state(result, self, name, method): | ||
1445 | 671 | """ callback for when rendering is finished """ | ||
1446 | 672 | bucket = self.buckets[name] | ||
1447 | 673 | return bucket._save(self._rootdir) | ||
1448 | 674 | _defer = request.notifyFinish() | ||
1449 | 675 | _defer.addCallback(save_state, self, name, request.method) | ||
1450 | 676 | |||
1451 | 677 | bucket = self.buckets.get(name, None) | ||
1452 | 678 | # if we operate on a key, pass control | ||
1453 | 679 | if request.postpath and request.postpath[0]: | ||
1454 | 680 | if bucket is None: | ||
1455 | 681 | # bucket does not exist, yet we attempt operation on | ||
1456 | 682 | # an object from that bucket | ||
1457 | 683 | return ErrorPage(http.NOT_FOUND, "InvalidBucketName", | ||
1458 | 684 | "The specified bucket is not valid", | ||
1459 | 685 | request.path) | ||
1460 | 686 | return bucket | ||
1461 | 687 | |||
1462 | 688 | # these are operations that are happening on a bucket and | ||
1463 | 689 | # which are better handled from the root handler | ||
1464 | 690 | |||
1465 | 691 | # we're asked to list a bucket | ||
1466 | 692 | if request.method in ("GET", "HEAD"): | ||
1467 | 693 | if bucket is None: | ||
1468 | 694 | return ErrorPage(http.NOT_FOUND, "NoSuchBucket", | ||
1469 | 695 | "The specified bucket does not exist.", | ||
1470 | 696 | request.path) | ||
1471 | 697 | return ListBucketResult(bucket) | ||
1472 | 698 | # bucket creation. if bucket already exists, noop | ||
1473 | 699 | elif request.method == "PUT": | ||
1474 | 700 | if bucket is None: | ||
1475 | 701 | bucket = self._add_bucket(name) | ||
1476 | 702 | return EmptyPage() | ||
1477 | 703 | # we're asked to delete a bucket | ||
1478 | 704 | elif request.method == "DELETE": | ||
1479 | 705 | if len(bucket): # non-empty buckets can not be deleted | ||
1480 | 706 | return ErrorPage(http.CONFLICT, "BucketNotEmpty", | ||
1481 | 707 | "The bucket you tried to delete is not empty.", | ||
1482 | 708 | request.path) | ||
1483 | 709 | bucket.delete() | ||
1484 | 710 | del self.buckets[name] | ||
1485 | 711 | if self._rootdir: | ||
1486 | 712 | self._save(with_buckets=False) | ||
1487 | 713 | return EmptyPage(http.NO_CONTENT, | ||
1488 | 714 | headers=dict(Location=request.path)) | ||
1489 | 715 | else: | ||
1490 | 716 | return ErrorPage(http.BAD_REQUEST, "BadRequest", | ||
1491 | 717 | "Your '%s' request is invalid" % request.method, | ||
1492 | 718 | request.path) | ||
1493 | 719 | return bucket | ||
1494 | 720 | |||
1495 | 721 | def check_auth(self, request): | ||
1496 | 722 | """ Validates key/secret """ | ||
1497 | 723 | auth_str = request.getHeader('Authorization') | ||
1498 | 724 | if not auth_str.startswith("AWS "): | ||
1499 | 725 | return False | ||
1500 | 726 | access_key, signature = auth_str[4:].split(":") | ||
1501 | 727 | if not access_key in self.auth: | ||
1502 | 728 | return False | ||
1503 | 729 | secret_key = self.auth[ access_key ] | ||
1504 | 730 | headers = request.getAllHeaders() | ||
1505 | 731 | c_string = canonical_path_string( | ||
1506 | 732 | request.method, request.path, headers) | ||
1507 | 733 | if encode(secret_key, c_string) != signature: | ||
1508 | 734 | return False | ||
1509 | 735 | return True | ||
1510 | 736 | |||
1511 | 737 | |||
1512 | 738 | if __name__ == "__main__": | ||
1513 | 739 | root = Root() | ||
1514 | 740 | site = server.Site(root) | ||
1515 | 741 | reactor.listenTCP(8808, site) | ||
1516 | 742 | reactor.run() | ||
1517 | 0 | 743 | ||
1518 | === added file 'txaws/s4/s4_xml.py' | |||
1519 | --- txaws/s4/s4_xml.py 1970-01-01 00:00:00 +0000 | |||
1520 | +++ txaws/s4/s4_xml.py 2009-08-19 14:36:56 +0000 | |||
1521 | @@ -0,0 +1,155 @@ | |||
1522 | 1 | # Copyright 2008-2009 Canonical Ltd. | ||
1523 | 2 | # | ||
1524 | 3 | # Permission is hereby granted, free of charge, to any person obtaining | ||
1525 | 4 | # a copy of this software and associated documentation files (the | ||
1526 | 5 | # "Software"), to deal in the Software without restriction, including | ||
1527 | 6 | # without limitation the rights to use, copy, modify, merge, publish, | ||
1528 | 7 | # distribute, sublicense, and/or sell copies of the Software, and to | ||
1529 | 8 | # permit persons to whom the Software is furnished to do so, subject to | ||
1530 | 9 | # the following conditions: | ||
1531 | 10 | # | ||
1532 | 11 | # The above copyright notice and this permission notice shall be | ||
1533 | 12 | # included in all copies or substantial portions of the Software. | ||
1534 | 13 | # | ||
1535 | 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | ||
1536 | 15 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | ||
1537 | 16 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | ||
1538 | 17 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE | ||
1539 | 18 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | ||
1540 | 19 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | ||
1541 | 20 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | ||
1542 | 21 | |||
1543 | 22 | """ build XML responses that mimic the behavior of the real S3 """ | ||
1544 | 23 | |||
1545 | 24 | from StringIO import StringIO | ||
1546 | 25 | from xml.etree.ElementTree import Element, ElementTree | ||
1547 | 26 | |||
1548 | 27 | XMLNS = "http://s3.amazonaws.com/doc/2006-03-01" | ||
1549 | 28 | |||
1550 | 29 | # <?xml version="1.0" encoding="UTF-8"?> | ||
1551 | 30 | def to_XML(elem): | ||
1552 | 31 | """ renders an xml element to a text/xml page """ | ||
1553 | 32 | s = StringIO() | ||
1554 | 33 | s.write("""<?xml version="1.0" encoding="UTF-8"?>\n""") | ||
1555 | 34 | tree = ElementTree(elem) | ||
1556 | 35 | tree.write(s) | ||
1557 | 36 | return s.getvalue() | ||
1558 | 37 | |||
1559 | 38 | def add_props(elem, **kw): | ||
1560 | 39 | """ add subnodes to a xml node based on a dictionary """ | ||
1561 | 40 | for (key, val) in kw.iteritems(): | ||
1562 | 41 | prop = Element(key) | ||
1563 | 42 | prop.tail = "\n" | ||
1564 | 43 | if val is None: | ||
1565 | 44 | val = "" | ||
1566 | 45 | elif isinstance(val, bool): | ||
1567 | 46 | val = str(val).lower() | ||
1568 | 47 | elif not isinstance(val, str): | ||
1569 | 48 | val = str(val) | ||
1570 | 49 | prop.text = val | ||
1571 | 50 | elem.append(prop) | ||
1572 | 51 | |||
1573 | 52 | # <ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01"> | ||
1574 | 53 | # <Name>bucket</Name> | ||
1575 | 54 | # <Prefix>prefix</Prefix> | ||
1576 | 55 | # <Marker>marker</Marker> | ||
1577 | 56 | # <MaxKeys>max-keys</MaxKeys> | ||
1578 | 57 | # <IsTruncated>false</IsTruncated> | ||
1579 | 58 | # <Contents> | ||
1580 | 59 | # <Key>object</Key> | ||
1581 | 60 | # <LastModified>date</LastModified> | ||
1582 | 61 | # <ETag>etag</ETag> | ||
1583 | 62 | # <Size>size</Size> | ||
1584 | 63 | # <StorageClass>STANDARD</StorageClass> | ||
1585 | 64 | # <Owner> | ||
1586 | 65 | # <ID>owner_id</ID> | ||
1587 | 66 | # <DisplayName>owner_name</DisplayName> | ||
1588 | 67 | # </Owner> | ||
1589 | 68 | # </Contents> | ||
1590 | 69 | # ... | ||
1591 | 70 | # </ListBucketResult> | ||
1592 | 71 | def ListBucketResult(bucket, children): | ||
1593 | 72 | """ builds the xml tree corresponding to a bucket listing """ | ||
1594 | 73 | root = Element("ListBucketResult", dict(xmlns=XMLNS)) | ||
1595 | 74 | root.tail = root.text = "\n" | ||
1596 | 75 | add_props(root, **dict( | ||
1597 | 76 | Name = bucket.get_name(), | ||
1598 | 77 | IsTruncated = False, | ||
1599 | 78 | Marker = 0, | ||
1600 | 79 | )) | ||
1601 | 80 | for (obname, ob) in children.iteritems(): | ||
1602 | 81 | contents = Element("Contents") | ||
1603 | 82 | add_props(contents, **dict( | ||
1604 | 83 | Key = obname, | ||
1605 | 84 | LastModified = ob.get_date(), | ||
1606 | 85 | ETag = ob.get_etag(), | ||
1607 | 86 | Size = ob.get_size(), | ||
1608 | 87 | StorageClass = "STANDARD", | ||
1609 | 88 | )) | ||
1610 | 89 | owner = Element("Owner") | ||
1611 | 90 | add_props(owner, **dict( | ||
1612 | 91 | ID = ob.get_owner_id(), | ||
1613 | 92 | DisplayName = ob.get_owner(), )) | ||
1614 | 93 | contents.append(owner) | ||
1615 | 94 | root.append(contents) | ||
1616 | 95 | return root | ||
1617 | 96 | |||
1618 | 97 | # <Error> | ||
1619 | 98 | # <Code>NoSuchKey</Code> | ||
1620 | 99 | # <Message>The resource you requested does not exist</Message> | ||
1621 | 100 | # <Resource>/mybucket/myfoto.jpg</Resource> | ||
1622 | 101 | # <RequestId>4442587FB7D0A2F9</RequestId> | ||
1623 | 102 | # </Error> | ||
1624 | 103 | def AmazonError(code, message, resource, req_id=""): | ||
1625 | 104 | """ builds xml tree corresponding to an Amazon error xml page """ | ||
1626 | 105 | root = Element("Error") | ||
1627 | 106 | root.tail = root.text = "\n" | ||
1628 | 107 | add_props(root, **dict( | ||
1629 | 108 | Code = code, | ||
1630 | 109 | Message = message, | ||
1631 | 110 | Resource = resource, | ||
1632 | 111 | RequestId = req_id)) | ||
1633 | 112 | return root | ||
1634 | 113 | |||
1635 | 114 | # <ListAllMyBucketsResult xmlns="http://doc.s3.amazonaws.com/2006-03-01"> | ||
1636 | 115 | # <Owner> | ||
1637 | 116 | # <ID>user_id</ID> | ||
1638 | 117 | # <DisplayName>display_name</DisplayName> | ||
1639 | 118 | # </Owner> | ||
1640 | 119 | # <Buckets> | ||
1641 | 120 | # <Bucket> | ||
1642 | 121 | # <Name>bucket_name</Name> | ||
1643 | 122 | # <CreationDate>date</CreationDate> | ||
1644 | 123 | # </Bucket> | ||
1645 | 124 | # ... | ||
1646 | 125 | # </Buckets> | ||
1647 | 126 | # </ListAllMyBucketsResult> | ||
1648 | 127 | def ListAllMyBucketsResult(owner, buckets): | ||
1649 | 128 | """ builds xml tree corresponding to an Amazon list all buckets """ | ||
1650 | 129 | root = Element("ListAllMyBucketsResult", dict(xmlns=XMLNS)) | ||
1651 | 130 | root.tail = root.text = "\n" | ||
1652 | 131 | xml_owner = Element("Owner") | ||
1653 | 132 | add_props(xml_owner, **dict( | ||
1654 | 133 | ID = owner["id"], | ||
1655 | 134 | DisplayName = owner["name"] )) | ||
1656 | 135 | root.append(xml_owner) | ||
1657 | 136 | xml_buckets = Element("Buckets") | ||
1658 | 137 | for bucket in buckets: | ||
1659 | 138 | b = Element("Bucket") | ||
1660 | 139 | add_props(b, **dict( | ||
1661 | 140 | Name = bucket._name, | ||
1662 | 141 | CreationDate = bucket._date)) | ||
1663 | 142 | xml_buckets.append(b) | ||
1664 | 143 | root.append(xml_buckets) | ||
1665 | 144 | return root | ||
1666 | 145 | |||
1667 | 146 | if __name__ == '__main__': | ||
1668 | 147 | # pylint: disable-msg=W0403 | ||
1669 | 148 | # pylint: disable-msg=E0611 | ||
1670 | 149 | from s4 import Bucket | ||
1671 | 150 | bucket = Bucket("test-bucket") | ||
1672 | 151 | lbr = ListBucketResult(bucket) | ||
1673 | 152 | print to_XML(lbr) | ||
1674 | 153 | |||
1675 | 154 | |||
1676 | 155 | |||
1677 | 0 | 156 | ||
1678 | === added directory 'txaws/s4/testing' | |||
1679 | === added file 'txaws/s4/testing/__init__.py' | |||
1680 | === added file 'txaws/s4/testing/testcase.py' | |||
1681 | --- txaws/s4/testing/testcase.py 1970-01-01 00:00:00 +0000 | |||
1682 | +++ txaws/s4/testing/testcase.py 2009-08-19 14:36:56 +0000 | |||
1683 | @@ -0,0 +1,131 @@ | |||
1684 | 1 | # Copyright 2008-2009 Canonical Ltd. | ||
1685 | 2 | # | ||
1686 | 3 | # Permission is hereby granted, free of charge, to any person obtaining | ||
1687 | 4 | # a copy of this software and associated documentation files (the | ||
1688 | 5 | # "Software"), to deal in the Software without restriction, including | ||
1689 | 6 | # without limitation the rights to use, copy, modify, merge, publish, | ||
1690 | 7 | # distribute, sublicense, and/or sell copies of the Software, and to | ||
1691 | 8 | # permit persons to whom the Software is furnished to do so, subject to | ||
1692 | 9 | # the following conditions: | ||
1693 | 10 | # | ||
1694 | 11 | # The above copyright notice and this permission notice shall be | ||
1695 | 12 | # included in all copies or substantial portions of the Software. | ||
1696 | 13 | # | ||
1697 | 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | ||
1698 | 15 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | ||
1699 | 16 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | ||
1700 | 17 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE | ||
1701 | 18 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | ||
1702 | 19 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | ||
1703 | 20 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | ||
1704 | 21 | |||
1705 | 22 | """Test case for S4 test server""" | ||
1706 | 23 | |||
1707 | 24 | import os | ||
1708 | 25 | import tempfile | ||
1709 | 26 | import shutil | ||
1710 | 27 | |||
1711 | 28 | from twisted.web import server | ||
1712 | 29 | from twisted.internet import reactor | ||
1713 | 30 | from twisted.trial.unittest import TestCase as TwistedTestCase | ||
1714 | 31 | |||
1715 | 32 | from txaws.s4 import s4 | ||
1716 | 33 | from boto.s3 import connection | ||
1717 | 34 | |||
1718 | 35 | # pylint: disable-msg=W0201 | ||
1719 | 36 | class S4TestCase(TwistedTestCase): | ||
1720 | 37 | """ Base class for testing S4 | ||
1721 | 38 | |||
1722 | 39 | This class takes care of starting a server instance for all S4 tests | ||
1723 | 40 | |||
1724 | 41 | As S4 is based on twisted, we inherit from TwistedTestCase. | ||
1725 | 42 | As our tests are blocking, we decorate them with 'blocking_test' to | ||
1726 | 43 | handle that. | ||
1727 | 44 | """ | ||
1728 | 45 | s3 = None | ||
1729 | 46 | logfile = None | ||
1730 | 47 | storagedir = None | ||
1731 | 48 | active = False | ||
1732 | 49 | def setUp(self): | ||
1733 | 50 | """Setup method.""" | ||
1734 | 51 | if not self.active: | ||
1735 | 52 | self.start_server() | ||
1736 | 53 | |||
1737 | 54 | def tearDown(self): | ||
1738 | 55 | """ tear down end testcase method """ | ||
1739 | 56 | # dirty hack to force closing all the cruft boto might be | ||
1740 | 57 | # leaving around | ||
1741 | 58 | if self.s3: | ||
1742 | 59 | # this for is intentional to deal with s3._cache.__iter__ breakage | ||
1743 | 60 | for key in [x for x in self.s3._cache]: | ||
1744 | 61 | self.s3._cache[key].close() | ||
1745 | 62 | self.s3._cache[key] = None | ||
1746 | 63 | self.s3 = None | ||
1747 | 64 | self.stop_server() | ||
1748 | 65 | |||
1749 | 66 | def connect_ok(self, access=s4.AWS_DEFAULT_ACCESS_KEY_ID, | ||
1750 | 67 | secret=s4.AWS_DEFAULT_SECRET_ACCESS_KEY): | ||
1751 | 68 | """ Get a valid connection to S3 (actually, to S4) """ | ||
1752 | 69 | if self.s3: | ||
1753 | 70 | return self.s3 | ||
1754 | 71 | s3 = connection.S3Connection(access, secret, is_secure=False, | ||
1755 | 72 | host="localhost", port=self.port, | ||
1756 | 73 | calling_format=s4.CallingFormat()) | ||
1757 | 74 | # don't let boto do it's braindead retrying for us | ||
1758 | 75 | s3.num_retries = 0 | ||
1759 | 76 | # Need to keep track of this connection | ||
1760 | 77 | self.s3 = s3 | ||
1761 | 78 | return s3 | ||
1762 | 79 | |||
1763 | 80 | @property | ||
1764 | 81 | def port(self): | ||
1765 | 82 | """The port.""" | ||
1766 | 83 | return self.conn.getHost().port | ||
1767 | 84 | |||
1768 | 85 | def start_server(self, persistent=False): | ||
1769 | 86 | """ start the S4 listening server """ | ||
1770 | 87 | if self.active: | ||
1771 | 88 | return | ||
1772 | 89 | if persistent: | ||
1773 | 90 | if not self.storagedir: | ||
1774 | 91 | self.storagedir = tempfile.mkdtemp( | ||
1775 | 92 | prefix="test-s4-boto-", suffix="-cache") | ||
1776 | 93 | root = s4.Root(storagedir=self.storagedir) | ||
1777 | 94 | else: | ||
1778 | 95 | root = s4.Root() | ||
1779 | 96 | self.site = server.Site(root) | ||
1780 | 97 | self.active = True | ||
1781 | 98 | self.conn = reactor.listenTCP(0, self.site) | ||
1782 | 99 | |||
1783 | 100 | def stop_server(self): | ||
1784 | 101 | """ stop the S4 listening server """ | ||
1785 | 102 | self.active = False | ||
1786 | 103 | self.conn.stopListening() | ||
1787 | 104 | if self.storagedir and os.path.exists(self.storagedir): | ||
1788 | 105 | shutil.rmtree(self.storagedir, ignore_errors=True) | ||
1789 | 106 | self.storagedir = None | ||
1790 | 107 | |||
1791 | 108 | def restart_server(self, persistent=False): | ||
1792 | 109 | """ restarts the S4 listening server """ | ||
1793 | 110 | self.stop_server() | ||
1794 | 111 | self.start_server(persistent=persistent) | ||
1795 | 112 | |||
1796 | 113 | |||
1797 | 114 | from twisted.internet import threads | ||
1798 | 115 | from twisted.python.util import mergeFunctionMetadata | ||
1799 | 116 | |||
1800 | 117 | def defer_to_thread(function): | ||
1801 | 118 | """Run in a thread and return a Deferred that fires when done.""" | ||
1802 | 119 | def decorated(*args, **kwargs): | ||
1803 | 120 | """Run in a thread and return a Deferred that fires when done.""" | ||
1804 | 121 | return threads.deferToThread(function, *args, **kwargs) | ||
1805 | 122 | return mergeFunctionMetadata(function, decorated) | ||
1806 | 123 | |||
1807 | 124 | def skip_test(reason): | ||
1808 | 125 | """ tag a testcase to be skipped by the test runner """ | ||
1809 | 126 | def deco(f, *args, **kw): | ||
1810 | 127 | """ testcase decorator """ | ||
1811 | 128 | f.skip = reason | ||
1812 | 129 | return deco | ||
1813 | 130 | |||
1814 | 131 | |||
1815 | 0 | 132 | ||
1816 | === added directory 'txaws/s4/tests' | |||
1817 | === added file 'txaws/s4/tests/__init__.py' | |||
1818 | === added file 'txaws/s4/tests/test_S4.py' | |||
1819 | --- txaws/s4/tests/test_S4.py 1970-01-01 00:00:00 +0000 | |||
1820 | +++ txaws/s4/tests/test_S4.py 2009-08-19 14:36:56 +0000 | |||
1821 | @@ -0,0 +1,194 @@ | |||
1822 | 1 | # Copyright 2008 Canonical Ltd. | ||
1823 | 2 | # | ||
1824 | 3 | # Permission is hereby granted, free of charge, to any person obtaining | ||
1825 | 4 | # a copy of this software and associated documentation files (the | ||
1826 | 5 | # "Software"), to deal in the Software without restriction, including | ||
1827 | 6 | # without limitation the rights to use, copy, modify, merge, publish, | ||
1828 | 7 | # distribute, sublicense, and/or sell copies of the Software, and to | ||
1829 | 8 | # permit persons to whom the Software is furnished to do so, subject to | ||
1830 | 9 | # the following conditions: | ||
1831 | 10 | # | ||
1832 | 11 | # The above copyright notice and this permission notice shall be | ||
1833 | 12 | # included in all copies or substantial portions of the Software. | ||
1834 | 13 | # | ||
1835 | 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | ||
1836 | 15 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | ||
1837 | 16 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | ||
1838 | 17 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE | ||
1839 | 18 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | ||
1840 | 19 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | ||
1841 | 20 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | ||
1842 | 21 | |||
1843 | 22 | """Unit tests for S4 test server""" | ||
1844 | 23 | |||
1845 | 24 | import time | ||
1846 | 25 | import unittest | ||
1847 | 26 | |||
1848 | 27 | from txaws.s4.testing.testcase import S4TestCase, defer_to_thread | ||
1849 | 28 | from boto.exception import S3ResponseError, BotoServerError | ||
1850 | 29 | |||
1851 | 30 | class TestBasicObjectManipulation(S4TestCase): | ||
1852 | 31 | """Tests for basic object manipulation.""" | ||
1853 | 32 | |||
1854 | 33 | def _get_sample_key(self, s3, content, content_type=None): | ||
1855 | 34 | """ cerate a new bucket and return a sample key from content """ | ||
1856 | 35 | bname = "test-%.2f" % time.time() | ||
1857 | 36 | bucket = s3.create_bucket(bname) | ||
1858 | 37 | key = bucket.new_key("sample") | ||
1859 | 38 | if content_type: | ||
1860 | 39 | key.content_type = content_type | ||
1861 | 40 | key.set_contents_from_string(content) | ||
1862 | 41 | return key | ||
1863 | 42 | |||
1864 | 43 | @defer_to_thread | ||
1865 | 44 | def test_get(self): | ||
1866 | 45 | """ Get one object """ | ||
1867 | 46 | |||
1868 | 47 | s3 = self.connect_ok() | ||
1869 | 48 | size = 30 | ||
1870 | 49 | b = s3.get_bucket("size") | ||
1871 | 50 | m = b.get_key(str(size)) | ||
1872 | 51 | |||
1873 | 52 | body = m.get_contents_as_string() | ||
1874 | 53 | self.assertEquals(body, "0"*size) | ||
1875 | 54 | self.assertEquals(m.size, size) | ||
1876 | 55 | self.assertEquals(m.content_type, "text/plain") | ||
1877 | 56 | |||
1878 | 57 | @defer_to_thread | ||
1879 | 58 | def test_get_range(self): | ||
1880 | 59 | """Get part of one object""" | ||
1881 | 60 | |||
1882 | 61 | s3 = self.connect_ok() | ||
1883 | 62 | content = '0123456789' | ||
1884 | 63 | key = self._get_sample_key(s3, content) | ||
1885 | 64 | size = len(content) | ||
1886 | 65 | |||
1887 | 66 | def _get_range(range_start, range_size=None): | ||
1888 | 67 | """test range get for various ranges""" | ||
1889 | 68 | if range_size: | ||
1890 | 69 | range_header = {"Range" : "bytes=%s-%s" % ( | ||
1891 | 70 | range_start, range_start + range_size - 1 )} | ||
1892 | 71 | else: | ||
1893 | 72 | range_header = {"Range" : "bytes=%s-" % (range_start,)} | ||
1894 | 73 | range_size = size - range_start | ||
1895 | 74 | key.open_read(headers=range_header) | ||
1896 | 75 | self.assertEquals(key.size, range_size) | ||
1897 | 76 | self.assertEquals(key.resp.status, 206) | ||
1898 | 77 | ret = key.read() | ||
1899 | 78 | body = content[range_start:range_start+range_size] | ||
1900 | 79 | self.assertEquals(ret, body) | ||
1901 | 80 | key.close() | ||
1902 | 81 | # get a test range | ||
1903 | 82 | range_size = 5 | ||
1904 | 83 | range_start = 2 | ||
1905 | 84 | _get_range(range_start) | ||
1906 | 85 | _get_range(range_start, range_size) | ||
1907 | 86 | |||
1908 | 87 | @defer_to_thread | ||
1909 | 88 | def test_get_multiple_range(self): | ||
1910 | 89 | """Get part of one object""" | ||
1911 | 90 | |||
1912 | 91 | s3 = self.connect_ok() | ||
1913 | 92 | content = '0123456789' | ||
1914 | 93 | size = len(content) | ||
1915 | 94 | key = self._get_sample_key(s3, content) | ||
1916 | 95 | range_header = {"Range" : "bytes=0-1,5-6,9-" } | ||
1917 | 96 | exc = self.assertRaises(S3ResponseError, key.open_read, | ||
1918 | 97 | headers=range_header) | ||
1919 | 98 | self.assertEquals(exc.status, 416) | ||
1920 | 99 | key.close() | ||
1921 | 100 | |||
1922 | 101 | @defer_to_thread | ||
1923 | 102 | def test_get_illegal_range(self): | ||
1924 | 103 | """make sure first-byte-pos is present""" | ||
1925 | 104 | |||
1926 | 105 | s3 = self.connect_ok() | ||
1927 | 106 | content = '0123456789' | ||
1928 | 107 | size = len(content) | ||
1929 | 108 | key = self._get_sample_key(s3, content) | ||
1930 | 109 | range_header = {"Range" : "bytes=-1" } | ||
1931 | 110 | exc = self.assertRaises(S3ResponseError, key.open_read, | ||
1932 | 111 | headers=range_header) | ||
1933 | 112 | self.assertEquals(exc.status, 416) | ||
1934 | 113 | key.close() | ||
1935 | 114 | |||
1936 | 115 | @defer_to_thread | ||
1937 | 116 | def test_get_404(self): | ||
1938 | 117 | """ Try to get an object thats not there, expect 404 """ | ||
1939 | 118 | |||
1940 | 119 | s3 = self.connect_ok() | ||
1941 | 120 | bname = "test-%.2f" % time.time() | ||
1942 | 121 | bucket = s3.create_bucket(bname) | ||
1943 | 122 | # this does not create a key on the server side yet | ||
1944 | 123 | key = bucket.new_key(bname) | ||
1945 | 124 | # ... which is why we should get errors when attempting to read it | ||
1946 | 125 | exc = self.assertRaises(S3ResponseError, key.open_read) | ||
1947 | 126 | self.assertEquals(key.resp.status, 404) | ||
1948 | 127 | self.assertEquals(exc.status, 404) | ||
1949 | 128 | |||
1950 | 129 | @defer_to_thread | ||
1951 | 130 | def test_get_403(self): | ||
1952 | 131 | """ Try to get an object with invalid credentials """ | ||
1953 | 132 | s3 = self.connect_ok(secret="bad secret") | ||
1954 | 133 | exc = self.assertRaises(S3ResponseError, s3.get_bucket, "size") | ||
1955 | 134 | self.assertEquals(exc.status, 403) | ||
1956 | 135 | |||
1957 | 136 | |||
1958 | 137 | @defer_to_thread | ||
1959 | 138 | def test_discarded(self): | ||
1960 | 139 | """ put an object, get a 404 """ | ||
1961 | 140 | s3 = self.connect_ok() | ||
1962 | 141 | bucket = s3.get_bucket("discard") | ||
1963 | 142 | key = bucket.new_key("sample") | ||
1964 | 143 | message = "Hello World!" | ||
1965 | 144 | key.content_type = "text/lame" | ||
1966 | 145 | key.set_contents_from_string(message) | ||
1967 | 146 | exc = self.assertRaises(S3ResponseError, key.read) | ||
1968 | 147 | self.assertEquals(exc.status, 404) | ||
1969 | 148 | |||
1970 | 149 | @defer_to_thread | ||
1971 | 150 | def test_put(self): | ||
1972 | 151 | """ put an object, get it back """ | ||
1973 | 152 | s3 = self.connect_ok() | ||
1974 | 153 | |||
1975 | 154 | message = "Hello World!" | ||
1976 | 155 | key = self._get_sample_key(s3, message, "text/lame") | ||
1977 | 156 | for x in range(1, 10): | ||
1978 | 157 | body = key.get_contents_as_string() | ||
1979 | 158 | self.assertEquals(body, message*x) | ||
1980 | 159 | key.set_contents_from_string(message*(x+1)) | ||
1981 | 160 | self.assertEquals(key.content_type, "text/lame") | ||
1982 | 161 | |||
1983 | 162 | @defer_to_thread | ||
1984 | 163 | def test_fail_next(self): | ||
1985 | 164 | """ Test whether fail_next_put works """ | ||
1986 | 165 | s3 = self.connect_ok() | ||
1987 | 166 | message = "Hello World!" | ||
1988 | 167 | key = self._get_sample_key(s3, message, "text/lamest") | ||
1989 | 168 | |||
1990 | 169 | # dirty poking at our own internals, but it works... | ||
1991 | 170 | self.site.resource.fail_next_put() | ||
1992 | 171 | |||
1993 | 172 | exc = self.assertRaises(BotoServerError, key.set_contents_from_string, | ||
1994 | 173 | message) | ||
1995 | 174 | self.assertEquals(exc.status, 500) | ||
1996 | 175 | # next one should work | ||
1997 | 176 | key.set_contents_from_string(message*2) | ||
1998 | 177 | body = key.get_contents_as_string() | ||
1999 | 178 | self.assertEquals(body, message*2) | ||
2000 | 179 | |||
2001 | 180 | # now test the get fail | ||
2002 | 181 | self.site.resource.fail_next_get() | ||
2003 | 182 | key.set_contents_from_string(message*3) | ||
2004 | 183 | exc = self.assertRaises(BotoServerError, key.read) | ||
2005 | 184 | self.assertEquals(exc.status, 500) | ||
2006 | 185 | # next get should work | ||
2007 | 186 | body = key.get_contents_as_string() | ||
2008 | 187 | self.assertEquals(body, message*3) | ||
2009 | 188 | |||
2010 | 189 | def test_suite(): | ||
2011 | 190 | """Used by the rest runner to find the tests in this module""" | ||
2012 | 191 | return unittest.TestLoader().loadTestsFromName(__name__) | ||
2013 | 192 | |||
2014 | 193 | if __name__ == "__main__": | ||
2015 | 194 | unittest.main() | ||
2016 | 0 | 195 | ||
2017 | === added file 'txaws/s4/tests/test_boto.py' | |||
2018 | --- txaws/s4/tests/test_boto.py 1970-01-01 00:00:00 +0000 | |||
2019 | +++ txaws/s4/tests/test_boto.py 2009-08-19 14:36:56 +0000 | |||
2020 | @@ -0,0 +1,275 @@ | |||
2021 | 1 | #!/usr/bin/python | ||
2022 | 2 | # | ||
2023 | 3 | # Permission is hereby granted, free of charge, to any person obtaining | ||
2024 | 4 | # a copy of this software and associated documentation files (the | ||
2025 | 5 | # "Software"), to deal in the Software without restriction, including | ||
2026 | 6 | # without limitation the rights to use, copy, modify, merge, publish, | ||
2027 | 7 | # distribute, sublicense, and/or sell copies of the Software, and to | ||
2028 | 8 | # permit persons to whom the Software is furnished to do so, subject to | ||
2029 | 9 | # the following conditions: | ||
2030 | 10 | # | ||
2031 | 11 | # The above copyright notice and this permission notice shall be | ||
2032 | 12 | # included in all copies or substantial portions of the Software. | ||
2033 | 13 | # | ||
2034 | 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | ||
2035 | 15 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | ||
2036 | 16 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | ||
2037 | 17 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE | ||
2038 | 18 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | ||
2039 | 19 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | ||
2040 | 20 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | ||
2041 | 21 | |||
2042 | 22 | # | ||
2043 | 23 | # test s4 implementation using the python-boto client | ||
2044 | 24 | |||
2045 | 25 | """ | ||
2046 | 26 | imported (from boto) unit tests for the S3Connection | ||
2047 | 27 | """ | ||
2048 | 28 | import unittest | ||
2049 | 29 | |||
2050 | 30 | import os | ||
2051 | 31 | import time | ||
2052 | 32 | import tempfile | ||
2053 | 33 | |||
2054 | 34 | from StringIO import StringIO | ||
2055 | 35 | |||
2056 | 36 | from txaws.s4.testing.testcase import S4TestCase, defer_to_thread, skip_test | ||
2057 | 37 | from boto.exception import S3PermissionsError | ||
2058 | 38 | |||
2059 | 39 | # pylint: disable-msg=C0111 | ||
2060 | 40 | class S3ConnectionTest(S4TestCase): | ||
2061 | 41 | def _get_bucket(self, s3conn): | ||
2062 | 42 | # create a new, empty bucket | ||
2063 | 43 | bucket_name = 'test-%.3f' % time.time() | ||
2064 | 44 | bucket = s3conn.create_bucket(bucket_name) | ||
2065 | 45 | # now try a get_bucket call and see if it's really there | ||
2066 | 46 | bucket = s3conn.get_bucket(bucket_name) | ||
2067 | 47 | return bucket | ||
2068 | 48 | |||
2069 | 49 | @defer_to_thread | ||
2070 | 50 | def test_basic(self): | ||
2071 | 51 | T1 = 'This is a test of file upload and download' | ||
2072 | 52 | s3conn = self.connect_ok() | ||
2073 | 53 | |||
2074 | 54 | all_buckets = s3conn.get_all_buckets() | ||
2075 | 55 | bucket = self._get_bucket(s3conn) | ||
2076 | 56 | all_buckets = s3conn.get_all_buckets() | ||
2077 | 57 | self.failUnless(bucket.name in [x.name for x in all_buckets]) | ||
2078 | 58 | # bucket should be empty now | ||
2079 | 59 | self.failUnlessEqual(bucket.get_key("missing"), None) | ||
2080 | 60 | all = bucket.get_all_keys() | ||
2081 | 61 | self.failUnlessEqual(len(all), 0) | ||
2082 | 62 | # create a new key and store it's content from a string | ||
2083 | 63 | k = bucket.new_key() | ||
2084 | 64 | k.name = 'foobar' | ||
2085 | 65 | k.set_contents_from_string(T1) | ||
2086 | 66 | fp = StringIO() | ||
2087 | 67 | # now get the contents from s3 to a local file | ||
2088 | 68 | k.get_contents_to_file(fp) | ||
2089 | 69 | # check to make sure content read from s3 is identical to original | ||
2090 | 70 | self.failUnlessEqual(T1, fp.getvalue()) | ||
2091 | 71 | bucket.delete_key(k) | ||
2092 | 72 | self.failUnlessEqual(bucket.get_key(k.name), None) | ||
2093 | 73 | |||
2094 | 74 | @defer_to_thread | ||
2095 | 75 | def test_lookup(self): | ||
2096 | 76 | T1 = 'This is a test of file upload and download' | ||
2097 | 77 | T2 = 'This is a second string to test file upload and download' | ||
2098 | 78 | s3conn = self.connect_ok() | ||
2099 | 79 | bucket = self._get_bucket(s3conn) | ||
2100 | 80 | # create a new key and store it's content from a string | ||
2101 | 81 | k = bucket.new_key() | ||
2102 | 82 | # test a few variations on get_all_keys - first load some data | ||
2103 | 83 | # for the first one, let's override the content type | ||
2104 | 84 | (fd, fname) = tempfile.mkstemp() | ||
2105 | 85 | os.write(fd, T1) | ||
2106 | 86 | os.close(fd) | ||
2107 | 87 | phony_mimetype = 'application/x-boto-test' | ||
2108 | 88 | headers = {'Content-Type': phony_mimetype} | ||
2109 | 89 | k.name = 'foo/bar' | ||
2110 | 90 | k.set_contents_from_string(T1, headers) | ||
2111 | 91 | k.name = 'foo/bas' | ||
2112 | 92 | k.set_contents_from_filename(fname) | ||
2113 | 93 | k.name = 'foo/bat' | ||
2114 | 94 | k.set_contents_from_string(T1) | ||
2115 | 95 | k.name = 'fie/bar' | ||
2116 | 96 | k.set_contents_from_string(T1) | ||
2117 | 97 | k.name = 'fie/bas' | ||
2118 | 98 | k.set_contents_from_string(T1) | ||
2119 | 99 | k.name = 'fie/bat' | ||
2120 | 100 | k.set_contents_from_string(T1) | ||
2121 | 101 | # try resetting the contents to another value | ||
2122 | 102 | md5 = k.md5 | ||
2123 | 103 | k.set_contents_from_string(T2) | ||
2124 | 104 | self.failIfEqual(k.md5, md5) | ||
2125 | 105 | os.unlink(fname) | ||
2126 | 106 | all = bucket.get_all_keys() | ||
2127 | 107 | self.failUnlessEqual(len(all), 6) | ||
2128 | 108 | rs = bucket.get_all_keys(prefix='foo') | ||
2129 | 109 | self.failUnlessEqual(len(rs), 3) | ||
2130 | 110 | rs = bucket.get_all_keys(maxkeys=5) | ||
2131 | 111 | self.failUnlessEqual(len(rs), 5) | ||
2132 | 112 | # test the lookup method | ||
2133 | 113 | k = bucket.lookup('foo/bar') | ||
2134 | 114 | self.failUnless(isinstance(k, bucket.key_class)) | ||
2135 | 115 | self.failUnlessEqual(k.content_type, phony_mimetype) | ||
2136 | 116 | k = bucket.lookup('notthere') | ||
2137 | 117 | self.failUnlessEqual(k, None) | ||
2138 | 118 | |||
2139 | 119 | @defer_to_thread | ||
2140 | 120 | def test_metadata(self): | ||
2141 | 121 | T1 = 'This is a test of file upload and download' | ||
2142 | 122 | s3conn = self.connect_ok() | ||
2143 | 123 | bucket = self._get_bucket(s3conn) | ||
2144 | 124 | # try some metadata stuff | ||
2145 | 125 | k = bucket.new_key() | ||
2146 | 126 | k.name = 'has_metadata' | ||
2147 | 127 | mdkey1 = 'meta1' | ||
2148 | 128 | mdval1 = 'This is the first metadata value' | ||
2149 | 129 | k.set_metadata(mdkey1, mdval1) | ||
2150 | 130 | mdkey2 = 'meta2' | ||
2151 | 131 | mdval2 = 'This is the second metadata value' | ||
2152 | 132 | k.set_metadata(mdkey2, mdval2) | ||
2153 | 133 | k.set_contents_from_string(T1) | ||
2154 | 134 | k = bucket.lookup('has_metadata') | ||
2155 | 135 | self.failUnlessEqual(k.get_metadata(mdkey1), mdval1) | ||
2156 | 136 | self.failUnlessEqual(k.get_metadata(mdkey2), mdval2) | ||
2157 | 137 | k = bucket.new_key() | ||
2158 | 138 | k.name = 'has_metadata' | ||
2159 | 139 | k.get_contents_as_string() | ||
2160 | 140 | self.failUnlessEqual(k.get_metadata(mdkey1), mdval1) | ||
2161 | 141 | self.failUnlessEqual(k.get_metadata(mdkey2), mdval2) | ||
2162 | 142 | bucket.delete_key(k) | ||
2163 | 143 | # try a key with a funny character | ||
2164 | 144 | rs = bucket.get_all_keys() | ||
2165 | 145 | num_keys = len(rs) | ||
2166 | 146 | k = bucket.new_key() | ||
2167 | 147 | k.name = 'testnewline\n' | ||
2168 | 148 | k.set_contents_from_string('This is a test') | ||
2169 | 149 | rs = bucket.get_all_keys() | ||
2170 | 150 | self.failUnlessEqual(len(rs), num_keys + 1) | ||
2171 | 151 | bucket.delete_key(k) | ||
2172 | 152 | rs = bucket.get_all_keys() | ||
2173 | 153 | self.failUnlessEqual(len(rs), num_keys) | ||
2174 | 154 | |||
2175 | 155 | # tests removing objects from the store | ||
2176 | 156 | @defer_to_thread | ||
2177 | 157 | def test_cleanup(self): | ||
2178 | 158 | s3conn = self.connect_ok() | ||
2179 | 159 | bucket = self._get_bucket(s3conn) | ||
2180 | 160 | for x in range(10): | ||
2181 | 161 | k = bucket.new_key() | ||
2182 | 162 | k.name = "foo%d" % x | ||
2183 | 163 | k.set_contents_from_string("test %d" % x) | ||
2184 | 164 | all = bucket.get_all_keys() | ||
2185 | 165 | # now delete all keys in bucket | ||
2186 | 166 | for k in all: | ||
2187 | 167 | bucket.delete_key(k) | ||
2188 | 168 | # now delete bucket | ||
2189 | 169 | s3conn.delete_bucket(bucket) | ||
2190 | 170 | |||
2191 | 171 | @defer_to_thread | ||
2192 | 172 | def test_connection(self): | ||
2193 | 173 | s3conn = self.connect_ok() | ||
2194 | 174 | bucket = self._get_bucket(s3conn) | ||
2195 | 175 | all_buckets = s3conn.get_all_buckets() | ||
2196 | 176 | size_bucket = s3conn.get_bucket("size") | ||
2197 | 177 | discard_buucket = s3conn.get_bucket("discard") | ||
2198 | 178 | |||
2199 | 179 | @defer_to_thread | ||
2200 | 180 | def test_persistence(self): | ||
2201 | 181 | # pylint: disable-msg=W0631 | ||
2202 | 182 | # first, stop the server and restart it in persistent mode | ||
2203 | 183 | self.restart_server(persistent=True) | ||
2204 | 184 | s3conn = self.connect_ok() | ||
2205 | 185 | for bcount in range(1, 5): | ||
2206 | 186 | bucket = self._get_bucket(s3conn) | ||
2207 | 187 | for kcount in range(1, 5): | ||
2208 | 188 | k = bucket.new_key() | ||
2209 | 189 | k.name = "bucket-%d-key-%d" % (bcount, kcount) | ||
2210 | 190 | k.set_contents_from_string( | ||
2211 | 191 | "This is key %d from bucket %d (%s)" %( | ||
2212 | 192 | kcount, bcount, bucket.name)) | ||
2213 | 193 | k.set_metadata("bcount", bcount) | ||
2214 | 194 | k.set_metadata("kcount", kcount) | ||
2215 | 195 | # now get a list of all the buckets and objects in the store | ||
2216 | 196 | all_buckets = s3conn.get_all_buckets() | ||
2217 | 197 | all_objects = {} | ||
2218 | 198 | for x in all_buckets: | ||
2219 | 199 | if x.name in ["size", "discard"]: | ||
2220 | 200 | continue | ||
2221 | 201 | objset = all_objects.setdefault(x.name, set()) | ||
2222 | 202 | bucket = s3conn.get_bucket(x.name) | ||
2223 | 203 | for obj in bucket.get_all_keys(): | ||
2224 | 204 | objset.add(obj) | ||
2225 | 205 | # XXX: test metadata | ||
2226 | 206 | # now stop the S4Server and restart it | ||
2227 | 207 | self.restart_server(persistent=True) | ||
2228 | 208 | new_buckets = s3conn.get_all_buckets() | ||
2229 | 209 | self.failUnlessEqual( | ||
2230 | 210 | set([x.name for x in all_buckets]), | ||
2231 | 211 | set([x.name for x in new_buckets]) ) | ||
2232 | 212 | new_objects = {} | ||
2233 | 213 | for x in new_buckets: | ||
2234 | 214 | if x.name in ["size", "discard"]: | ||
2235 | 215 | continue | ||
2236 | 216 | objset = new_objects.setdefault(x.name, set()) | ||
2237 | 217 | bucket = s3conn.get_bucket(x.name) | ||
2238 | 218 | for obj in bucket.get_all_keys(): | ||
2239 | 219 | objset.add(obj) | ||
2240 | 220 | # XXX: test metadata | ||
2241 | 221 | # test the newobjects | ||
2242 | 222 | self.failUnlessEqual( | ||
2243 | 223 | set(all_objects.keys()), | ||
2244 | 224 | set(new_objects.keys()) ) | ||
2245 | 225 | for key in all_objects.keys(): | ||
2246 | 226 | self.failUnlessEqual( | ||
2247 | 227 | set([x.name for x in all_objects[key]]), | ||
2248 | 228 | set([x.name for x in new_objects[key]]) ) | ||
2249 | 229 | |||
2250 | 230 | @defer_to_thread | ||
2251 | 231 | def test_size_bucket(self): | ||
2252 | 232 | s3conn = self.connect_ok() | ||
2253 | 233 | bucket = s3conn.get_bucket("size") | ||
2254 | 234 | all_keys = bucket.get_all_keys() | ||
2255 | 235 | self.failUnlessEqual(all_keys, []) | ||
2256 | 236 | for size in range(1, 10**7, 10000): | ||
2257 | 237 | k = bucket.get_key(str(size)) | ||
2258 | 238 | self.failUnlessEqual(size, k.size) | ||
2259 | 239 | # try to read in the last key (should be the biggest) | ||
2260 | 240 | size = 0 | ||
2261 | 241 | k.open("r") | ||
2262 | 242 | for chunk in k: | ||
2263 | 243 | size += len(chunk) | ||
2264 | 244 | self.failUnlessEqual(size, k.size) | ||
2265 | 245 | |||
2266 | 246 | @skip_test("S4 does not have this functionality yet") | ||
2267 | 247 | @defer_to_thread | ||
2268 | 248 | def test_acl(self): | ||
2269 | 249 | s3conn = self.connect_ok() | ||
2270 | 250 | bucket = self._get_bucket(s3conn) | ||
2271 | 251 | # try some acl stuff | ||
2272 | 252 | bucket.set_acl('public-read') | ||
2273 | 253 | policy = bucket.get_acl() | ||
2274 | 254 | assert len(policy.acl.grants) == 2 | ||
2275 | 255 | bucket.set_acl('private') | ||
2276 | 256 | policy = bucket.get_acl() | ||
2277 | 257 | assert len(policy.acl.grants) == 1 | ||
2278 | 258 | k = bucket.lookup('foo/bar') | ||
2279 | 259 | k.set_acl('public-read') | ||
2280 | 260 | policy = k.get_acl() | ||
2281 | 261 | assert len(policy.acl.grants) == 2 | ||
2282 | 262 | k.set_acl('private') | ||
2283 | 263 | policy = k.get_acl() | ||
2284 | 264 | assert len(policy.acl.grants) == 1 | ||
2285 | 265 | # try the convenience methods for grants | ||
2286 | 266 | bucket.add_user_grant( | ||
2287 | 267 | 'FULL_CONTROL', | ||
2288 | 268 | 'c1e724fbfa0979a4448393c59a8c055011f739b6d102fb37a65f26414653cd67') | ||
2289 | 269 | self.failUnlessRaises(S3PermissionsError, bucket.add_email_grant, | ||
2290 | 270 | 'foobar', 'foo@bar.com') | ||
2291 | 271 | |||
2292 | 272 | if __name__ == '__main__': | ||
2293 | 273 | suite = unittest.TestSuite() | ||
2294 | 274 | suite.addTest(unittest.makeSuite(S3ConnectionTest)) | ||
2295 | 275 | unittest.TextTestRunner(verbosity=2).run(suite) |
The ubuntu one team would like to contribute the S4 (Simple Storage Service Simulator) code that we use as a stub to test against when developing software that relies on S3.