Browse code

* s3cmd, S3/AccessLog.py, ...: Added [accesslog] command.

git-svn-id: https://s3tools.svn.sourceforge.net/svnroot/s3tools/s3cmd/trunk@402 830e0280-6d2a-0410-9c65-932aecc39d9d

Michal Ludvig authored on 2010/03/19 12:18:18
Showing 9 changed files
... ...
@@ -1,3 +1,7 @@
1
+2010-03-19  Michal Ludvig  <mludvig@logix.net.nz>
2
+
3
+	* s3cmd, S3/AccessLog.py, ...: Added [accesslog] command.
4
+
1 5
 2009-12-10  Michal Ludvig  <mludvig@logix.net.nz>
2 6
 
3 7
 	* s3cmd: Path separator conversion on Windows hosts.
... ...
@@ -1,3 +1,7 @@
1
+s3cmd 0.9.9.92 -  ???
2
+==============
3
+* Added [accesslog] command. (needs manpage!)
4
+
1 5
 s3cmd 0.9.9.91 -  2009-10-08
2 6
 ==============
3 7
 * Fixed invalid reference to a variable in failed upload handling.
... ...
@@ -3,7 +3,7 @@
3 3
 ##         http://www.logix.cz/michal
4 4
 ## License: GPL Version 2
5 5
 
6
-from Utils import *
6
+from Utils import getTreeFromXml
7 7
 
8 8
 try:
9 9
 	import xml.etree.ElementTree as ET
... ...
@@ -12,6 +12,7 @@ except ImportError:
12 12
 
13 13
 class Grantee(object):
14 14
 	ALL_USERS_URI = "http://acs.amazonaws.com/groups/global/AllUsers"
15
+	LOG_DELIVERY_URI = "http://acs.amazonaws.com/groups/s3/LogDelivery"
15 16
 
16 17
 	def __init__(self):
17 18
 		self.xsi_type = None
... ...
@@ -53,6 +54,17 @@ class GranteeAnonRead(Grantee):
53 53
 		self.name = Grantee.ALL_USERS_URI
54 54
 		self.permission = "READ"
55 55
 
56
+class GranteeLogDelivery(Grantee):
57
+	def __init__(self, permission):
58
+		"""
59
+		permission must be either READ_ACP or WRITE
60
+		"""
61
+		Grantee.__init__(self)
62
+		self.xsi_type = "Group"
63
+		self.tag = "URI"
64
+		self.name = Grantee.LOG_DELIVERY_URI
65
+		self.permission = permission
66
+
56 67
 class ACL(object):
57 68
 	EMPTY_ACL = "<AccessControlPolicy><Owner><ID></ID></Owner><AccessControlList></AccessControlList></AccessControlPolicy>"
58 69
 
... ...
@@ -109,11 +121,14 @@ class ACL(object):
109 109
 	
110 110
 	def grantAnonRead(self):
111 111
 		if not self.isAnonRead():
112
-			self.grantees.append(GranteeAnonRead())
112
+			self.appendGrantee(GranteeAnonRead())
113 113
 	
114 114
 	def revokeAnonRead(self):
115 115
 		self.grantees = [g for g in self.grantees if not g.isAnonRead()]
116 116
 
117
+	def appendGrantee(self, grantee):
118
+		self.grantees.append(grantee)
119
+
117 120
 	def __str__(self):
118 121
 		tree = getTreeFromXml(ACL.EMPTY_ACL)
119 122
 		tree.attrib['xmlns'] = "http://s3.amazonaws.com/doc/2006-03-01/"
120 123
new file mode 100644
... ...
@@ -0,0 +1,90 @@
0
+## Amazon S3 - Access Control List representation
1
+## Author: Michal Ludvig <michal@logix.cz>
2
+##         http://www.logix.cz/michal
3
+## License: GPL Version 2
4
+
5
+import S3Uri
6
+from Exceptions import ParameterError
7
+from Utils import getTreeFromXml
8
+from ACL import GranteeAnonRead
9
+
10
+try:
11
+	import xml.etree.ElementTree as ET
12
+except ImportError:
13
+	import elementtree.ElementTree as ET
14
+
15
+__all__ = []
16
+class AccessLog(object):
17
+	LOG_DISABLED = "<BucketLoggingStatus></BucketLoggingStatus>"
18
+	LOG_TEMPLATE = "<LoggingEnabled><TargetBucket></TargetBucket><TargetPrefix></TargetPrefix></LoggingEnabled>"
19
+
20
+	def __init__(self, xml = None):
21
+		if not xml:
22
+			xml = self.LOG_DISABLED
23
+		self.tree = getTreeFromXml(xml)
24
+		self.tree.attrib['xmlns'] = "http://doc.s3.amazonaws.com/2006-03-01"
25
+	
26
+	def isLoggingEnabled(self):
27
+		return bool(self.tree.find(".//LoggingEnabled"))
28
+
29
+	def disableLogging(self):
30
+		el = self.tree.find(".//LoggingEnabled")
31
+		if el:
32
+			self.tree.remove(el)
33
+	
34
+	def enableLogging(self, target_prefix_uri):
35
+		el = self.tree.find(".//LoggingEnabled")
36
+		if not el:
37
+			el = getTreeFromXml(self.LOG_TEMPLATE)
38
+			self.tree.append(el)
39
+		el.find(".//TargetBucket").text = target_prefix_uri.bucket()
40
+		el.find(".//TargetPrefix").text = target_prefix_uri.object()
41
+
42
+	def targetPrefix(self):
43
+		if self.isLoggingEnabled():
44
+			el = self.tree.find(".//LoggingEnabled")
45
+			target_prefix = "s3://%s/%s" % (
46
+				self.tree.find(".//LoggingEnabled//TargetBucket").text, 
47
+				self.tree.find(".//LoggingEnabled//TargetPrefix").text)
48
+			return S3Uri.S3Uri(target_prefix)
49
+		else:
50
+			return ""
51
+
52
+	def setAclPublic(self, acl_public):
53
+		le = self.tree.find(".//LoggingEnabled")
54
+		if not le:
55
+			raise ParameterError("Logging not enabled, can't set default ACL for logs")
56
+		tg = le.find(".//TargetGrants")
57
+		if not acl_public:
58
+			if not tg:
59
+				## All good, it's not been there
60
+				return
61
+			else:
62
+				le.remove(tg)
63
+		else: # acl_public == True
64
+			anon_read = GranteeAnonRead().getElement()
65
+			if not tg:
66
+				tg = ET.SubElement(le, "TargetGrants")
67
+			## What if TargetGrants already exists? We should check if 
68
+			## AnonRead is there before appending a new one. Later...
69
+			tg.append(anon_read)
70
+
71
+	def isAclPublic(self):
72
+		raise NotImplementedError()
73
+
74
+	def __str__(self):
75
+		return ET.tostring(self.tree)
76
+__all__.append("AccessLog")
77
+
78
+if __name__ == "__main__":
79
+	from S3Uri import S3Uri
80
+	log = AccessLog()
81
+	print log
82
+	log.enableLogging(S3Uri("s3://targetbucket/prefix/log-"))
83
+	print log
84
+	log.setAclPublic(True)
85
+	print log
86
+	log.setAclPublic(False)
87
+	print log
88
+	log.disableLogging()
89
+	print log
... ...
@@ -29,6 +29,7 @@ class Config(object):
29 29
 	human_readable_sizes = False
30 30
 	extra_headers = SortedDict(ignore_case = True)
31 31
 	force = False
32
+	enable = None
32 33
 	get_continue = False
33 34
 	skip_existing = False
34 35
 	recursive = False
... ...
@@ -69,6 +70,7 @@ class Config(object):
69 69
 	debug_include = {}
70 70
 	encoding = "utf-8"
71 71
 	urlencoding_mode = "normal"
72
+	log_target_prefix = ""
72 73
 
73 74
 	## Creating a singleton
74 75
 	def __new__(self, configfile = None):
... ...
@@ -9,6 +9,7 @@ import time
9 9
 import httplib
10 10
 import logging
11 11
 import mimetypes
12
+import re
12 13
 from logging import debug, info, warning, error
13 14
 from stat import ST_SIZE
14 15
 
... ...
@@ -22,8 +23,11 @@ from SortedDict import SortedDict
22 22
 from BidirMap import BidirMap
23 23
 from Config import Config
24 24
 from Exceptions import *
25
-from ACL import ACL
25
+from ACL import ACL, GranteeLogDelivery
26
+from AccessLog import AccessLog
27
+from S3Uri import S3Uri
26 28
 
29
+__all__ = []
27 30
 class S3Request(object):
28 31
 	def __init__(self, s3, method_string, resource, headers, params = {}):
29 32
 		self.s3 = s3
... ...
@@ -322,6 +326,41 @@ class S3(object):
322 322
 		response = self.send_request(request, body)
323 323
 		return response
324 324
 
325
+	def get_accesslog(self, uri):
326
+		request = self.create_request("BUCKET_LIST", bucket = uri.bucket(), extra = "?logging")
327
+		response = self.send_request(request)
328
+		accesslog = AccessLog(response['data'])
329
+		return accesslog
330
+
331
+	def set_accesslog_acl(self, uri):
332
+		acl = self.get_acl(uri)
333
+		debug("Current ACL(%s): %s" % (uri.uri(), str(acl)))
334
+		acl.appendGrantee(GranteeLogDelivery("READ_ACP"))
335
+		acl.appendGrantee(GranteeLogDelivery("WRITE"))
336
+		debug("Updated ACL(%s): %s" % (uri.uri(), str(acl)))
337
+		self.set_acl(uri, acl)
338
+
339
+	def set_accesslog(self, uri, enable, log_target_prefix_uri = None, acl_public = False):
340
+		request = self.create_request("BUCKET_CREATE", bucket = uri.bucket(), extra = "?logging")
341
+		accesslog = AccessLog()
342
+		if enable:
343
+			accesslog.enableLogging(log_target_prefix_uri)
344
+			accesslog.setAclPublic(acl_public)
345
+		else:
346
+			accesslog.disableLogging()
347
+		body = str(accesslog)
348
+		debug(u"set_accesslog(%s): accesslog-xml: %s" % (uri, body))
349
+		try:
350
+			response = self.send_request(request, body)
351
+		except S3Error, e:
352
+			if e.info['Code'] == "InvalidTargetBucketForLogging":
353
+				info("Setting up log-delivery ACL for target bucket.")
354
+				self.set_accesslog_acl(S3Uri("s3://%s" % log_target_prefix_uri.bucket()))
355
+				response = self.send_request(request, body)
356
+			else:
357
+				raise
358
+		return accesslog, response
359
+
325 360
 	## Low level methods
326 361
 	def urlencode_string(self, string, urlencoding_mode = None):
327 362
 		if type(string) == unicode:
... ...
@@ -720,3 +759,4 @@ class S3(object):
720 720
 			return S3.check_bucket_name(bucket, dns_strict = True)
721 721
 		except ParameterError:
722 722
 			return False
723
+__all__.append("S3")
... ...
@@ -8,7 +8,7 @@ import re
8 8
 import sys
9 9
 from BidirMap import BidirMap
10 10
 from logging import debug
11
-from S3 import S3
11
+import S3
12 12
 from Utils import unicodise
13 13
 
14 14
 class S3Uri(object):
... ...
@@ -73,7 +73,7 @@ class S3UriS3(S3Uri):
73 73
 		return "/".join(["s3:/", self._bucket, self._object])
74 74
 	
75 75
 	def is_dns_compatible(self):
76
-		return S3.check_bucket_name_dns_conformity(self._bucket)
76
+		return S3.S3.check_bucket_name_dns_conformity(self._bucket)
77 77
 
78 78
 	def public_url(self):
79 79
 		if self.is_dns_compatible():
... ...
@@ -29,6 +29,7 @@ except ImportError:
29 29
 	import elementtree.ElementTree as ET
30 30
 from xml.parsers.expat import ExpatError
31 31
 
32
+__all__ = []
32 33
 def parseNodes(nodes):
33 34
 	## WARNING: Ignores text nodes from mixed xml/text.
34 35
 	## For instance <tag1>some text<tag2>other text</tag2></tag1>
... ...
@@ -44,6 +45,7 @@ def parseNodes(nodes):
44 44
 				retval_item[name] = node.findtext(".//%s" % child.tag)
45 45
 		retval.append(retval_item)
46 46
 	return retval
47
+__all__.append("parseNodes")
47 48
 
48 49
 def stripNameSpace(xml):
49 50
 	"""
... ...
@@ -56,6 +58,7 @@ def stripNameSpace(xml):
56 56
 	else:
57 57
 		xmlns = None
58 58
 	return xml, xmlns
59
+__all__.append("stripNameSpace")
59 60
 
60 61
 def getTreeFromXml(xml):
61 62
 	xml, xmlns = stripNameSpace(xml)
... ...
@@ -67,11 +70,13 @@ def getTreeFromXml(xml):
67 67
 	except ExpatError, e:
68 68
 		error(e)
69 69
 		raise Exceptions.ParameterError("Bucket contains invalid filenames. Please run: s3cmd fixbucket s3://your-bucket/")
70
+__all__.append("getTreeFromXml")
70 71
 	
71 72
 def getListFromXml(xml, node):
72 73
 	tree = getTreeFromXml(xml)
73 74
 	nodes = tree.findall('.//%s' % (node))
74 75
 	return parseNodes(nodes)
76
+__all__.append("getListFromXml")
75 77
 
76 78
 def getDictFromTree(tree):
77 79
 	ret_dict = {}
... ...
@@ -86,6 +91,7 @@ def getDictFromTree(tree):
86 86
 		else:
87 87
 			ret_dict[child.tag] = child.text or ""
88 88
 	return ret_dict
89
+__all__.append("getDictFromTree")
89 90
 
90 91
 def getTextFromXml(xml, xpath):
91 92
 	tree = getTreeFromXml(xml)
... ...
@@ -93,15 +99,18 @@ def getTextFromXml(xml, xpath):
93 93
 		return tree.text
94 94
 	else:
95 95
 		return tree.findtext(xpath)
96
+__all__.append("getTextFromXml")
96 97
 
97 98
 def getRootTagName(xml):
98 99
 	tree = getTreeFromXml(xml)
99 100
 	return tree.tag
101
+__all__.append("getRootTagName")
100 102
 
101 103
 def xmlTextNode(tag_name, text):
102 104
 	el = ET.Element(tag_name)
103 105
 	el.text = unicode(text)
104 106
 	return el
107
+__all__.append("xmlTextNode")
105 108
 
106 109
 def appendXmlTextNode(tag_name, text, parent):
107 110
 	"""
... ...
@@ -111,22 +120,27 @@ def appendXmlTextNode(tag_name, text, parent):
111 111
 	Returns the newly created Node.
112 112
 	"""
113 113
 	parent.append(xmlTextNode(tag_name, text))
114
+__all__.append("appendXmlTextNode")
114 115
 
115 116
 def dateS3toPython(date):
116 117
 	date = re.compile("(\.\d*)?Z").sub(".000Z", date)
117 118
 	return time.strptime(date, "%Y-%m-%dT%H:%M:%S.000Z")
119
+__all__.append("dateS3toPython")
118 120
 
119 121
 def dateS3toUnix(date):
120 122
 	## FIXME: This should be timezone-aware.
121 123
 	## Currently the argument to strptime() is GMT but mktime() 
122 124
 	## treats it as "localtime". Anyway...
123 125
 	return time.mktime(dateS3toPython(date))
126
+__all__.append("dateS3toUnix")
124 127
 
125 128
 def dateRFC822toPython(date):
126 129
 	return rfc822.parsedate(date)
130
+__all__.append("dateRFC822toPython")
127 131
 
128 132
 def dateRFC822toUnix(date):
129 133
 	return time.mktime(dateRFC822toPython(date))
134
+__all__.append("dateRFC822toUnix")
130 135
 
131 136
 def formatSize(size, human_readable = False, floating_point = False):
132 137
 	size = floating_point and float(size) or int(size)
... ...
@@ -139,16 +153,18 @@ def formatSize(size, human_readable = False, floating_point = False):
139 139
 		return (size, coeff)
140 140
 	else:
141 141
 		return (size, "")
142
+__all__.append("formatSize")
142 143
 
143 144
 def formatDateTime(s3timestamp):
144 145
 	return time.strftime("%Y-%m-%d %H:%M", dateS3toPython(s3timestamp))
146
+__all__.append("formatDateTime")
145 147
 
146 148
 def convertTupleListToDict(list):
147 149
 	retval = {}
148 150
 	for tuple in list:
149 151
 		retval[tuple[0]] = tuple[1]
150 152
 	return retval
151
-
153
+__all__.append("convertTupleListToDict")
152 154
 
153 155
 _rnd_chars = string.ascii_letters+string.digits
154 156
 _rnd_chars_len = len(_rnd_chars)
... ...
@@ -158,6 +174,7 @@ def rndstr(len):
158 158
 		retval += _rnd_chars[random.randint(0, _rnd_chars_len-1)]
159 159
 		len -= 1
160 160
 	return retval
161
+__all__.append("rndstr")
161 162
 
162 163
 def mktmpsomething(prefix, randchars, createfunc):
163 164
 	old_umask = os.umask(0077)
... ...
@@ -175,13 +192,16 @@ def mktmpsomething(prefix, randchars, createfunc):
175 175
 
176 176
 	os.umask(old_umask)
177 177
 	return dirname
178
+__all__.append("mktmpsomething")
178 179
 
179 180
 def mktmpdir(prefix = "/tmp/tmpdir-", randchars = 10):
180 181
 	return mktmpsomething(prefix, randchars, os.mkdir)
182
+__all__.append("mktmpdir")
181 183
 
182 184
 def mktmpfile(prefix = "/tmp/tmpfile-", randchars = 20):
183 185
 	createfunc = lambda filename : os.close(os.open(filename, os.O_CREAT | os.O_EXCL))
184 186
 	return mktmpsomething(prefix, randchars, createfunc)
187
+__all__.append("mktmpfile")
185 188
 
186 189
 def hash_file_md5(filename):
187 190
 	h = md5()
... ...
@@ -194,6 +214,7 @@ def hash_file_md5(filename):
194 194
 		h.update(data)
195 195
 	f.close()
196 196
 	return h.hexdigest()
197
+__all__.append("hash_file_md5")
197 198
 
198 199
 def mkdir_with_parents(dir_name):
199 200
 	"""
... ...
@@ -220,6 +241,7 @@ def mkdir_with_parents(dir_name):
220 220
 			warning("%s: %s" % (cur_dir, e))
221 221
 			return False
222 222
 	return True
223
+__all__.append("mkdir_with_parents")
223 224
 
224 225
 def unicodise(string, encoding = None, errors = "replace"):
225 226
 	"""
... ...
@@ -236,6 +258,7 @@ def unicodise(string, encoding = None, errors = "replace"):
236 236
 		return string.decode(encoding, errors)
237 237
 	except UnicodeDecodeError:
238 238
 		raise UnicodeDecodeError("Conversion to unicode failed: %r" % string)
239
+__all__.append("unicodise")
239 240
 
240 241
 def deunicodise(string, encoding = None, errors = "replace"):
241 242
 	"""
... ...
@@ -253,6 +276,7 @@ def deunicodise(string, encoding = None, errors = "replace"):
253 253
 		return string.encode(encoding, errors)
254 254
 	except UnicodeEncodeError:
255 255
 		raise UnicodeEncodeError("Conversion from unicode failed: %r" % string)
256
+__all__.append("deunicodise")
256 257
 
257 258
 def unicodise_safe(string, encoding = None):
258 259
 	"""
... ...
@@ -261,6 +285,7 @@ def unicodise_safe(string, encoding = None):
261 261
 	"""
262 262
 
263 263
 	return unicodise(deunicodise(string, encoding), encoding).replace(u'\ufffd', '?')
264
+__all__.append("unicodise_safe")
264 265
 
265 266
 def replace_nonprintables(string):
266 267
 	"""
... ...
@@ -284,9 +309,11 @@ def replace_nonprintables(string):
284 284
 	if modified and Config.Config().urlencoding_mode != "fixbucket":
285 285
 		warning("%d non-printable characters replaced in: %s" % (modified, new_string))
286 286
 	return new_string
287
+__all__.append("replace_nonprintables")
287 288
 
288 289
 def sign_string(string_to_sign):
289 290
 	#debug("string_to_sign: %s" % string_to_sign)
290 291
 	signature = base64.encodestring(hmac.new(Config.Config().secret_key, string_to_sign, sha1).digest()).strip()
291 292
 	#debug("signature: %s" % signature)
292 293
 	return signature
294
+__all__.append("sign_string")
... ...
@@ -1122,6 +1122,27 @@ def cmd_setacl(args):
1122 1122
 		if retsponse['status'] == 200:
1123 1123
 			output(u"%s: ACL set to %s  %s" % (uri, set_to_acl, seq_label))
1124 1124
 
1125
+def cmd_accesslog(args):
1126
+	s3 = S3(cfg)
1127
+	bucket_uri = S3Uri(args.pop())
1128
+	if bucket_uri.object():
1129
+		raise ParameterError("Only bucket name is required for [accesslog] command")
1130
+	if cfg.enable == True:
1131
+		log_target_prefix_uri = S3Uri(cfg.log_target_prefix)
1132
+		if log_target_prefix_uri.type != "s3":
1133
+			raise ParameterError("--log-target-prefix must be a S3 URI")
1134
+		accesslog, response = s3.set_accesslog(bucket_uri, enable = True, log_target_prefix_uri = log_target_prefix_uri, acl_public = cfg.acl_public)
1135
+	elif cfg.enable == False:
1136
+		accesslog, response = s3.set_accesslog(bucket_uri, enable = False)
1137
+	else:	# cfg.enable == None
1138
+		accesslog = s3.get_accesslog(bucket_uri)
1139
+
1140
+	output(u"Access logging for: %s" % bucket_uri.uri())
1141
+	output(u"   Logging Enabled: %s" % accesslog.isLoggingEnabled())
1142
+	if accesslog.isLoggingEnabled():
1143
+		output(u"     Target prefix: %s" % accesslog.targetPrefix().uri())
1144
+		#output(u"   Public Access:   %s" % accesslog.isAclPublic())
1145
+		
1125 1146
 def cmd_sign(args):
1126 1147
 	string_to_sign = args.pop()
1127 1148
 	debug("string-to-sign: %r" % string_to_sign)
... ...
@@ -1426,6 +1447,7 @@ def get_commands_list():
1426 1426
 	{"cmd":"cp", "label":"Copy object", "param":"s3://BUCKET1/OBJECT1 s3://BUCKET2[/OBJECT2]", "func":cmd_cp, "argc":2},
1427 1427
 	{"cmd":"mv", "label":"Move object", "param":"s3://BUCKET1/OBJECT1 s3://BUCKET2[/OBJECT2]", "func":cmd_mv, "argc":2},
1428 1428
 	{"cmd":"setacl", "label":"Modify Access control list for Bucket or Files", "param":"s3://BUCKET[/OBJECT]", "func":cmd_setacl, "argc":1},
1429
+	{"cmd":"accesslog", "label":"Enable/disable bucket access logging", "param":"s3://BUCKET", "func":cmd_accesslog, "argc":1},
1429 1430
 	{"cmd":"sign", "label":"Sign arbitrary string using the secret key", "param":"STRING-TO-SIGN", "func":cmd_sign, "argc":1},
1430 1431
 	{"cmd":"fixbucket", "label":"Fix invalid file names in a bucket", "param":"s3://BUCKET[/PREFIX]", "func":cmd_fixbucket, "argc":1},
1431 1432
 
... ...
@@ -1516,6 +1538,8 @@ def main():
1516 1516
 
1517 1517
 	optparser.add_option(      "--bucket-location", dest="bucket_location", help="Datacentre to create bucket in. Either EU or US (default)")
1518 1518
 
1519
+	optparser.add_option(      "--log-target-prefix", dest="log_target_prefix", help="Target prefix for access logs (S3 URI)")
1520
+
1519 1521
 	optparser.add_option("-m", "--mime-type", dest="default_mime_type", type="mimetype", metavar="MIME/TYPE", help="Default MIME-type to be set for objects stored.")
1520 1522
 	optparser.add_option("-M", "--guess-mime-type", dest="guess_mime_type", action="store_true", help="Guess MIME-type of files by their extension. Falls back to default MIME-Type as specified by --mime-type option")
1521 1523
 
... ...
@@ -1529,8 +1553,8 @@ def main():
1529 1529
 
1530 1530
 	optparser.add_option(      "--progress", dest="progress_meter", action="store_true", help="Display progress meter (default on TTY).")
1531 1531
 	optparser.add_option(      "--no-progress", dest="progress_meter", action="store_false", help="Don't display progress meter (default on non-TTY).")
1532
-	optparser.add_option(      "--enable", dest="cf_enable", action="store_true", help="Enable given CloudFront distribution (only for [cfmodify] command)")
1533
-	optparser.add_option(      "--disable", dest="cf_enable", action="store_false", help="Enable given CloudFront distribution (only for [cfmodify] command)")
1532
+	optparser.add_option(      "--enable", dest="enable", action="store_true", help="Enable given CloudFront distribution (for [cfmodify] command) or access logging (for [accesslog] command)")
1533
+	optparser.add_option(      "--disable", dest="enable", action="store_false", help="Enable given CloudFront distribution (only for [cfmodify] command) or access logging (for [accesslog] command)")
1534 1534
 	optparser.add_option(      "--cf-add-cname", dest="cf_cnames_add", action="append", metavar="CNAME", help="Add given CNAME to a CloudFront distribution (only for [cfcreate] and [cfmodify] commands)")
1535 1535
 	optparser.add_option(      "--cf-remove-cname", dest="cf_cnames_remove", action="append", metavar="CNAME", help="Remove given CNAME from a CloudFront distribution (only for [cfmodify] command)")
1536 1536
 	optparser.add_option(      "--cf-comment", dest="cf_comment", action="store", metavar="COMMENT", help="Set COMMENT for a given CloudFront distribution (only for [cfcreate] and [cfmodify] commands)")
... ...
@@ -1617,7 +1641,13 @@ def main():
1617 1617
 		except AttributeError:
1618 1618
 			## Some Config() options are not settable from command line
1619 1619
 			pass
1620
-	
1620
+
1621
+	## Special handling for tri-state options (True, False, None)
1622
+	cfg.update_option("enable", options.enable)
1623
+
1624
+	## CloudFront's cf_enable and Config's enable share the same --enable switch
1625
+	options.cf_enable = options.enable
1626
+
1621 1627
 	## Update CloudFront options if some were set
1622 1628
 	for option in CfCmd.options.option_list():
1623 1629
 		try:
... ...
@@ -1735,9 +1765,9 @@ if __name__ == '__main__':
1735 1735
 		## detect any syntax errors in there
1736 1736
 		from S3.Exceptions import *
1737 1737
 		from S3 import PkgInfo
1738
-		from S3.S3 import *
1738
+		from S3.S3 import S3
1739 1739
 		from S3.Config import Config
1740
-		from S3.S3Uri import *
1740
+		from S3.S3Uri import S3Uri
1741 1741
 		from S3 import Utils
1742 1742
 		from S3.Utils import unicodise
1743 1743
 		from S3.Progress import Progress