├── .gitignore ├── LICENSE ├── README.md ├── bin ├── pf.anchor ├── server └── server-macos ├── metadata ├── __init__.py ├── bottle.py ├── otp.py ├── prompt.py ├── routes.py ├── util.py └── views │ └── manage.tpl └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | server.conf 3 | bin/pf.conf 4 | venv 5 | venv2 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2014 Cory Thomas 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a 5 | copy of this software and associated documentation files (the 6 | "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, 8 | distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so, subject to 10 | the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included 13 | in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 16 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 18 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 19 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 20 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 21 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | aws-mock-metadata 2 | ================= 3 | 4 | Mock EC2 metadata service that can run on a developer machine. 5 | 6 | It is possible in AWS to protect [API access with 7 | MFA](http://docs.aws.amazon.com/IAM/latest/UserGuide/MFAProtectedAPI.html). 8 | However, this is not convenient to setup or use since you must regularly refresh 9 | your credentials. 10 | 11 | Amazon EC2 instances have a metadata service that can be accessed to 12 | retrieve the instance profile credentials. Those credentials are 13 | rotated regularly and have an associated expiration. Most AWS SDKs 14 | check this service to use the instance profile credentials. 15 | 16 | This service runs on a developer machine and masquerades as the 17 | Amazon metadata service. This allows it to integrate with the SDKs 18 | and provide session credentials when needed. The credentials are generated 19 | on demand using the STS GetSessionToken or AssumeRole API. 20 | 21 | There are three effectives modes of operation for the service: 22 | 23 | * If the user has no MFA device setup or MFA is disabled in configuration, 24 | the credentials are generated without a one time pass code. The resulting 25 | credentials should have the same permissions as the user's credentials. 26 | This operation is transparent to the user. 27 | * If the user has a virtual MFA device and has provided the MFA secret, 28 | the pass code is generated by this service and used to create the 29 | credentials. This operation is completely transparent to the user. 30 | * If the user has an MFA device and has not provided the MFA secret, 31 | a small prompt window pops up to enter the OTP code when the service 32 | needs to generate new credentials. This will occur the first time 33 | the service starts up and after previous credentials expire. This 34 | mode can be useful if the user has a hardware keyfob, for testing 35 | the service with an existing virtual MFA, or if the user doesn't 36 | want to store the MFA secret. 37 | 38 | # Dependencies 39 | 40 | This application is still beta and may change in breaking ways in the 41 | future. 42 | 43 | * python 2 44 | * python 3 has not been tested 45 | * boto python library 46 | * this may change to [botocore](https://github.com/boto/botocore), which 47 | will impact config file format 48 | 49 | # Installation 50 | 51 | Currently only works on OSX, but should be trivial to make it work on 52 | other platforms. 53 | 54 | This should be changed to run as a launchd daemon at some point. 55 | 56 | 1. Clone the repo 57 | 2. Add AWS access key and secret key to *server.conf* in the repo 58 | directory 59 | * See Configuration section 60 | * These permanent keys are used to generate the session keys using the MFA token 61 | * There must be an MFA device associated with the account 62 | 3. Run `bin/server-macos` 63 | * Prompts for password to setup an IP alias and firewall forwarding rule. 64 | * You can examine the script and run the commands separately. The 65 | script is only provided for convenience. 66 | 67 | # Configuration 68 | 69 | *SERVICE_HOME/server.conf*
70 | *~/.aws-mock-metadata/config* 71 | 72 | ``` 73 | [aws] 74 | access_key=... # Optional. Access key to generate temp keys with. 75 | # If not provided, the service falls back on boto's 76 | # credential discovery. 77 | secret_key=... # Optional. Secret key to generate temp keys with. 78 | # If not provided, the service falls back on boto's 79 | # credential discovery. 80 | region=us-east-1 # Optional. Not generally necessary since IAM and STS 81 | # are not region specific. Default is us-east-1. 82 | mfa_enabled=yes # Optional. Enable generation of credentials using MFA. 83 | # This option is only used if MFA is enabled on the provided 84 | # account. If disabled, no MFA token is generated or 85 | # prompted for. 86 | mfa_secret=... # Optional. Base32 encoded virtual MFA device secret. 87 | # If set, the metadata server will generate OTP codes 88 | # internally instead of showing a prompt. The secret 89 | # is provided when setting up the virtual mfa device. 90 | 91 | [metadata] 92 | host=169.254.169.254 # Optional. Interface to bind to. Default is 93 | # 169.254.169.254. 94 | port=45000 # Optional. Port to bind to. Default is 45000. 95 | token_duration=43200 # Optional. Timeout, in seconds, for the generated 96 | # keys. Minimum is 15 minutes. 97 | # Default is 12 hours for sts:GetSessionToken and 98 | # 1 hour for sts:AssumeRole. Maximum is 36 hours 99 | # for sts:GetSessionToken and 1 hour for 100 | # sts:AssumeRole. If you specify a value higher 101 | # than the maximum, the maximum is used. 102 | role_arn=arn:aws:iam::123456789012:role/${aws:username} 103 | # Optional. ARN of a role to assume. If specified, 104 | # the metadata server uses sts:AssumeRole to create 105 | # the temporary credentials. Otherwise, the 106 | # metadata server uses sts:GetSessionToken. 107 | # The string '${aws:username}' will be replaced 108 | # with the name of the user requesting the 109 | # credentials. No other variables are currently 110 | # supported. 111 | profile=... # Name of the initial profile to select. Default is 112 | # "default". 113 | 114 | # Define a profile. A profile consists of a set of options 115 | # used to create a session. 116 | # Multiple profiles can be defined and switched on-the-fly. 117 | # Default option values are taken from the [aws] and [metadata] 118 | # sections. 119 | [profile:NAME] # NAME is a string used to identify the profile 120 | access_key=... 121 | secret_key=... 122 | mfa_enabled=... 123 | mfa_secret=... 124 | region=... 125 | token_duration=... 126 | role_arn=... 127 | ``` 128 | 129 | ## AWS CLI 130 | 131 | If you don't provide the MFA secret and use a separate device to generate 132 | OTP codes, it is recommended to update the local metadata service timeout 133 | for the AWS command line interface. This ensures that you have enough time 134 | to enter the MFA token before the request expires and your script can 135 | continue without interruption. 136 | 137 | *~/.aws/config* 138 | 139 | ``` 140 | [default] 141 | metadata_service_timeout = 15.0 # 15 second timeout to enter MFA token 142 | ``` 143 | 144 | # Mock Endpoints 145 | 146 | The following EC2 metadata service endpoints are implemented. 147 | 148 | ``` 149 | 169.254.169.254/latest/meta-data/iam/security-credentials/ 150 | 169.254.169.254/latest/meta-data/iam/security-credentials/local-credentials 151 | ``` 152 | 153 | If the MFA device secret is not provided and 154 | `/latest/meta-data/iam/security-credentials/local-credentials` is 155 | requested and there are no session credentials available, a dialog pops 156 | up prompting for the MFA token. The dialog blocks the request until the 157 | correct token is entered. Once the token is provided, the session 158 | credentials are generated and cached until they expire. Once they 159 | expire, a new token prompt will appear on the next request for 160 | credentials. 161 | 162 | # User Interface 163 | 164 | A simple UI to view, reset, and update the session credentials is 165 | available by loading *http://169.254.169.254/manage* in your 166 | browser. 167 | 168 | # API 169 | 170 | There is an API available to query and update the MFA session 171 | credentials. 172 | 173 | ## Get Profiles 174 | 175 | `GET 169.254.169.254/manage/profiles` 176 | 177 | ### Response: 200 178 | 179 | ```json 180 | { 181 | "profiles": [ 182 | { 183 | "name": "...", 184 | "accessKey": "...", 185 | "region": "...", 186 | "tokenDuration": 123, // Optional 187 | "roleArn": "...", // Optional 188 | "session": { // Optional, if there is no active session for the profile 189 | "accessKey": "....", 190 | "secretKey": "....", 191 | "sessionToken": "...", 192 | "expiration": "2014-04-09T09:00:44Z" 193 | }, 194 | } 195 | ] 196 | } 197 | ``` 198 | 199 | ## Get Profile 200 | 201 | `GET 169.254.169.254/manage/profiles/NAME` 202 | 203 | ### Response: 404 204 | 205 | * Profile does not exist. 206 | 207 | ### Response: 200 208 | 209 | ```json 210 | { 211 | "profile": { 212 | "name": "...", 213 | "accessKey": "...", 214 | "region": "...", 215 | "tokenDuration": 123, // Optional 216 | "roleArn": "...", // Optional 217 | "session": { // Optional, if there is no active session for the profile 218 | "accessKey": "....", 219 | "secretKey": "....", 220 | "sessionToken": "...", 221 | "expiration": "2014-04-09T09:00:44Z" 222 | }, 223 | } 224 | } 225 | ``` 226 | 227 | ## Get Credentials 228 | 229 | `GET 169.254.169.254/manage/session` 230 | 231 | ### Response: 200 232 | 233 | Returns current session information. 234 | 235 | ```json 236 | { 237 | "session": { // Optional, if there is no active session for the profile 238 | "accessKey": "....", 239 | "secretKey": "....", 240 | "sessionToken": "...", 241 | "expiration": "2014-04-09T09:00:44Z" 242 | }, 243 | "profile": { 244 | "accessKey": "...", 245 | "region": "...", 246 | "name": "...", 247 | "tokenDuration": 123, // Optional 248 | "roleArn": "..." // Optional 249 | } 250 | } 251 | ``` 252 | 253 | ## Clear Credentials 254 | 255 | `DELETE 169.254.169.254/manage/session` 256 | 257 | ### Response: 200 258 | 259 | Session credentials were cleared or no session exists. 260 | 261 | ## Create Credentials 262 | 263 | `POST 169.254.169.254/manage/session` 264 | 265 | Create a new session, change the current profile, or both. 266 | 267 | Examples: 268 | ```bash 269 | curl -X POST -d token=123456 169.254.169.254/manage/session 270 | curl -X POST -d session=other 169.254.169.254/manage/session 271 | curl -X POST -d 'token=123456&session=other' 169.254.169.254/manage/session 272 | ``` 273 | 274 | ### Body 275 | 276 | *Content-Type: application/x-www-form-urlencoded* 277 | 278 | ``` 279 | token=123456&session=other 280 | ``` 281 | 282 | *Content-Type: application/json* 283 | 284 | ```json 285 | { 286 | "token": "123456", 287 | "session": "other" 288 | } 289 | ``` 290 | 291 | ### Response: 400 292 | 293 | * Invalid MFA token string format. Must be 6 digits. 294 | * The specified profile name does not exist. 295 | * Neither profile nor token was specified. 296 | 297 | ### Response: 403 298 | 299 | * Specified MFA token does not match expected token for the MFA device. 300 | 301 | ### Response: 200 302 | 303 | Session was created successfully and/or profile the was changed. Returns the 304 | session information. The response will contain a session if a valid token was 305 | provided. The response will not contain a session if only a new profile name was 306 | specified and the profile does not have an existing session. 307 | 308 | ```json 309 | { 310 | "session": { // Optional, if there is no active session for the profile 311 | "accessKey": "....", 312 | "secretKey": "....", 313 | "sessionToken": "...", 314 | "expiration": "2014-04-09T09:00:44Z" 315 | }, 316 | "profile": { 317 | "accessKey": "...", 318 | "region": "...", 319 | "name": "...", 320 | "tokenDuration": 123, // Optional 321 | "roleArn": "..." // Optional 322 | } 323 | } 324 | ``` 325 | # License 326 | 327 | The MIT License (MIT) 328 | Copyright (c) 2014 Cory Thomas 329 | 330 | See [LICENSE](LICENSE) 331 | -------------------------------------------------------------------------------- /bin/pf.anchor: -------------------------------------------------------------------------------- 1 | rdr pass on lo0 inet proto tcp from any to 169.254.169.254 port 80 -> 169.254.169.254 port 45000 2 | -------------------------------------------------------------------------------- /bin/server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import absolute_import, print_function 4 | 5 | import argparse 6 | from os import path 7 | import sys 8 | import re 9 | 10 | APP_DIR = path.abspath(path.join(path.dirname(__file__), '..')) 11 | DEFAULT_CONFIG_FILE = path.relpath(path.join(APP_DIR, 'server.conf')) 12 | sys.path.append(APP_DIR) 13 | 14 | from metadata import bottle, routes, Metadata, Profile 15 | 16 | 17 | bottle.TEMPLATE_PATH.append(path.join(APP_DIR, 'metadata/views')) 18 | 19 | 20 | def port(value): 21 | value = int(value) 22 | 23 | if value < 0 or value > 65535: 24 | raise argparse.ArgumentTypeError( 25 | "invalid port value: {}: value must be 0-65535".format(value)) 26 | 27 | return value 28 | 29 | 30 | def existing_file(value): 31 | if not path.isfile(value): 32 | raise argparse.ArgumentTypeError( 33 | 'file does not exist: {}'.format(value)) 34 | 35 | return value 36 | 37 | 38 | def to_int(value): 39 | return value if value is None else int(value) 40 | 41 | 42 | def to_bool(value, default=False): 43 | if value is None: 44 | return default 45 | 46 | return value.lower() in ('yes', 'true', 'y', 't', '1') 47 | 48 | 49 | def parse_profiles(config): 50 | prog = re.compile('^profile:([^\.]+)\.(.*)$') 51 | profiles = {} 52 | region = app.config.get('aws.region', 'us-east-1') 53 | access_key = app.config.get('aws.access_key') 54 | secret_key = app.config.get('aws.secret_key') 55 | mfa_enabled = app.config.get('aws.mfa_enabled') 56 | mfa_secret = app.config.get('aws.mfa_secret') 57 | token_duration = app.config.get('metadata.token_duration') 58 | role_arn = app.config.get('metadata.role_arn') 59 | 60 | for key, value in config.items(): 61 | res = prog.match(key) 62 | 63 | if res: 64 | profiles.setdefault(res.group(1), {})[res.group(2)] = value 65 | 66 | profiles.setdefault('default', {}) 67 | 68 | for profile in profiles.values(): 69 | profile.setdefault('region', region) 70 | profile.setdefault('access_key', access_key) 71 | profile.setdefault('secret_key', secret_key) 72 | profile['token_duration'] = to_int( 73 | profile.setdefault('token_duration', token_duration)) 74 | profile.setdefault('role_arn', role_arn) 75 | profile.setdefault('mfa_secret', mfa_secret) 76 | profile['mfa_enabled'] = to_bool(profile.setdefault( 77 | 'mfa_enabled', mfa_enabled), default=True) 78 | 79 | result = {} 80 | for name, values in profiles.items(): 81 | try: 82 | result[name] = Profile(**values) 83 | except Exception as ex: 84 | raise Exception("Error loading profile {}".format(name), ex) 85 | 86 | return result 87 | 88 | if __name__ == '__main__': 89 | parser = argparse.ArgumentParser() 90 | 91 | parser.add_argument( 92 | '--config', 93 | metavar='FILE', 94 | type=existing_file, 95 | help='configuration file (default: {})'.format(DEFAULT_CONFIG_FILE)) 96 | 97 | parser.add_argument( 98 | '--host', 99 | help=('interface to bind the metadata server to ' 100 | '(default: 169.254.169.254)')) 101 | 102 | parser.add_argument( 103 | '--port', 104 | type=port, 105 | help='port to bind the metadata server to (default: 45000)') 106 | 107 | parser.add_argument( 108 | '--profile', 109 | help='name of the profile to load by default') 110 | 111 | args = parser.parse_args() 112 | 113 | app = bottle.default_app() 114 | app.config.load_config(args.config or DEFAULT_CONFIG_FILE) 115 | 116 | user_config = path.join(path.expanduser('~'), '.aws-mock-metadata/config') 117 | 118 | if path.isfile(user_config): 119 | app.config.load_config(user_config) 120 | 121 | profile_name = args.profile or\ 122 | app.config.get('metadata.profile', 'default') 123 | 124 | app.config.meta_set( 125 | 'metadata', 126 | 'obj', 127 | Metadata(parse_profiles(app.config), profile_name)) 128 | 129 | app.run( 130 | host=args.host or app.config.get('metadata.host', '169.254.169.254'), 131 | port=args.port or int(app.config.get('metadata.port', 45000))) 132 | -------------------------------------------------------------------------------- /bin/server-macos: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | APP_HOST=169.254.169.254 4 | APP_PORT=45000 5 | FW_RULE_NUM=59 6 | 7 | echo "Adding loopback alias ${APP_HOST}" 8 | sudo ifconfig lo0 alias ${APP_HOST} 9 | 10 | echo "Redirecting ${APP_HOST} port 80 => ${APP_PORT}" 11 | if which ipfw > /dev/null; then 12 | sudo ipfw add ${FW_RULE_NUM} fwd ${APP_HOST},${APP_PORT} tcp from any to ${APP_HOST} 80 in 13 | else 14 | SCRIPT_DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) 15 | cat < ${SCRIPT_DIR}/pf.conf 16 | rdr-anchor "forwarding" 17 | load anchor "forwarding" from "${SCRIPT_DIR}/pf.anchor" 18 | 19 | EOF 20 | pfctlOutput=`sudo pfctl -Ef "${SCRIPT_DIR}/pf.conf" 2>&1` 21 | if [[ "$?" != "0" ]]; then 22 | echo "Unable to setup port forwarding:\n$pfctlOutput"; 23 | fi 24 | TOKEN=`echo ${pfctlOutput} | sed 's/.*Token : //'` 25 | fi 26 | 27 | echo "Running AWS mock metadata service" 28 | $(dirname $0)/server --host ${APP_HOST} --port ${APP_PORT} "${@}" 29 | 30 | echo 31 | echo "Removing redirect ${APP_HOST} port 80 => ${APP_PORT}" 32 | if which ipfw > /dev/null; then 33 | sudo ipfw delete ${FW_RULE_NUM} 34 | else 35 | pfctlOutput=`sudo pfctl -X ${TOKEN} 2>&1` 36 | if [[ "$?" != "0" ]]; then 37 | echo "Unable to disable port forwarding: \n $pfctlOutput" 38 | fi 39 | fi 40 | 41 | echo "Removing loopback alias ${APP_HOST}" 42 | sudo ifconfig lo0 -alias ${APP_HOST} 43 | -------------------------------------------------------------------------------- /metadata/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from datetime import datetime 4 | import boto.iam 5 | import boto.sts 6 | import subprocess 7 | import os.path 8 | from metadata.util import cache, first_item, get_value 9 | from metadata import otp 10 | 11 | class NoSuchProfileError(Exception): 12 | def __init__(self, name): 13 | super(NoSuchProfileError, self).__init__( 14 | 'Profile {} not found.'.format(name)) 15 | 16 | 17 | class Metadata(object): 18 | def __init__(self, profiles, profile_name): 19 | self.profiles = profiles 20 | self.profile_name = profile_name 21 | 22 | @property 23 | def profile_name(self): 24 | return self.__profile_name 25 | 26 | @profile_name.setter 27 | def profile_name(self, name): 28 | if name not in self.profiles: 29 | raise NoSuchProfileError(name) 30 | 31 | self.__profile_name = name 32 | 33 | @property 34 | def profile(self): 35 | return self.profiles[self.profile_name] 36 | 37 | # Replace uses of the following methods with metadata.profile.x 38 | 39 | @property 40 | def session(self): 41 | return self.profile.session 42 | 43 | @property 44 | def session_expired(self): 45 | return self.profile.session_expired 46 | 47 | def clear_session(self, *args, **kw): 48 | return self.profile.clear_session(*args, **kw) 49 | 50 | def get_session(self, *args, **kw): 51 | return self.profile.get_session(*args, **kw) 52 | 53 | 54 | class Profile(object): 55 | def __init__(self, region='us-east-1', access_key=None, secret_key=None, 56 | token_duration=None, role_arn=None, mfa_secret=None, mfa_enabled=True): 57 | self.region = region 58 | self.access_key = access_key 59 | self.secret_key = secret_key 60 | self.token_duration = token_duration 61 | self.role_arn = role_arn 62 | self.mfa_enabled = mfa_enabled 63 | self.otp = otp.Totp(mfa_secret) if mfa_secret is not None else None 64 | 65 | self.user = None 66 | self.session = None 67 | self.session_expiration = datetime.min 68 | self.mfa_serial_number = None 69 | 70 | self._load_user() 71 | 72 | if role_arn: 73 | self.role_arn = role_arn.replace('${aws:username}', 74 | self.user.user_name) 75 | 76 | @cache 77 | def iam(self): 78 | return boto.iam.connect_to_region( 79 | self.region, 80 | aws_access_key_id=self.access_key, 81 | aws_secret_access_key=self.secret_key) 82 | 83 | @cache 84 | def sts(self): 85 | return boto.sts.connect_to_region( 86 | self.region, 87 | aws_access_key_id=self.access_key, 88 | aws_secret_access_key=self.secret_key) 89 | 90 | @property 91 | def session_expired(self): 92 | return datetime.utcnow() > self.session_expiration 93 | 94 | def _load_user(self): 95 | self.user = get_value( 96 | self.iam.get_user(), 97 | 'get_user_response', 98 | 'get_user_result', 99 | 'user') 100 | 101 | if self.mfa_enabled: 102 | self._load_mfa_device() 103 | 104 | def _load_mfa_device(self): 105 | mfa_device = first_item(get_value( 106 | self.iam.get_all_mfa_devices(self.user.user_name), 107 | 'list_mfa_devices_response', 108 | 'list_mfa_devices_result', 109 | 'mfa_devices')) 110 | 111 | self.mfa_serial_number = mfa_device.serial_number\ 112 | if mfa_device\ 113 | else None 114 | 115 | def _prompt_token(self): 116 | if self.otp: 117 | return str(self.otp.generate()) 118 | else: 119 | script = os.path.join(os.path.dirname(__file__), 'prompt.py') 120 | return subprocess.check_output(['/usr/bin/python', script]).strip() 121 | 122 | def _create_session(self, token_value): 123 | if self.role_arn: 124 | role = self.sts.assume_role( 125 | role_arn=self.role_arn, 126 | role_session_name='some_session_id', 127 | duration_seconds=self.token_duration, 128 | mfa_serial_number=self.mfa_serial_number, 129 | mfa_token=token_value if self.mfa_serial_number else None) 130 | 131 | return role.credentials 132 | else: 133 | return self.sts.get_session_token( 134 | duration=self.token_duration, 135 | force_new=True, 136 | mfa_serial_number=self.mfa_serial_number, 137 | mfa_token=token_value if self.mfa_serial_number else None) 138 | 139 | def clear_session(self): 140 | self.session = None 141 | self.session_expiration = datetime.min 142 | 143 | def get_session(self, token_value=None): 144 | if self.session_expired or token_value: 145 | try: 146 | while self.mfa_serial_number and not token_value: 147 | token_value = self._prompt_token() 148 | 149 | self.session = self._create_session(token_value) 150 | 151 | # Needs testing, but might be worth subtracting X seconds 152 | # to prevent returning outdated session token. SDKs and AWS 153 | # may already handle this, so it may not be worth it. 154 | self.session_expiration = datetime.strptime( 155 | self.session.expiration, 156 | '%Y-%m-%dT%H:%M:%SZ') 157 | except: 158 | self.clear_session() 159 | raise 160 | 161 | return self.session 162 | -------------------------------------------------------------------------------- /metadata/bottle.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Bottle is a fast and simple micro-framework for small web applications. It 5 | offers request dispatching (Routes) with url parameter support, templates, 6 | a built-in HTTP Server and adapters for many third party WSGI/HTTP-server and 7 | template engines - all in a single file and with no dependencies other than the 8 | Python Standard Library. 9 | 10 | Homepage and documentation: http://bottlepy.org/ 11 | 12 | Copyright (c) 2014, Marcel Hellkamp. 13 | License: MIT (see LICENSE for details) 14 | """ 15 | 16 | from __future__ import with_statement 17 | 18 | __author__ = 'Marcel Hellkamp' 19 | __version__ = '0.13-dev' 20 | __license__ = 'MIT' 21 | 22 | # The gevent server adapter needs to patch some modules before they are imported 23 | # This is why we parse the commandline parameters here but handle them later 24 | if __name__ == '__main__': 25 | from optparse import OptionParser 26 | _cmd_parser = OptionParser(usage="usage: %prog [options] package.module:app") 27 | _opt = _cmd_parser.add_option 28 | _opt("--version", action="store_true", help="show version number.") 29 | _opt("-b", "--bind", metavar="ADDRESS", help="bind socket to ADDRESS.") 30 | _opt("-s", "--server", default='wsgiref', help="use SERVER as backend.") 31 | _opt("-p", "--plugin", action="append", help="install additional plugin/s.") 32 | _opt("--debug", action="store_true", help="start server in debug mode.") 33 | _opt("--reload", action="store_true", help="auto-reload on file changes.") 34 | _cmd_options, _cmd_args = _cmd_parser.parse_args() 35 | if _cmd_options.server and _cmd_options.server.startswith('gevent'): 36 | import gevent.monkey; gevent.monkey.patch_all() 37 | 38 | import base64, cgi, email.utils, functools, hmac, imp, itertools, mimetypes,\ 39 | os, re, subprocess, sys, tempfile, threading, time, warnings 40 | 41 | from datetime import date as datedate, datetime, timedelta 42 | from tempfile import TemporaryFile 43 | from traceback import format_exc, print_exc 44 | from inspect import getargspec 45 | from unicodedata import normalize 46 | 47 | 48 | try: from simplejson import dumps as json_dumps, loads as json_lds 49 | except ImportError: # pragma: no cover 50 | try: from json import dumps as json_dumps, loads as json_lds 51 | except ImportError: 52 | try: from django.utils.simplejson import dumps as json_dumps, loads as json_lds 53 | except ImportError: 54 | def json_dumps(data): 55 | raise ImportError("JSON support requires Python 2.6 or simplejson.") 56 | json_lds = json_dumps 57 | 58 | 59 | 60 | # We now try to fix 2.5/2.6/3.1/3.2 incompatibilities. 61 | # It ain't pretty but it works... Sorry for the mess. 62 | 63 | py = sys.version_info 64 | py3k = py >= (3, 0, 0) 65 | py25 = py < (2, 6, 0) 66 | py31 = (3, 1, 0) <= py < (3, 2, 0) 67 | 68 | # Workaround for the missing "as" keyword in py3k. 69 | def _e(): return sys.exc_info()[1] 70 | 71 | # Workaround for the "print is a keyword/function" Python 2/3 dilemma 72 | # and a fallback for mod_wsgi (resticts stdout/err attribute access) 73 | try: 74 | _stdout, _stderr = sys.stdout.write, sys.stderr.write 75 | except IOError: 76 | _stdout = lambda x: sys.stdout.write(x) 77 | _stderr = lambda x: sys.stderr.write(x) 78 | 79 | # Lots of stdlib and builtin differences. 80 | if py3k: 81 | import http.client as httplib 82 | import _thread as thread 83 | from urllib.parse import urljoin, SplitResult as UrlSplitResult 84 | from urllib.parse import urlencode, quote as urlquote, unquote as urlunquote 85 | urlunquote = functools.partial(urlunquote, encoding='latin1') 86 | from http.cookies import SimpleCookie 87 | from collections import MutableMapping as DictMixin 88 | import pickle 89 | from io import BytesIO 90 | from configparser import ConfigParser 91 | basestring = str 92 | unicode = str 93 | json_loads = lambda s: json_lds(touni(s)) 94 | callable = lambda x: hasattr(x, '__call__') 95 | imap = map 96 | def _raise(*a): raise a[0](a[1]).with_traceback(a[2]) 97 | else: # 2.x 98 | import httplib 99 | import thread 100 | from urlparse import urljoin, SplitResult as UrlSplitResult 101 | from urllib import urlencode, quote as urlquote, unquote as urlunquote 102 | from Cookie import SimpleCookie 103 | from itertools import imap 104 | import cPickle as pickle 105 | from StringIO import StringIO as BytesIO 106 | from ConfigParser import SafeConfigParser as ConfigParser 107 | if py25: 108 | msg = "Python 2.5 support may be dropped in future versions of Bottle." 109 | warnings.warn(msg, DeprecationWarning) 110 | from UserDict import DictMixin 111 | def next(it): return it.next() 112 | bytes = str 113 | else: # 2.6, 2.7 114 | from collections import MutableMapping as DictMixin 115 | unicode = unicode 116 | json_loads = json_lds 117 | eval(compile('def _raise(*a): raise a[0], a[1], a[2]', '', 'exec')) 118 | 119 | # Some helpers for string/byte handling 120 | def tob(s, enc='utf8'): 121 | return s.encode(enc) if isinstance(s, unicode) else bytes(s) 122 | 123 | def touni(s, enc='utf8', err='strict'): 124 | if isinstance(s, bytes): 125 | return s.decode(enc, err) 126 | else: 127 | return unicode(s or ("" if s is None else s)) 128 | 129 | tonat = touni if py3k else tob 130 | 131 | # 3.2 fixes cgi.FieldStorage to accept bytes (which makes a lot of sense). 132 | # 3.1 needs a workaround. 133 | if py31: 134 | from io import TextIOWrapper 135 | class NCTextIOWrapper(TextIOWrapper): 136 | def close(self): pass # Keep wrapped buffer open. 137 | 138 | 139 | # A bug in functools causes it to break if the wrapper is an instance method 140 | def update_wrapper(wrapper, wrapped, *a, **ka): 141 | try: functools.update_wrapper(wrapper, wrapped, *a, **ka) 142 | except AttributeError: pass 143 | 144 | 145 | 146 | # These helpers are used at module level and need to be defined first. 147 | # And yes, I know PEP-8, but sometimes a lower-case classname makes more sense. 148 | 149 | def depr(message, hard=False): 150 | warnings.warn(message, DeprecationWarning, stacklevel=3) 151 | 152 | def makelist(data): # This is just to handy 153 | if isinstance(data, (tuple, list, set, dict)): return list(data) 154 | elif data: return [data] 155 | else: return [] 156 | 157 | 158 | class DictProperty(object): 159 | ''' Property that maps to a key in a local dict-like attribute. ''' 160 | def __init__(self, attr, key=None, read_only=False): 161 | self.attr, self.key, self.read_only = attr, key, read_only 162 | 163 | def __call__(self, func): 164 | functools.update_wrapper(self, func, updated=[]) 165 | self.getter, self.key = func, self.key or func.__name__ 166 | return self 167 | 168 | def __get__(self, obj, cls): 169 | if obj is None: return self 170 | key, storage = self.key, getattr(obj, self.attr) 171 | if key not in storage: storage[key] = self.getter(obj) 172 | return storage[key] 173 | 174 | def __set__(self, obj, value): 175 | if self.read_only: raise AttributeError("Read-Only property.") 176 | getattr(obj, self.attr)[self.key] = value 177 | 178 | def __delete__(self, obj): 179 | if self.read_only: raise AttributeError("Read-Only property.") 180 | del getattr(obj, self.attr)[self.key] 181 | 182 | 183 | class cached_property(object): 184 | ''' A property that is only computed once per instance and then replaces 185 | itself with an ordinary attribute. Deleting the attribute resets the 186 | property. ''' 187 | 188 | def __init__(self, func): 189 | self.__doc__ = getattr(func, '__doc__') 190 | self.func = func 191 | 192 | def __get__(self, obj, cls): 193 | if obj is None: return self 194 | value = obj.__dict__[self.func.__name__] = self.func(obj) 195 | return value 196 | 197 | 198 | class lazy_attribute(object): 199 | ''' A property that caches itself to the class object. ''' 200 | def __init__(self, func): 201 | functools.update_wrapper(self, func, updated=[]) 202 | self.getter = func 203 | 204 | def __get__(self, obj, cls): 205 | value = self.getter(cls) 206 | setattr(cls, self.__name__, value) 207 | return value 208 | 209 | 210 | 211 | 212 | 213 | 214 | ############################################################################### 215 | # Exceptions and Events ######################################################## 216 | ############################################################################### 217 | 218 | 219 | class BottleException(Exception): 220 | """ A base class for exceptions used by bottle. """ 221 | pass 222 | 223 | 224 | 225 | 226 | 227 | 228 | ############################################################################### 229 | # Routing ###################################################################### 230 | ############################################################################### 231 | 232 | 233 | class RouteError(BottleException): 234 | """ This is a base class for all routing related exceptions """ 235 | 236 | 237 | class RouteReset(BottleException): 238 | """ If raised by a plugin or request handler, the route is reset and all 239 | plugins are re-applied. """ 240 | 241 | class RouterUnknownModeError(RouteError): pass 242 | 243 | 244 | class RouteSyntaxError(RouteError): 245 | """ The route parser found something not supported by this router. """ 246 | 247 | 248 | class RouteBuildError(RouteError): 249 | """ The route could not be built. """ 250 | 251 | 252 | def _re_flatten(p): 253 | ''' Turn all capturing groups in a regular expression pattern into 254 | non-capturing groups. ''' 255 | if '(' not in p: return p 256 | return re.sub(r'(\\*)(\(\?P<[^>]+>|\((?!\?))', 257 | lambda m: m.group(0) if len(m.group(1)) % 2 else m.group(1) + '(?:', p) 258 | 259 | 260 | class Router(object): 261 | ''' A Router is an ordered collection of route->target pairs. It is used to 262 | efficiently match WSGI requests against a number of routes and return 263 | the first target that satisfies the request. The target may be anything, 264 | usually a string, ID or callable object. A route consists of a path-rule 265 | and a HTTP method. 266 | 267 | The path-rule is either a static path (e.g. `/contact`) or a dynamic 268 | path that contains wildcards (e.g. `/wiki/`). The wildcard syntax 269 | and details on the matching order are described in docs:`routing`. 270 | ''' 271 | 272 | default_pattern = '[^/]+' 273 | default_filter = 're' 274 | 275 | #: The current CPython regexp implementation does not allow more 276 | #: than 99 matching groups per regular expression. 277 | _MAX_GROUPS_PER_PATTERN = 99 278 | 279 | def __init__(self, strict=False): 280 | self.rules = [] # All rules in order 281 | self._groups = {} # index of regexes to find them in dyna_routes 282 | self.builder = {} # Data structure for the url builder 283 | self.static = {} # Search structure for static routes 284 | self.dyna_routes = {} 285 | self.dyna_regexes = {} # Search structure for dynamic routes 286 | #: If true, static routes are no longer checked first. 287 | self.strict_order = strict 288 | self.filters = { 289 | 're': lambda conf: 290 | (_re_flatten(conf or self.default_pattern), None, None), 291 | 'int': lambda conf: (r'-?\d+', int, lambda x: str(int(x))), 292 | 'float': lambda conf: (r'-?[\d.]+', float, lambda x: str(float(x))), 293 | 'path': lambda conf: (r'.+?', None, None)} 294 | 295 | def add_filter(self, name, func): 296 | ''' Add a filter. The provided function is called with the configuration 297 | string as parameter and must return a (regexp, to_python, to_url) tuple. 298 | The first element is a string, the last two are callables or None. ''' 299 | self.filters[name] = func 300 | 301 | rule_syntax = re.compile('(\\\\*)'\ 302 | '(?:(?::([a-zA-Z_][a-zA-Z_0-9]*)?()(?:#(.*?)#)?)'\ 303 | '|(?:<([a-zA-Z_][a-zA-Z_0-9]*)?(?::([a-zA-Z_]*)'\ 304 | '(?::((?:\\\\.|[^\\\\>]+)+)?)?)?>))') 305 | 306 | def _itertokens(self, rule): 307 | offset, prefix = 0, '' 308 | for match in self.rule_syntax.finditer(rule): 309 | prefix += rule[offset:match.start()] 310 | g = match.groups() 311 | if len(g[0])%2: # Escaped wildcard 312 | prefix += match.group(0)[len(g[0]):] 313 | offset = match.end() 314 | continue 315 | if prefix: 316 | yield prefix, None, None 317 | name, filtr, conf = g[4:7] if g[2] is None else g[1:4] 318 | yield name, filtr or 'default', conf or None 319 | offset, prefix = match.end(), '' 320 | if offset <= len(rule) or prefix: 321 | yield prefix+rule[offset:], None, None 322 | 323 | def add(self, rule, method, target, name=None): 324 | ''' Add a new rule or replace the target for an existing rule. ''' 325 | anons = 0 # Number of anonymous wildcards found 326 | keys = [] # Names of keys 327 | pattern = '' # Regular expression pattern with named groups 328 | filters = [] # Lists of wildcard input filters 329 | builder = [] # Data structure for the URL builder 330 | is_static = True 331 | 332 | for key, mode, conf in self._itertokens(rule): 333 | if mode: 334 | is_static = False 335 | if mode == 'default': mode = self.default_filter 336 | mask, in_filter, out_filter = self.filters[mode](conf) 337 | if not key: 338 | pattern += '(?:%s)' % mask 339 | key = 'anon%d' % anons 340 | anons += 1 341 | else: 342 | pattern += '(?P<%s>%s)' % (key, mask) 343 | keys.append(key) 344 | if in_filter: filters.append((key, in_filter)) 345 | builder.append((key, out_filter or str)) 346 | elif key: 347 | pattern += re.escape(key) 348 | builder.append((None, key)) 349 | 350 | self.builder[rule] = builder 351 | if name: self.builder[name] = builder 352 | 353 | if is_static and not self.strict_order: 354 | self.static.setdefault(method, {}) 355 | self.static[method][self.build(rule)] = (target, None) 356 | return 357 | 358 | try: 359 | re_pattern = re.compile('^(%s)$' % pattern) 360 | re_match = re_pattern.match 361 | except re.error: 362 | raise RouteSyntaxError("Could not add Route: %s (%s)" % (rule, _e())) 363 | 364 | if filters: 365 | def getargs(path): 366 | url_args = re_match(path).groupdict() 367 | for name, wildcard_filter in filters: 368 | try: 369 | url_args[name] = wildcard_filter(url_args[name]) 370 | except ValueError: 371 | raise HTTPError(400, 'Path has wrong format.') 372 | return url_args 373 | elif re_pattern.groupindex: 374 | def getargs(path): 375 | return re_match(path).groupdict() 376 | else: 377 | getargs = None 378 | 379 | flatpat = _re_flatten(pattern) 380 | whole_rule = (rule, flatpat, target, getargs) 381 | 382 | if (flatpat, method) in self._groups: 383 | if DEBUG: 384 | msg = 'Route <%s %s> overwrites a previously defined route' 385 | warnings.warn(msg % (method, rule), RuntimeWarning) 386 | self.dyna_routes[method][self._groups[flatpat, method]] = whole_rule 387 | else: 388 | self.dyna_routes.setdefault(method, []).append(whole_rule) 389 | self._groups[flatpat, method] = len(self.dyna_routes[method]) - 1 390 | 391 | self._compile(method) 392 | 393 | def _compile(self, method): 394 | all_rules = self.dyna_routes[method] 395 | comborules = self.dyna_regexes[method] = [] 396 | maxgroups = self._MAX_GROUPS_PER_PATTERN 397 | for x in range(0, len(all_rules), maxgroups): 398 | some = all_rules[x:x+maxgroups] 399 | combined = (flatpat for (_, flatpat, _, _) in some) 400 | combined = '|'.join('(^%s$)' % flatpat for flatpat in combined) 401 | combined = re.compile(combined).match 402 | rules = [(target, getargs) for (_, _, target, getargs) in some] 403 | comborules.append((combined, rules)) 404 | 405 | def build(self, _name, *anons, **query): 406 | ''' Build an URL by filling the wildcards in a rule. ''' 407 | builder = self.builder.get(_name) 408 | if not builder: raise RouteBuildError("No route with that name.", _name) 409 | try: 410 | for i, value in enumerate(anons): query['anon%d'%i] = value 411 | url = ''.join([f(query.pop(n)) if n else f for (n,f) in builder]) 412 | return url if not query else url+'?'+urlencode(query) 413 | except KeyError: 414 | raise RouteBuildError('Missing URL argument: %r' % _e().args[0]) 415 | 416 | def match(self, environ): 417 | ''' Return a (target, url_agrs) tuple or raise HTTPError(400/404/405). ''' 418 | verb = environ['REQUEST_METHOD'].upper() 419 | path = environ['PATH_INFO'] or '/' 420 | target = None 421 | if verb == 'HEAD': 422 | methods = ['PROXY', verb, 'GET', 'ANY'] 423 | else: 424 | methods = ['PROXY', verb, 'ANY'] 425 | 426 | for method in methods: 427 | if method in self.static and path in self.static[method]: 428 | target, getargs = self.static[method][path] 429 | return target, getargs(path) if getargs else {} 430 | elif method in self.dyna_regexes: 431 | for combined, rules in self.dyna_regexes[method]: 432 | match = combined(path) 433 | if match: 434 | target, getargs = rules[match.lastindex - 1] 435 | return target, getargs(path) if getargs else {} 436 | 437 | # No matching route found. Collect alternative methods for 405 response 438 | allowed = set([]) 439 | nocheck = set(methods) 440 | for method in set(self.static) - nocheck: 441 | if path in self.static[method]: 442 | allowed.add(verb) 443 | for method in set(self.dyna_regexes) - allowed - nocheck: 444 | for combined, rules in self.dyna_regexes[method]: 445 | match = combined(path) 446 | if match: 447 | allowed.add(method) 448 | if allowed: 449 | allow_header = ",".join(sorted(allowed)) 450 | raise HTTPError(405, "Method not allowed.", Allow=allow_header) 451 | 452 | # No matching route and no alternative method found. We give up 453 | raise HTTPError(404, "Not found: " + repr(path)) 454 | 455 | 456 | 457 | 458 | 459 | 460 | class Route(object): 461 | ''' This class wraps a route callback along with route specific metadata and 462 | configuration and applies Plugins on demand. It is also responsible for 463 | turing an URL path rule into a regular expression usable by the Router. 464 | ''' 465 | 466 | def __init__(self, app, rule, method, callback, name=None, 467 | plugins=None, skiplist=None, **config): 468 | #: The application this route is installed to. 469 | self.app = app 470 | #: The path-rule string (e.g. ``/wiki/:page``). 471 | self.rule = rule 472 | #: The HTTP method as a string (e.g. ``GET``). 473 | self.method = method 474 | #: The original callback with no plugins applied. Useful for introspection. 475 | self.callback = callback 476 | #: The name of the route (if specified) or ``None``. 477 | self.name = name or None 478 | #: A list of route-specific plugins (see :meth:`Bottle.route`). 479 | self.plugins = plugins or [] 480 | #: A list of plugins to not apply to this route (see :meth:`Bottle.route`). 481 | self.skiplist = skiplist or [] 482 | #: Additional keyword arguments passed to the :meth:`Bottle.route` 483 | #: decorator are stored in this dictionary. Used for route-specific 484 | #: plugin configuration and meta-data. 485 | self.config = ConfigDict().load_dict(config) 486 | 487 | @cached_property 488 | def call(self): 489 | ''' The route callback with all plugins applied. This property is 490 | created on demand and then cached to speed up subsequent requests.''' 491 | return self._make_callback() 492 | 493 | def reset(self): 494 | ''' Forget any cached values. The next time :attr:`call` is accessed, 495 | all plugins are re-applied. ''' 496 | self.__dict__.pop('call', None) 497 | 498 | def prepare(self): 499 | ''' Do all on-demand work immediately (useful for debugging).''' 500 | self.call 501 | 502 | def all_plugins(self): 503 | ''' Yield all Plugins affecting this route. ''' 504 | unique = set() 505 | for p in reversed(self.app.plugins + self.plugins): 506 | if True in self.skiplist: break 507 | name = getattr(p, 'name', False) 508 | if name and (name in self.skiplist or name in unique): continue 509 | if p in self.skiplist or type(p) in self.skiplist: continue 510 | if name: unique.add(name) 511 | yield p 512 | 513 | def _make_callback(self): 514 | callback = self.callback 515 | for plugin in self.all_plugins(): 516 | try: 517 | if hasattr(plugin, 'apply'): 518 | callback = plugin.apply(callback, self) 519 | else: 520 | callback = plugin(callback) 521 | except RouteReset: # Try again with changed configuration. 522 | return self._make_callback() 523 | if not callback is self.callback: 524 | update_wrapper(callback, self.callback) 525 | return callback 526 | 527 | def get_undecorated_callback(self): 528 | ''' Return the callback. If the callback is a decorated function, try to 529 | recover the original function. ''' 530 | func = self.callback 531 | func = getattr(func, '__func__' if py3k else 'im_func', func) 532 | closure_attr = '__closure__' if py3k else 'func_closure' 533 | while hasattr(func, closure_attr) and getattr(func, closure_attr): 534 | func = getattr(func, closure_attr)[0].cell_contents 535 | return func 536 | 537 | def get_callback_args(self): 538 | ''' Return a list of argument names the callback (most likely) accepts 539 | as keyword arguments. If the callback is a decorated function, try 540 | to recover the original function before inspection. ''' 541 | return getargspec(self.get_undecorated_callback())[0] 542 | 543 | def get_config(self, key, default=None): 544 | ''' Lookup a config field and return its value, first checking the 545 | route.config, then route.app.config.''' 546 | for conf in (self.config, self.app.conifg): 547 | if key in conf: return conf[key] 548 | return default 549 | 550 | def __repr__(self): 551 | cb = self.get_undecorated_callback() 552 | return '<%s %r %r>' % (self.method, self.rule, cb) 553 | 554 | 555 | 556 | 557 | 558 | 559 | ############################################################################### 560 | # Application Object ########################################################### 561 | ############################################################################### 562 | 563 | 564 | class Bottle(object): 565 | """ Each Bottle object represents a single, distinct web application and 566 | consists of routes, callbacks, plugins, resources and configuration. 567 | Instances are callable WSGI applications. 568 | 569 | :param catchall: If true (default), handle all exceptions. Turn off to 570 | let debugging middleware handle exceptions. 571 | """ 572 | 573 | def __init__(self, catchall=True, autojson=True): 574 | 575 | #: A :class:`ConfigDict` for app specific configuration. 576 | self.config = ConfigDict() 577 | self.config._on_change = functools.partial(self.trigger_hook, 'config') 578 | self.config.meta_set('autojson', 'validate', bool) 579 | self.config.meta_set('catchall', 'validate', bool) 580 | self.config['catchall'] = catchall 581 | self.config['autojson'] = autojson 582 | 583 | #: A :class:`ResourceManager` for application files 584 | self.resources = ResourceManager() 585 | 586 | self.routes = [] # List of installed :class:`Route` instances. 587 | self.router = Router() # Maps requests to :class:`Route` instances. 588 | self.error_handler = {} 589 | 590 | # Core plugins 591 | self.plugins = [] # List of installed plugins. 592 | if self.config['autojson']: 593 | self.install(JSONPlugin()) 594 | self.install(TemplatePlugin()) 595 | 596 | #: If true, most exceptions are caught and returned as :exc:`HTTPError` 597 | catchall = DictProperty('config', 'catchall') 598 | 599 | __hook_names = 'before_request', 'after_request', 'app_reset', 'config' 600 | __hook_reversed = 'after_request' 601 | 602 | @cached_property 603 | def _hooks(self): 604 | return dict((name, []) for name in self.__hook_names) 605 | 606 | def add_hook(self, name, func): 607 | ''' Attach a callback to a hook. Three hooks are currently implemented: 608 | 609 | before_request 610 | Executed once before each request. The request context is 611 | available, but no routing has happened yet. 612 | after_request 613 | Executed once after each request regardless of its outcome. 614 | app_reset 615 | Called whenever :meth:`Bottle.reset` is called. 616 | ''' 617 | if name in self.__hook_reversed: 618 | self._hooks[name].insert(0, func) 619 | else: 620 | self._hooks[name].append(func) 621 | 622 | def remove_hook(self, name, func): 623 | ''' Remove a callback from a hook. ''' 624 | if name in self._hooks and func in self._hooks[name]: 625 | self._hooks[name].remove(func) 626 | return True 627 | 628 | def trigger_hook(self, __name, *args, **kwargs): 629 | ''' Trigger a hook and return a list of results. ''' 630 | return [hook(*args, **kwargs) for hook in self._hooks[__name][:]] 631 | 632 | def hook(self, name): 633 | """ Return a decorator that attaches a callback to a hook. See 634 | :meth:`add_hook` for details.""" 635 | def decorator(func): 636 | self.add_hook(name, func) 637 | return func 638 | return decorator 639 | 640 | def mount(self, prefix, app, **options): 641 | ''' Mount an application (:class:`Bottle` or plain WSGI) to a specific 642 | URL prefix. Example:: 643 | 644 | root_app.mount('/admin/', admin_app) 645 | 646 | :param prefix: path prefix or `mount-point`. If it ends in a slash, 647 | that slash is mandatory. 648 | :param app: an instance of :class:`Bottle` or a WSGI application. 649 | 650 | All other parameters are passed to the underlying :meth:`route` call. 651 | ''' 652 | 653 | segments = [p for p in prefix.split('/') if p] 654 | if not segments: raise ValueError('Empty path prefix.') 655 | path_depth = len(segments) 656 | 657 | def mountpoint_wrapper(): 658 | try: 659 | request.path_shift(path_depth) 660 | rs = HTTPResponse([]) 661 | def start_response(status, headerlist, exc_info=None): 662 | if exc_info: 663 | try: 664 | _raise(*exc_info) 665 | finally: 666 | exc_info = None 667 | rs.status = status 668 | for name, value in headerlist: rs.add_header(name, value) 669 | return rs.body.append 670 | body = app(request.environ, start_response) 671 | if body and rs.body: body = itertools.chain(rs.body, body) 672 | rs.body = body or rs.body 673 | return rs 674 | finally: 675 | request.path_shift(-path_depth) 676 | 677 | options.setdefault('skip', True) 678 | options.setdefault('method', 'PROXY') 679 | options.setdefault('mountpoint', {'prefix': prefix, 'target': app}) 680 | options['callback'] = mountpoint_wrapper 681 | 682 | self.route('/%s/<:re:.*>' % '/'.join(segments), **options) 683 | if not prefix.endswith('/'): 684 | self.route('/' + '/'.join(segments), **options) 685 | 686 | def merge(self, routes): 687 | ''' Merge the routes of another :class:`Bottle` application or a list of 688 | :class:`Route` objects into this application. The routes keep their 689 | 'owner', meaning that the :data:`Route.app` attribute is not 690 | changed. ''' 691 | if isinstance(routes, Bottle): 692 | routes = routes.routes 693 | for route in routes: 694 | self.add_route(route) 695 | 696 | def install(self, plugin): 697 | ''' Add a plugin to the list of plugins and prepare it for being 698 | applied to all routes of this application. A plugin may be a simple 699 | decorator or an object that implements the :class:`Plugin` API. 700 | ''' 701 | if hasattr(plugin, 'setup'): plugin.setup(self) 702 | if not callable(plugin) and not hasattr(plugin, 'apply'): 703 | raise TypeError("Plugins must be callable or implement .apply()") 704 | self.plugins.append(plugin) 705 | self.reset() 706 | return plugin 707 | 708 | def uninstall(self, plugin): 709 | ''' Uninstall plugins. Pass an instance to remove a specific plugin, a type 710 | object to remove all plugins that match that type, a string to remove 711 | all plugins with a matching ``name`` attribute or ``True`` to remove all 712 | plugins. Return the list of removed plugins. ''' 713 | removed, remove = [], plugin 714 | for i, plugin in list(enumerate(self.plugins))[::-1]: 715 | if remove is True or remove is plugin or remove is type(plugin) \ 716 | or getattr(plugin, 'name', True) == remove: 717 | removed.append(plugin) 718 | del self.plugins[i] 719 | if hasattr(plugin, 'close'): plugin.close() 720 | if removed: self.reset() 721 | return removed 722 | 723 | def reset(self, route=None): 724 | ''' Reset all routes (force plugins to be re-applied) and clear all 725 | caches. If an ID or route object is given, only that specific route 726 | is affected. ''' 727 | if route is None: routes = self.routes 728 | elif isinstance(route, Route): routes = [route] 729 | else: routes = [self.routes[route]] 730 | for route in routes: route.reset() 731 | if DEBUG: 732 | for route in routes: route.prepare() 733 | self.trigger_hook('app_reset') 734 | 735 | def close(self): 736 | ''' Close the application and all installed plugins. ''' 737 | for plugin in self.plugins: 738 | if hasattr(plugin, 'close'): plugin.close() 739 | self.stopped = True 740 | 741 | def run(self, **kwargs): 742 | ''' Calls :func:`run` with the same parameters. ''' 743 | run(self, **kwargs) 744 | 745 | def match(self, environ): 746 | """ Search for a matching route and return a (:class:`Route` , urlargs) 747 | tuple. The second value is a dictionary with parameters extracted 748 | from the URL. Raise :exc:`HTTPError` (404/405) on a non-match.""" 749 | return self.router.match(environ) 750 | 751 | def get_url(self, routename, **kargs): 752 | """ Return a string that matches a named route """ 753 | scriptname = request.environ.get('SCRIPT_NAME', '').strip('/') + '/' 754 | location = self.router.build(routename, **kargs).lstrip('/') 755 | return urljoin(urljoin('/', scriptname), location) 756 | 757 | def add_route(self, route): 758 | ''' Add a route object, but do not change the :data:`Route.app` 759 | attribute.''' 760 | self.routes.append(route) 761 | self.router.add(route.rule, route.method, route, name=route.name) 762 | if DEBUG: route.prepare() 763 | 764 | def route(self, path=None, method='GET', callback=None, name=None, 765 | apply=None, skip=None, **config): 766 | """ A decorator to bind a function to a request URL. Example:: 767 | 768 | @app.route('/hello/:name') 769 | def hello(name): 770 | return 'Hello %s' % name 771 | 772 | The ``:name`` part is a wildcard. See :class:`Router` for syntax 773 | details. 774 | 775 | :param path: Request path or a list of paths to listen to. If no 776 | path is specified, it is automatically generated from the 777 | signature of the function. 778 | :param method: HTTP method (`GET`, `POST`, `PUT`, ...) or a list of 779 | methods to listen to. (default: `GET`) 780 | :param callback: An optional shortcut to avoid the decorator 781 | syntax. ``route(..., callback=func)`` equals ``route(...)(func)`` 782 | :param name: The name for this route. (default: None) 783 | :param apply: A decorator or plugin or a list of plugins. These are 784 | applied to the route callback in addition to installed plugins. 785 | :param skip: A list of plugins, plugin classes or names. Matching 786 | plugins are not installed to this route. ``True`` skips all. 787 | 788 | Any additional keyword arguments are stored as route-specific 789 | configuration and passed to plugins (see :meth:`Plugin.apply`). 790 | """ 791 | if callable(path): path, callback = None, path 792 | plugins = makelist(apply) 793 | skiplist = makelist(skip) 794 | def decorator(callback): 795 | # TODO: Documentation and tests 796 | if isinstance(callback, basestring): callback = load(callback) 797 | for rule in makelist(path) or yieldroutes(callback): 798 | for verb in makelist(method): 799 | verb = verb.upper() 800 | route = Route(self, rule, verb, callback, name=name, 801 | plugins=plugins, skiplist=skiplist, **config) 802 | self.add_route(route) 803 | return callback 804 | return decorator(callback) if callback else decorator 805 | 806 | def get(self, path=None, method='GET', **options): 807 | """ Equals :meth:`route`. """ 808 | return self.route(path, method, **options) 809 | 810 | def post(self, path=None, method='POST', **options): 811 | """ Equals :meth:`route` with a ``POST`` method parameter. """ 812 | return self.route(path, method, **options) 813 | 814 | def put(self, path=None, method='PUT', **options): 815 | """ Equals :meth:`route` with a ``PUT`` method parameter. """ 816 | return self.route(path, method, **options) 817 | 818 | def delete(self, path=None, method='DELETE', **options): 819 | """ Equals :meth:`route` with a ``DELETE`` method parameter. """ 820 | return self.route(path, method, **options) 821 | 822 | def error(self, code=500): 823 | """ Decorator: Register an output handler for a HTTP error code""" 824 | def wrapper(handler): 825 | self.error_handler[int(code)] = handler 826 | return handler 827 | return wrapper 828 | 829 | def default_error_handler(self, res): 830 | return tob(template(ERROR_PAGE_TEMPLATE, e=res)) 831 | 832 | def _handle(self, environ): 833 | path = environ['bottle.raw_path'] = environ['PATH_INFO'] 834 | if py3k: 835 | try: 836 | environ['PATH_INFO'] = path.encode('latin1').decode('utf8') 837 | except UnicodeError: 838 | return HTTPError(400, 'Invalid path string. Expected UTF-8') 839 | 840 | try: 841 | environ['bottle.app'] = self 842 | request.bind(environ) 843 | response.bind() 844 | try: 845 | self.trigger_hook('before_request') 846 | route, args = self.router.match(environ) 847 | environ['route.handle'] = route 848 | environ['bottle.route'] = route 849 | environ['route.url_args'] = args 850 | return route.call(**args) 851 | finally: 852 | self.trigger_hook('after_request') 853 | 854 | except HTTPResponse: 855 | return _e() 856 | except RouteReset: 857 | route.reset() 858 | return self._handle(environ) 859 | except (KeyboardInterrupt, SystemExit, MemoryError): 860 | raise 861 | except Exception: 862 | if not self.catchall: raise 863 | stacktrace = format_exc() 864 | environ['wsgi.errors'].write(stacktrace) 865 | return HTTPError(500, "Internal Server Error", _e(), stacktrace) 866 | 867 | def _cast(self, out, peek=None): 868 | """ Try to convert the parameter into something WSGI compatible and set 869 | correct HTTP headers when possible. 870 | Support: False, str, unicode, dict, HTTPResponse, HTTPError, file-like, 871 | iterable of strings and iterable of unicodes 872 | """ 873 | 874 | # Empty output is done here 875 | if not out: 876 | if 'Content-Length' not in response: 877 | response['Content-Length'] = 0 878 | return [] 879 | # Join lists of byte or unicode strings. Mixed lists are NOT supported 880 | if isinstance(out, (tuple, list))\ 881 | and isinstance(out[0], (bytes, unicode)): 882 | out = out[0][0:0].join(out) # b'abc'[0:0] -> b'' 883 | # Encode unicode strings 884 | if isinstance(out, unicode): 885 | out = out.encode(response.charset) 886 | # Byte Strings are just returned 887 | if isinstance(out, bytes): 888 | if 'Content-Length' not in response: 889 | response['Content-Length'] = len(out) 890 | return [out] 891 | # HTTPError or HTTPException (recursive, because they may wrap anything) 892 | # TODO: Handle these explicitly in handle() or make them iterable. 893 | if isinstance(out, HTTPError): 894 | out.apply(response) 895 | out = self.error_handler.get(out.status_code, self.default_error_handler)(out) 896 | return self._cast(out) 897 | if isinstance(out, HTTPResponse): 898 | out.apply(response) 899 | return self._cast(out.body) 900 | 901 | # File-like objects. 902 | if hasattr(out, 'read'): 903 | if 'wsgi.file_wrapper' in request.environ: 904 | return request.environ['wsgi.file_wrapper'](out) 905 | elif hasattr(out, 'close') or not hasattr(out, '__iter__'): 906 | return WSGIFileWrapper(out) 907 | 908 | # Handle Iterables. We peek into them to detect their inner type. 909 | try: 910 | iout = iter(out) 911 | first = next(iout) 912 | while not first: 913 | first = next(iout) 914 | except StopIteration: 915 | return self._cast('') 916 | except HTTPResponse: 917 | first = _e() 918 | except (KeyboardInterrupt, SystemExit, MemoryError): 919 | raise 920 | except Exception: 921 | if not self.catchall: raise 922 | first = HTTPError(500, 'Unhandled exception', _e(), format_exc()) 923 | 924 | # These are the inner types allowed in iterator or generator objects. 925 | if isinstance(first, HTTPResponse): 926 | return self._cast(first) 927 | elif isinstance(first, bytes): 928 | new_iter = itertools.chain([first], iout) 929 | elif isinstance(first, unicode): 930 | encoder = lambda x: x.encode(response.charset) 931 | new_iter = imap(encoder, itertools.chain([first], iout)) 932 | else: 933 | msg = 'Unsupported response type: %s' % type(first) 934 | return self._cast(HTTPError(500, msg)) 935 | if hasattr(out, 'close'): 936 | new_iter = _closeiter(new_iter, out.close) 937 | return new_iter 938 | 939 | def wsgi(self, environ, start_response): 940 | """ The bottle WSGI-interface. """ 941 | try: 942 | out = self._cast(self._handle(environ)) 943 | # rfc2616 section 4.3 944 | if response._status_code in (100, 101, 204, 304)\ 945 | or environ['REQUEST_METHOD'] == 'HEAD': 946 | if hasattr(out, 'close'): out.close() 947 | out = [] 948 | start_response(response._status_line, response.headerlist) 949 | return out 950 | except (KeyboardInterrupt, SystemExit, MemoryError): 951 | raise 952 | except Exception: 953 | if not self.catchall: raise 954 | err = '

Critical error while processing request: %s

' \ 955 | % html_escape(environ.get('PATH_INFO', '/')) 956 | if DEBUG: 957 | err += '

Error:

\n
\n%s\n
\n' \ 958 | '

Traceback:

\n
\n%s\n
\n' \ 959 | % (html_escape(repr(_e())), html_escape(format_exc())) 960 | environ['wsgi.errors'].write(err) 961 | headers = [('Content-Type', 'text/html; charset=UTF-8')] 962 | start_response('500 INTERNAL SERVER ERROR', headers, sys.exc_info()) 963 | return [tob(err)] 964 | 965 | def __call__(self, environ, start_response): 966 | ''' Each instance of :class:'Bottle' is a WSGI application. ''' 967 | return self.wsgi(environ, start_response) 968 | 969 | def __enter__(self): 970 | ''' Use this application as default for all module-level shortcuts. ''' 971 | default_app.push(self) 972 | return self 973 | 974 | def __exit__(self, exc_type, exc_value, traceback): 975 | default_app.pop() 976 | 977 | 978 | 979 | 980 | 981 | ############################################################################### 982 | # HTTP and WSGI Tools ########################################################## 983 | ############################################################################### 984 | 985 | class BaseRequest(object): 986 | """ A wrapper for WSGI environment dictionaries that adds a lot of 987 | convenient access methods and properties. Most of them are read-only. 988 | 989 | Adding new attributes to a request actually adds them to the environ 990 | dictionary (as 'bottle.request.ext.'). This is the recommended 991 | way to store and access request-specific data. 992 | """ 993 | 994 | __slots__ = ('environ') 995 | 996 | #: Maximum size of memory buffer for :attr:`body` in bytes. 997 | MEMFILE_MAX = 102400 998 | 999 | def __init__(self, environ=None): 1000 | """ Wrap a WSGI environ dictionary. """ 1001 | #: The wrapped WSGI environ dictionary. This is the only real attribute. 1002 | #: All other attributes actually are read-only properties. 1003 | self.environ = {} if environ is None else environ 1004 | self.environ['bottle.request'] = self 1005 | 1006 | @DictProperty('environ', 'bottle.app', read_only=True) 1007 | def app(self): 1008 | ''' Bottle application handling this request. ''' 1009 | raise RuntimeError('This request is not connected to an application.') 1010 | 1011 | @DictProperty('environ', 'bottle.route', read_only=True) 1012 | def route(self): 1013 | """ The bottle :class:`Route` object that matches this request. """ 1014 | raise RuntimeError('This request is not connected to a route.') 1015 | 1016 | @DictProperty('environ', 'route.url_args', read_only=True) 1017 | def url_args(self): 1018 | """ The arguments extracted from the URL. """ 1019 | raise RuntimeError('This request is not connected to a route.') 1020 | 1021 | @property 1022 | def path(self): 1023 | ''' The value of ``PATH_INFO`` with exactly one prefixed slash (to fix 1024 | broken clients and avoid the "empty path" edge case). ''' 1025 | return '/' + self.environ.get('PATH_INFO','').lstrip('/') 1026 | 1027 | @property 1028 | def method(self): 1029 | ''' The ``REQUEST_METHOD`` value as an uppercase string. ''' 1030 | return self.environ.get('REQUEST_METHOD', 'GET').upper() 1031 | 1032 | @DictProperty('environ', 'bottle.request.headers', read_only=True) 1033 | def headers(self): 1034 | ''' A :class:`WSGIHeaderDict` that provides case-insensitive access to 1035 | HTTP request headers. ''' 1036 | return WSGIHeaderDict(self.environ) 1037 | 1038 | def get_header(self, name, default=None): 1039 | ''' Return the value of a request header, or a given default value. ''' 1040 | return self.headers.get(name, default) 1041 | 1042 | @DictProperty('environ', 'bottle.request.cookies', read_only=True) 1043 | def cookies(self): 1044 | """ Cookies parsed into a :class:`FormsDict`. Signed cookies are NOT 1045 | decoded. Use :meth:`get_cookie` if you expect signed cookies. """ 1046 | cookies = SimpleCookie(self.environ.get('HTTP_COOKIE','')).values() 1047 | return FormsDict((c.key, c.value) for c in cookies) 1048 | 1049 | def get_cookie(self, key, default=None, secret=None): 1050 | """ Return the content of a cookie. To read a `Signed Cookie`, the 1051 | `secret` must match the one used to create the cookie (see 1052 | :meth:`BaseResponse.set_cookie`). If anything goes wrong (missing 1053 | cookie or wrong signature), return a default value. """ 1054 | value = self.cookies.get(key) 1055 | if secret and value: 1056 | dec = cookie_decode(value, secret) # (key, value) tuple or None 1057 | return dec[1] if dec and dec[0] == key else default 1058 | return value or default 1059 | 1060 | @DictProperty('environ', 'bottle.request.query', read_only=True) 1061 | def query(self): 1062 | ''' The :attr:`query_string` parsed into a :class:`FormsDict`. These 1063 | values are sometimes called "URL arguments" or "GET parameters", but 1064 | not to be confused with "URL wildcards" as they are provided by the 1065 | :class:`Router`. ''' 1066 | get = self.environ['bottle.get'] = FormsDict() 1067 | pairs = _parse_qsl(self.environ.get('QUERY_STRING', '')) 1068 | for key, value in pairs: 1069 | get[key] = value 1070 | return get 1071 | 1072 | @DictProperty('environ', 'bottle.request.forms', read_only=True) 1073 | def forms(self): 1074 | """ Form values parsed from an `url-encoded` or `multipart/form-data` 1075 | encoded POST or PUT request body. The result is returned as a 1076 | :class:`FormsDict`. All keys and values are strings. File uploads 1077 | are stored separately in :attr:`files`. """ 1078 | forms = FormsDict() 1079 | for name, item in self.POST.allitems(): 1080 | if not isinstance(item, FileUpload): 1081 | forms[name] = item 1082 | return forms 1083 | 1084 | @DictProperty('environ', 'bottle.request.params', read_only=True) 1085 | def params(self): 1086 | """ A :class:`FormsDict` with the combined values of :attr:`query` and 1087 | :attr:`forms`. File uploads are stored in :attr:`files`. """ 1088 | params = FormsDict() 1089 | for key, value in self.query.allitems(): 1090 | params[key] = value 1091 | for key, value in self.forms.allitems(): 1092 | params[key] = value 1093 | return params 1094 | 1095 | @DictProperty('environ', 'bottle.request.files', read_only=True) 1096 | def files(self): 1097 | """ File uploads parsed from `multipart/form-data` encoded POST or PUT 1098 | request body. The values are instances of :class:`FileUpload`. 1099 | 1100 | """ 1101 | files = FormsDict() 1102 | for name, item in self.POST.allitems(): 1103 | if isinstance(item, FileUpload): 1104 | files[name] = item 1105 | return files 1106 | 1107 | @DictProperty('environ', 'bottle.request.json', read_only=True) 1108 | def json(self): 1109 | ''' If the ``Content-Type`` header is ``application/json``, this 1110 | property holds the parsed content of the request body. Only requests 1111 | smaller than :attr:`MEMFILE_MAX` are processed to avoid memory 1112 | exhaustion. ''' 1113 | if 'application/json' in self.environ.get('CONTENT_TYPE', ''): 1114 | return json_loads(self._get_body_string()) 1115 | return None 1116 | 1117 | def _iter_body(self, read, bufsize): 1118 | maxread = max(0, self.content_length) 1119 | while maxread: 1120 | part = read(min(maxread, bufsize)) 1121 | if not part: break 1122 | yield part 1123 | maxread -= len(part) 1124 | 1125 | def _iter_chunked(self, read, bufsize): 1126 | err = HTTPError(400, 'Error while parsing chunked transfer body.') 1127 | rn, sem, bs = tob('\r\n'), tob(';'), tob('') 1128 | while True: 1129 | header = read(1) 1130 | while header[-2:] != rn: 1131 | c = read(1) 1132 | header += c 1133 | if not c: raise err 1134 | if len(header) > bufsize: raise err 1135 | size, _, _ = header.partition(sem) 1136 | try: 1137 | maxread = int(tonat(size.strip()), 16) 1138 | except ValueError: 1139 | raise err 1140 | if maxread == 0: break 1141 | buff = bs 1142 | while maxread > 0: 1143 | if not buff: 1144 | buff = read(min(maxread, bufsize)) 1145 | part, buff = buff[:maxread], buff[maxread:] 1146 | if not part: raise err 1147 | yield part 1148 | maxread -= len(part) 1149 | if read(2) != rn: 1150 | raise err 1151 | 1152 | @DictProperty('environ', 'bottle.request.body', read_only=True) 1153 | def _body(self): 1154 | body_iter = self._iter_chunked if self.chunked else self._iter_body 1155 | read_func = self.environ['wsgi.input'].read 1156 | body, body_size, is_temp_file = BytesIO(), 0, False 1157 | for part in body_iter(read_func, self.MEMFILE_MAX): 1158 | body.write(part) 1159 | body_size += len(part) 1160 | if not is_temp_file and body_size > self.MEMFILE_MAX: 1161 | body, tmp = TemporaryFile(mode='w+b'), body 1162 | body.write(tmp.getvalue()) 1163 | del tmp 1164 | is_temp_file = True 1165 | self.environ['wsgi.input'] = body 1166 | body.seek(0) 1167 | return body 1168 | 1169 | def _get_body_string(self): 1170 | ''' read body until content-length or MEMFILE_MAX into a string. Raise 1171 | HTTPError(413) on requests that are to large. ''' 1172 | clen = self.content_length 1173 | if clen > self.MEMFILE_MAX: 1174 | raise HTTPError(413, 'Request to large') 1175 | if clen < 0: clen = self.MEMFILE_MAX + 1 1176 | data = self.body.read(clen) 1177 | if len(data) > self.MEMFILE_MAX: # Fail fast 1178 | raise HTTPError(413, 'Request to large') 1179 | return data 1180 | 1181 | @property 1182 | def body(self): 1183 | """ The HTTP request body as a seek-able file-like object. Depending on 1184 | :attr:`MEMFILE_MAX`, this is either a temporary file or a 1185 | :class:`io.BytesIO` instance. Accessing this property for the first 1186 | time reads and replaces the ``wsgi.input`` environ variable. 1187 | Subsequent accesses just do a `seek(0)` on the file object. """ 1188 | self._body.seek(0) 1189 | return self._body 1190 | 1191 | @property 1192 | def chunked(self): 1193 | ''' True if Chunked transfer encoding was. ''' 1194 | return 'chunked' in self.environ.get('HTTP_TRANSFER_ENCODING', '').lower() 1195 | 1196 | #: An alias for :attr:`query`. 1197 | GET = query 1198 | 1199 | @DictProperty('environ', 'bottle.request.post', read_only=True) 1200 | def POST(self): 1201 | """ The values of :attr:`forms` and :attr:`files` combined into a single 1202 | :class:`FormsDict`. Values are either strings (form values) or 1203 | instances of :class:`cgi.FieldStorage` (file uploads). 1204 | """ 1205 | post = FormsDict() 1206 | # We default to application/x-www-form-urlencoded for everything that 1207 | # is not multipart and take the fast path (also: 3.1 workaround) 1208 | if not self.content_type.startswith('multipart/'): 1209 | pairs = _parse_qsl(tonat(self._get_body_string(), 'latin1')) 1210 | for key, value in pairs: 1211 | post[key] = value 1212 | return post 1213 | 1214 | safe_env = {'QUERY_STRING':''} # Build a safe environment for cgi 1215 | for key in ('REQUEST_METHOD', 'CONTENT_TYPE', 'CONTENT_LENGTH'): 1216 | if key in self.environ: safe_env[key] = self.environ[key] 1217 | args = dict(fp=self.body, environ=safe_env, keep_blank_values=True) 1218 | if py31: 1219 | args['fp'] = NCTextIOWrapper(args['fp'], encoding='utf8', 1220 | newline='\n') 1221 | elif py3k: 1222 | args['encoding'] = 'utf8' 1223 | data = cgi.FieldStorage(**args) 1224 | data = data.list or [] 1225 | for item in data: 1226 | if item.filename: 1227 | post[item.name] = FileUpload(item.file, item.name, 1228 | item.filename, item.headers) 1229 | else: 1230 | post[item.name] = item.value 1231 | return post 1232 | 1233 | @property 1234 | def url(self): 1235 | """ The full request URI including hostname and scheme. If your app 1236 | lives behind a reverse proxy or load balancer and you get confusing 1237 | results, make sure that the ``X-Forwarded-Host`` header is set 1238 | correctly. """ 1239 | return self.urlparts.geturl() 1240 | 1241 | @DictProperty('environ', 'bottle.request.urlparts', read_only=True) 1242 | def urlparts(self): 1243 | ''' The :attr:`url` string as an :class:`urlparse.SplitResult` tuple. 1244 | The tuple contains (scheme, host, path, query_string and fragment), 1245 | but the fragment is always empty because it is not visible to the 1246 | server. ''' 1247 | env = self.environ 1248 | http = env.get('HTTP_X_FORWARDED_PROTO') or env.get('wsgi.url_scheme', 'http') 1249 | host = env.get('HTTP_X_FORWARDED_HOST') or env.get('HTTP_HOST') 1250 | if not host: 1251 | # HTTP 1.1 requires a Host-header. This is for HTTP/1.0 clients. 1252 | host = env.get('SERVER_NAME', '127.0.0.1') 1253 | port = env.get('SERVER_PORT') 1254 | if port and port != ('80' if http == 'http' else '443'): 1255 | host += ':' + port 1256 | path = urlquote(self.fullpath) 1257 | return UrlSplitResult(http, host, path, env.get('QUERY_STRING'), '') 1258 | 1259 | @property 1260 | def fullpath(self): 1261 | """ Request path including :attr:`script_name` (if present). """ 1262 | return urljoin(self.script_name, self.path.lstrip('/')) 1263 | 1264 | @property 1265 | def query_string(self): 1266 | """ The raw :attr:`query` part of the URL (everything in between ``?`` 1267 | and ``#``) as a string. """ 1268 | return self.environ.get('QUERY_STRING', '') 1269 | 1270 | @property 1271 | def script_name(self): 1272 | ''' The initial portion of the URL's `path` that was removed by a higher 1273 | level (server or routing middleware) before the application was 1274 | called. This script path is returned with leading and tailing 1275 | slashes. ''' 1276 | script_name = self.environ.get('SCRIPT_NAME', '').strip('/') 1277 | return '/' + script_name + '/' if script_name else '/' 1278 | 1279 | def path_shift(self, shift=1): 1280 | ''' Shift path segments from :attr:`path` to :attr:`script_name` and 1281 | vice versa. 1282 | 1283 | :param shift: The number of path segments to shift. May be negative 1284 | to change the shift direction. (default: 1) 1285 | ''' 1286 | script = self.environ.get('SCRIPT_NAME','/') 1287 | self['SCRIPT_NAME'], self['PATH_INFO'] = path_shift(script, self.path, shift) 1288 | 1289 | @property 1290 | def content_length(self): 1291 | ''' The request body length as an integer. The client is responsible to 1292 | set this header. Otherwise, the real length of the body is unknown 1293 | and -1 is returned. In this case, :attr:`body` will be empty. ''' 1294 | return int(self.environ.get('CONTENT_LENGTH') or -1) 1295 | 1296 | @property 1297 | def content_type(self): 1298 | ''' The Content-Type header as a lowercase-string (default: empty). ''' 1299 | return self.environ.get('CONTENT_TYPE', '').lower() 1300 | 1301 | @property 1302 | def is_xhr(self): 1303 | ''' True if the request was triggered by a XMLHttpRequest. This only 1304 | works with JavaScript libraries that support the `X-Requested-With` 1305 | header (most of the popular libraries do). ''' 1306 | requested_with = self.environ.get('HTTP_X_REQUESTED_WITH','') 1307 | return requested_with.lower() == 'xmlhttprequest' 1308 | 1309 | @property 1310 | def is_ajax(self): 1311 | ''' Alias for :attr:`is_xhr`. "Ajax" is not the right term. ''' 1312 | return self.is_xhr 1313 | 1314 | @property 1315 | def auth(self): 1316 | """ HTTP authentication data as a (user, password) tuple. This 1317 | implementation currently supports basic (not digest) authentication 1318 | only. If the authentication happened at a higher level (e.g. in the 1319 | front web-server or a middleware), the password field is None, but 1320 | the user field is looked up from the ``REMOTE_USER`` environ 1321 | variable. On any errors, None is returned. """ 1322 | basic = parse_auth(self.environ.get('HTTP_AUTHORIZATION','')) 1323 | if basic: return basic 1324 | ruser = self.environ.get('REMOTE_USER') 1325 | if ruser: return (ruser, None) 1326 | return None 1327 | 1328 | @property 1329 | def remote_route(self): 1330 | """ A list of all IPs that were involved in this request, starting with 1331 | the client IP and followed by zero or more proxies. This does only 1332 | work if all proxies support the ```X-Forwarded-For`` header. Note 1333 | that this information can be forged by malicious clients. """ 1334 | proxy = self.environ.get('HTTP_X_FORWARDED_FOR') 1335 | if proxy: return [ip.strip() for ip in proxy.split(',')] 1336 | remote = self.environ.get('REMOTE_ADDR') 1337 | return [remote] if remote else [] 1338 | 1339 | @property 1340 | def remote_addr(self): 1341 | """ The client IP as a string. Note that this information can be forged 1342 | by malicious clients. """ 1343 | route = self.remote_route 1344 | return route[0] if route else None 1345 | 1346 | def copy(self): 1347 | """ Return a new :class:`Request` with a shallow :attr:`environ` copy. """ 1348 | return Request(self.environ.copy()) 1349 | 1350 | def get(self, value, default=None): return self.environ.get(value, default) 1351 | def __getitem__(self, key): return self.environ[key] 1352 | def __delitem__(self, key): self[key] = ""; del(self.environ[key]) 1353 | def __iter__(self): return iter(self.environ) 1354 | def __len__(self): return len(self.environ) 1355 | def keys(self): return self.environ.keys() 1356 | def __setitem__(self, key, value): 1357 | """ Change an environ value and clear all caches that depend on it. """ 1358 | 1359 | if self.environ.get('bottle.request.readonly'): 1360 | raise KeyError('The environ dictionary is read-only.') 1361 | 1362 | self.environ[key] = value 1363 | todelete = () 1364 | 1365 | if key == 'wsgi.input': 1366 | todelete = ('body', 'forms', 'files', 'params', 'post', 'json') 1367 | elif key == 'QUERY_STRING': 1368 | todelete = ('query', 'params') 1369 | elif key.startswith('HTTP_'): 1370 | todelete = ('headers', 'cookies') 1371 | 1372 | for key in todelete: 1373 | self.environ.pop('bottle.request.'+key, None) 1374 | 1375 | def __repr__(self): 1376 | return '<%s: %s %s>' % (self.__class__.__name__, self.method, self.url) 1377 | 1378 | def __getattr__(self, name): 1379 | ''' Search in self.environ for additional user defined attributes. ''' 1380 | try: 1381 | var = self.environ['bottle.request.ext.%s'%name] 1382 | return var.__get__(self) if hasattr(var, '__get__') else var 1383 | except KeyError: 1384 | raise AttributeError('Attribute %r not defined.' % name) 1385 | 1386 | def __setattr__(self, name, value): 1387 | if name == 'environ': return object.__setattr__(self, name, value) 1388 | self.environ['bottle.request.ext.%s'%name] = value 1389 | 1390 | 1391 | 1392 | 1393 | def _hkey(s): 1394 | return s.title().replace('_','-') 1395 | 1396 | 1397 | class HeaderProperty(object): 1398 | def __init__(self, name, reader=None, writer=str, default=''): 1399 | self.name, self.default = name, default 1400 | self.reader, self.writer = reader, writer 1401 | self.__doc__ = 'Current value of the %r header.' % name.title() 1402 | 1403 | def __get__(self, obj, cls): 1404 | if obj is None: return self 1405 | value = obj.headers.get(self.name, self.default) 1406 | return self.reader(value) if self.reader else value 1407 | 1408 | def __set__(self, obj, value): 1409 | obj.headers[self.name] = self.writer(value) 1410 | 1411 | def __delete__(self, obj): 1412 | del obj.headers[self.name] 1413 | 1414 | 1415 | class BaseResponse(object): 1416 | """ Storage class for a response body as well as headers and cookies. 1417 | 1418 | This class does support dict-like case-insensitive item-access to 1419 | headers, but is NOT a dict. Most notably, iterating over a response 1420 | yields parts of the body and not the headers. 1421 | 1422 | :param body: The response body as one of the supported types. 1423 | :param status: Either an HTTP status code (e.g. 200) or a status line 1424 | including the reason phrase (e.g. '200 OK'). 1425 | :param headers: A dictionary or a list of name-value pairs. 1426 | 1427 | Additional keyword arguments are added to the list of headers. 1428 | Underscores in the header name are replaced with dashes. 1429 | """ 1430 | 1431 | default_status = 200 1432 | default_content_type = 'text/html; charset=UTF-8' 1433 | 1434 | # Header blacklist for specific response codes 1435 | # (rfc2616 section 10.2.3 and 10.3.5) 1436 | bad_headers = { 1437 | 204: set(('Content-Type',)), 1438 | 304: set(('Allow', 'Content-Encoding', 'Content-Language', 1439 | 'Content-Length', 'Content-Range', 'Content-Type', 1440 | 'Content-Md5', 'Last-Modified'))} 1441 | 1442 | def __init__(self, body='', status=None, headers=None, **more_headers): 1443 | self._cookies = None 1444 | self._headers = {} 1445 | self.body = body 1446 | self.status = status or self.default_status 1447 | if headers: 1448 | if isinstance(headers, dict): 1449 | headers = headers.items() 1450 | for name, value in headers: 1451 | self.add_header(name, value) 1452 | if more_headers: 1453 | for name, value in more_headers.items(): 1454 | self.add_header(name, value) 1455 | 1456 | def copy(self, cls=None): 1457 | ''' Returns a copy of self. ''' 1458 | cls = cls or BaseResponse 1459 | assert issubclass(cls, BaseResponse) 1460 | copy = cls() 1461 | copy.status = self.status 1462 | copy._headers = dict((k, v[:]) for (k, v) in self._headers.items()) 1463 | if self._cookies: 1464 | copy._cookies = SimpleCookie() 1465 | copy._cookies.load(self._cookies.output()) 1466 | return copy 1467 | 1468 | def __iter__(self): 1469 | return iter(self.body) 1470 | 1471 | def close(self): 1472 | if hasattr(self.body, 'close'): 1473 | self.body.close() 1474 | 1475 | @property 1476 | def status_line(self): 1477 | ''' The HTTP status line as a string (e.g. ``404 Not Found``).''' 1478 | return self._status_line 1479 | 1480 | @property 1481 | def status_code(self): 1482 | ''' The HTTP status code as an integer (e.g. 404).''' 1483 | return self._status_code 1484 | 1485 | def _set_status(self, status): 1486 | if isinstance(status, int): 1487 | code, status = status, _HTTP_STATUS_LINES.get(status) 1488 | elif ' ' in status: 1489 | status = status.strip() 1490 | code = int(status.split()[0]) 1491 | else: 1492 | raise ValueError('String status line without a reason phrase.') 1493 | if not 100 <= code <= 999: raise ValueError('Status code out of range.') 1494 | self._status_code = code 1495 | self._status_line = str(status or ('%d Unknown' % code)) 1496 | 1497 | def _get_status(self): 1498 | return self._status_line 1499 | 1500 | status = property(_get_status, _set_status, None, 1501 | ''' A writeable property to change the HTTP response status. It accepts 1502 | either a numeric code (100-999) or a string with a custom reason 1503 | phrase (e.g. "404 Brain not found"). Both :data:`status_line` and 1504 | :data:`status_code` are updated accordingly. The return value is 1505 | always a status string. ''') 1506 | del _get_status, _set_status 1507 | 1508 | @property 1509 | def headers(self): 1510 | ''' An instance of :class:`HeaderDict`, a case-insensitive dict-like 1511 | view on the response headers. ''' 1512 | hdict = HeaderDict() 1513 | hdict.dict = self._headers 1514 | return hdict 1515 | 1516 | def __contains__(self, name): return _hkey(name) in self._headers 1517 | def __delitem__(self, name): del self._headers[_hkey(name)] 1518 | def __getitem__(self, name): return self._headers[_hkey(name)][-1] 1519 | def __setitem__(self, name, value): self._headers[_hkey(name)] = [str(value)] 1520 | 1521 | def get_header(self, name, default=None): 1522 | ''' Return the value of a previously defined header. If there is no 1523 | header with that name, return a default value. ''' 1524 | return self._headers.get(_hkey(name), [default])[-1] 1525 | 1526 | def set_header(self, name, value): 1527 | ''' Create a new response header, replacing any previously defined 1528 | headers with the same name. ''' 1529 | self._headers[_hkey(name)] = [str(value)] 1530 | 1531 | def add_header(self, name, value): 1532 | ''' Add an additional response header, not removing duplicates. ''' 1533 | self._headers.setdefault(_hkey(name), []).append(str(value)) 1534 | 1535 | def iter_headers(self): 1536 | ''' Yield (header, value) tuples, skipping headers that are not 1537 | allowed with the current response status code. ''' 1538 | return self.headerlist 1539 | 1540 | @property 1541 | def headerlist(self): 1542 | ''' WSGI conform list of (header, value) tuples. ''' 1543 | out = [] 1544 | headers = list(self._headers.items()) 1545 | if 'Content-Type' not in self._headers: 1546 | headers.append(('Content-Type', [self.default_content_type])) 1547 | if self._status_code in self.bad_headers: 1548 | bad_headers = self.bad_headers[self._status_code] 1549 | headers = [h for h in headers if h[0] not in bad_headers] 1550 | out += [(name, val) for name, vals in headers for val in vals] 1551 | if self._cookies: 1552 | for c in self._cookies.values(): 1553 | out.append(('Set-Cookie', c.OutputString())) 1554 | return out 1555 | 1556 | content_type = HeaderProperty('Content-Type') 1557 | content_length = HeaderProperty('Content-Length', reader=int) 1558 | expires = HeaderProperty('Expires', 1559 | reader=lambda x: datetime.utcfromtimestamp(parse_date(x)), 1560 | writer=lambda x: http_date(x)) 1561 | 1562 | @property 1563 | def charset(self, default='UTF-8'): 1564 | """ Return the charset specified in the content-type header (default: utf8). """ 1565 | if 'charset=' in self.content_type: 1566 | return self.content_type.split('charset=')[-1].split(';')[0].strip() 1567 | return default 1568 | 1569 | def set_cookie(self, name, value, secret=None, **options): 1570 | ''' Create a new cookie or replace an old one. If the `secret` parameter is 1571 | set, create a `Signed Cookie` (described below). 1572 | 1573 | :param name: the name of the cookie. 1574 | :param value: the value of the cookie. 1575 | :param secret: a signature key required for signed cookies. 1576 | 1577 | Additionally, this method accepts all RFC 2109 attributes that are 1578 | supported by :class:`cookie.Morsel`, including: 1579 | 1580 | :param max_age: maximum age in seconds. (default: None) 1581 | :param expires: a datetime object or UNIX timestamp. (default: None) 1582 | :param domain: the domain that is allowed to read the cookie. 1583 | (default: current domain) 1584 | :param path: limits the cookie to a given path (default: current path) 1585 | :param secure: limit the cookie to HTTPS connections (default: off). 1586 | :param httponly: prevents client-side javascript to read this cookie 1587 | (default: off, requires Python 2.6 or newer). 1588 | 1589 | If neither `expires` nor `max_age` is set (default), the cookie will 1590 | expire at the end of the browser session (as soon as the browser 1591 | window is closed). 1592 | 1593 | Signed cookies may store any pickle-able object and are 1594 | cryptographically signed to prevent manipulation. Keep in mind that 1595 | cookies are limited to 4kb in most browsers. 1596 | 1597 | Warning: Signed cookies are not encrypted (the client can still see 1598 | the content) and not copy-protected (the client can restore an old 1599 | cookie). The main intention is to make pickling and unpickling 1600 | save, not to store secret information at client side. 1601 | ''' 1602 | if not self._cookies: 1603 | self._cookies = SimpleCookie() 1604 | 1605 | if secret: 1606 | value = touni(cookie_encode((name, value), secret)) 1607 | elif not isinstance(value, basestring): 1608 | raise TypeError('Secret key missing for non-string Cookie.') 1609 | 1610 | if len(value) > 4096: raise ValueError('Cookie value to long.') 1611 | self._cookies[name] = value 1612 | 1613 | for key, value in options.items(): 1614 | if key == 'max_age': 1615 | if isinstance(value, timedelta): 1616 | value = value.seconds + value.days * 24 * 3600 1617 | if key == 'expires': 1618 | if isinstance(value, (datedate, datetime)): 1619 | value = value.timetuple() 1620 | elif isinstance(value, (int, float)): 1621 | value = time.gmtime(value) 1622 | value = time.strftime("%a, %d %b %Y %H:%M:%S GMT", value) 1623 | self._cookies[name][key.replace('_', '-')] = value 1624 | 1625 | def delete_cookie(self, key, **kwargs): 1626 | ''' Delete a cookie. Be sure to use the same `domain` and `path` 1627 | settings as used to create the cookie. ''' 1628 | kwargs['max_age'] = -1 1629 | kwargs['expires'] = 0 1630 | self.set_cookie(key, '', **kwargs) 1631 | 1632 | def __repr__(self): 1633 | out = '' 1634 | for name, value in self.headerlist: 1635 | out += '%s: %s\n' % (name.title(), value.strip()) 1636 | return out 1637 | 1638 | 1639 | def _local_property(): 1640 | ls = threading.local() 1641 | def fget(self): 1642 | try: return ls.var 1643 | except AttributeError: 1644 | raise RuntimeError("Request context not initialized.") 1645 | def fset(self, value): ls.var = value 1646 | def fdel(self): del ls.var 1647 | return property(fget, fset, fdel, 'Thread-local property') 1648 | 1649 | 1650 | class LocalRequest(BaseRequest): 1651 | ''' A thread-local subclass of :class:`BaseRequest` with a different 1652 | set of attributes for each thread. There is usually only one global 1653 | instance of this class (:data:`request`). If accessed during a 1654 | request/response cycle, this instance always refers to the *current* 1655 | request (even on a multithreaded server). ''' 1656 | bind = BaseRequest.__init__ 1657 | environ = _local_property() 1658 | 1659 | 1660 | class LocalResponse(BaseResponse): 1661 | ''' A thread-local subclass of :class:`BaseResponse` with a different 1662 | set of attributes for each thread. There is usually only one global 1663 | instance of this class (:data:`response`). Its attributes are used 1664 | to build the HTTP response at the end of the request/response cycle. 1665 | ''' 1666 | bind = BaseResponse.__init__ 1667 | _status_line = _local_property() 1668 | _status_code = _local_property() 1669 | _cookies = _local_property() 1670 | _headers = _local_property() 1671 | body = _local_property() 1672 | 1673 | 1674 | Request = BaseRequest 1675 | Response = BaseResponse 1676 | 1677 | 1678 | class HTTPResponse(Response, BottleException): 1679 | def __init__(self, body='', status=None, headers=None, **more_headers): 1680 | super(HTTPResponse, self).__init__(body, status, headers, **more_headers) 1681 | 1682 | def apply(self, response): 1683 | response._status_code = self._status_code 1684 | response._status_line = self._status_line 1685 | response._headers = self._headers 1686 | response._cookies = self._cookies 1687 | response.body = self.body 1688 | 1689 | 1690 | class HTTPError(HTTPResponse): 1691 | default_status = 500 1692 | def __init__(self, status=None, body=None, exception=None, traceback=None, 1693 | **options): 1694 | self.exception = exception 1695 | self.traceback = traceback 1696 | super(HTTPError, self).__init__(body, status, **options) 1697 | 1698 | 1699 | 1700 | 1701 | 1702 | ############################################################################### 1703 | # Plugins ###################################################################### 1704 | ############################################################################### 1705 | 1706 | class PluginError(BottleException): pass 1707 | 1708 | 1709 | class JSONPlugin(object): 1710 | name = 'json' 1711 | api = 2 1712 | 1713 | def __init__(self, json_dumps=json_dumps): 1714 | self.json_dumps = json_dumps 1715 | 1716 | def apply(self, callback, route): 1717 | dumps = self.json_dumps 1718 | if not dumps: return callback 1719 | def wrapper(*a, **ka): 1720 | try: 1721 | rv = callback(*a, **ka) 1722 | except HTTPError: 1723 | rv = _e() 1724 | 1725 | if isinstance(rv, dict): 1726 | #Attempt to serialize, raises exception on failure 1727 | json_response = dumps(rv) 1728 | #Set content type only if serialization succesful 1729 | response.content_type = 'application/json' 1730 | return json_response 1731 | elif isinstance(rv, HTTPResponse) and isinstance(rv.body, dict): 1732 | rv.body = dumps(rv.body) 1733 | rv.content_type = 'application/json' 1734 | return rv 1735 | 1736 | return wrapper 1737 | 1738 | 1739 | class TemplatePlugin(object): 1740 | ''' This plugin applies the :func:`view` decorator to all routes with a 1741 | `template` config parameter. If the parameter is a tuple, the second 1742 | element must be a dict with additional options (e.g. `template_engine`) 1743 | or default variables for the template. ''' 1744 | name = 'template' 1745 | api = 2 1746 | 1747 | def apply(self, callback, route): 1748 | conf = route.config.get('template') 1749 | if isinstance(conf, (tuple, list)) and len(conf) == 2: 1750 | return view(conf[0], **conf[1])(callback) 1751 | elif isinstance(conf, str): 1752 | return view(conf)(callback) 1753 | else: 1754 | return callback 1755 | 1756 | 1757 | #: Not a plugin, but part of the plugin API. TODO: Find a better place. 1758 | class _ImportRedirect(object): 1759 | def __init__(self, name, impmask): 1760 | ''' Create a virtual package that redirects imports (see PEP 302). ''' 1761 | self.name = name 1762 | self.impmask = impmask 1763 | self.module = sys.modules.setdefault(name, imp.new_module(name)) 1764 | self.module.__dict__.update({'__file__': __file__, '__path__': [], 1765 | '__all__': [], '__loader__': self}) 1766 | sys.meta_path.append(self) 1767 | 1768 | def find_module(self, fullname, path=None): 1769 | if '.' not in fullname: return 1770 | packname = fullname.rsplit('.', 1)[0] 1771 | if packname != self.name: return 1772 | return self 1773 | 1774 | def load_module(self, fullname): 1775 | if fullname in sys.modules: return sys.modules[fullname] 1776 | modname = fullname.rsplit('.', 1)[1] 1777 | realname = self.impmask % modname 1778 | __import__(realname) 1779 | module = sys.modules[fullname] = sys.modules[realname] 1780 | setattr(self.module, modname, module) 1781 | module.__loader__ = self 1782 | return module 1783 | 1784 | 1785 | 1786 | 1787 | 1788 | 1789 | ############################################################################### 1790 | # Common Utilities ############################################################# 1791 | ############################################################################### 1792 | 1793 | 1794 | class MultiDict(DictMixin): 1795 | """ This dict stores multiple values per key, but behaves exactly like a 1796 | normal dict in that it returns only the newest value for any given key. 1797 | There are special methods available to access the full list of values. 1798 | """ 1799 | 1800 | def __init__(self, *a, **k): 1801 | self.dict = dict((k, [v]) for (k, v) in dict(*a, **k).items()) 1802 | 1803 | def __len__(self): return len(self.dict) 1804 | def __iter__(self): return iter(self.dict) 1805 | def __contains__(self, key): return key in self.dict 1806 | def __delitem__(self, key): del self.dict[key] 1807 | def __getitem__(self, key): return self.dict[key][-1] 1808 | def __setitem__(self, key, value): self.append(key, value) 1809 | def keys(self): return self.dict.keys() 1810 | 1811 | if py3k: 1812 | def values(self): return (v[-1] for v in self.dict.values()) 1813 | def items(self): return ((k, v[-1]) for k, v in self.dict.items()) 1814 | def allitems(self): 1815 | return ((k, v) for k, vl in self.dict.items() for v in vl) 1816 | iterkeys = keys 1817 | itervalues = values 1818 | iteritems = items 1819 | iterallitems = allitems 1820 | 1821 | else: 1822 | def values(self): return [v[-1] for v in self.dict.values()] 1823 | def items(self): return [(k, v[-1]) for k, v in self.dict.items()] 1824 | def iterkeys(self): return self.dict.iterkeys() 1825 | def itervalues(self): return (v[-1] for v in self.dict.itervalues()) 1826 | def iteritems(self): 1827 | return ((k, v[-1]) for k, v in self.dict.iteritems()) 1828 | def iterallitems(self): 1829 | return ((k, v) for k, vl in self.dict.iteritems() for v in vl) 1830 | def allitems(self): 1831 | return [(k, v) for k, vl in self.dict.iteritems() for v in vl] 1832 | 1833 | def get(self, key, default=None, index=-1, type=None): 1834 | ''' Return the most recent value for a key. 1835 | 1836 | :param default: The default value to be returned if the key is not 1837 | present or the type conversion fails. 1838 | :param index: An index for the list of available values. 1839 | :param type: If defined, this callable is used to cast the value 1840 | into a specific type. Exception are suppressed and result in 1841 | the default value to be returned. 1842 | ''' 1843 | try: 1844 | val = self.dict[key][index] 1845 | return type(val) if type else val 1846 | except Exception: 1847 | pass 1848 | return default 1849 | 1850 | def append(self, key, value): 1851 | ''' Add a new value to the list of values for this key. ''' 1852 | self.dict.setdefault(key, []).append(value) 1853 | 1854 | def replace(self, key, value): 1855 | ''' Replace the list of values with a single value. ''' 1856 | self.dict[key] = [value] 1857 | 1858 | def getall(self, key): 1859 | ''' Return a (possibly empty) list of values for a key. ''' 1860 | return self.dict.get(key) or [] 1861 | 1862 | #: Aliases for WTForms to mimic other multi-dict APIs (Django) 1863 | getone = get 1864 | getlist = getall 1865 | 1866 | 1867 | class FormsDict(MultiDict): 1868 | ''' This :class:`MultiDict` subclass is used to store request form data. 1869 | Additionally to the normal dict-like item access methods (which return 1870 | unmodified data as native strings), this container also supports 1871 | attribute-like access to its values. Attributes are automatically de- 1872 | or recoded to match :attr:`input_encoding` (default: 'utf8'). Missing 1873 | attributes default to an empty string. ''' 1874 | 1875 | #: Encoding used for attribute values. 1876 | input_encoding = 'utf8' 1877 | #: If true (default), unicode strings are first encoded with `latin1` 1878 | #: and then decoded to match :attr:`input_encoding`. 1879 | recode_unicode = True 1880 | 1881 | def _fix(self, s, encoding=None): 1882 | if isinstance(s, unicode) and self.recode_unicode: # Python 3 WSGI 1883 | return s.encode('latin1').decode(encoding or self.input_encoding) 1884 | elif isinstance(s, bytes): # Python 2 WSGI 1885 | return s.decode(encoding or self.input_encoding) 1886 | else: 1887 | return s 1888 | 1889 | def decode(self, encoding=None): 1890 | ''' Returns a copy with all keys and values de- or recoded to match 1891 | :attr:`input_encoding`. Some libraries (e.g. WTForms) want a 1892 | unicode dictionary. ''' 1893 | copy = FormsDict() 1894 | enc = copy.input_encoding = encoding or self.input_encoding 1895 | copy.recode_unicode = False 1896 | for key, value in self.allitems(): 1897 | copy.append(self._fix(key, enc), self._fix(value, enc)) 1898 | return copy 1899 | 1900 | def getunicode(self, name, default=None, encoding=None): 1901 | ''' Return the value as a unicode string, or the default. ''' 1902 | try: 1903 | return self._fix(self[name], encoding) 1904 | except (UnicodeError, KeyError): 1905 | return default 1906 | 1907 | def __getattr__(self, name, default=unicode()): 1908 | # Without this guard, pickle generates a cryptic TypeError: 1909 | if name.startswith('__') and name.endswith('__'): 1910 | return super(FormsDict, self).__getattr__(name) 1911 | return self.getunicode(name, default=default) 1912 | 1913 | 1914 | class HeaderDict(MultiDict): 1915 | """ A case-insensitive version of :class:`MultiDict` that defaults to 1916 | replace the old value instead of appending it. """ 1917 | 1918 | def __init__(self, *a, **ka): 1919 | self.dict = {} 1920 | if a or ka: self.update(*a, **ka) 1921 | 1922 | def __contains__(self, key): return _hkey(key) in self.dict 1923 | def __delitem__(self, key): del self.dict[_hkey(key)] 1924 | def __getitem__(self, key): return self.dict[_hkey(key)][-1] 1925 | def __setitem__(self, key, value): self.dict[_hkey(key)] = [str(value)] 1926 | def append(self, key, value): 1927 | self.dict.setdefault(_hkey(key), []).append(str(value)) 1928 | def replace(self, key, value): self.dict[_hkey(key)] = [str(value)] 1929 | def getall(self, key): return self.dict.get(_hkey(key)) or [] 1930 | def get(self, key, default=None, index=-1): 1931 | return MultiDict.get(self, _hkey(key), default, index) 1932 | def filter(self, names): 1933 | for name in [_hkey(n) for n in names]: 1934 | if name in self.dict: 1935 | del self.dict[name] 1936 | 1937 | 1938 | class WSGIHeaderDict(DictMixin): 1939 | ''' This dict-like class wraps a WSGI environ dict and provides convenient 1940 | access to HTTP_* fields. Keys and values are native strings 1941 | (2.x bytes or 3.x unicode) and keys are case-insensitive. If the WSGI 1942 | environment contains non-native string values, these are de- or encoded 1943 | using a lossless 'latin1' character set. 1944 | 1945 | The API will remain stable even on changes to the relevant PEPs. 1946 | Currently PEP 333, 444 and 3333 are supported. (PEP 444 is the only one 1947 | that uses non-native strings.) 1948 | ''' 1949 | #: List of keys that do not have a ``HTTP_`` prefix. 1950 | cgikeys = ('CONTENT_TYPE', 'CONTENT_LENGTH') 1951 | 1952 | def __init__(self, environ): 1953 | self.environ = environ 1954 | 1955 | def _ekey(self, key): 1956 | ''' Translate header field name to CGI/WSGI environ key. ''' 1957 | key = key.replace('-','_').upper() 1958 | if key in self.cgikeys: 1959 | return key 1960 | return 'HTTP_' + key 1961 | 1962 | def raw(self, key, default=None): 1963 | ''' Return the header value as is (may be bytes or unicode). ''' 1964 | return self.environ.get(self._ekey(key), default) 1965 | 1966 | def __getitem__(self, key): 1967 | return tonat(self.environ[self._ekey(key)], 'latin1') 1968 | 1969 | def __setitem__(self, key, value): 1970 | raise TypeError("%s is read-only." % self.__class__) 1971 | 1972 | def __delitem__(self, key): 1973 | raise TypeError("%s is read-only." % self.__class__) 1974 | 1975 | def __iter__(self): 1976 | for key in self.environ: 1977 | if key[:5] == 'HTTP_': 1978 | yield key[5:].replace('_', '-').title() 1979 | elif key in self.cgikeys: 1980 | yield key.replace('_', '-').title() 1981 | 1982 | def keys(self): return [x for x in self] 1983 | def __len__(self): return len(self.keys()) 1984 | def __contains__(self, key): return self._ekey(key) in self.environ 1985 | 1986 | 1987 | 1988 | class ConfigDict(dict): 1989 | ''' A dict-like configuration storage with additional support for 1990 | namespaces, validators, meta-data, on_change listeners and more. 1991 | ''' 1992 | 1993 | __slots__ = ('_meta', '_on_change') 1994 | 1995 | def __init__(self): 1996 | self._meta = {} 1997 | self._on_change = lambda name, value: None 1998 | 1999 | def load_config(self, filename): 2000 | ''' Load values from an ``*.ini`` style config file. 2001 | 2002 | If the config file contains sections, their names are used as 2003 | namespaces for the values within. The two special sections 2004 | ``DEFAULT`` and ``bottle`` refer to the root namespace (no prefix). 2005 | ''' 2006 | conf = ConfigParser() 2007 | conf.read(filename) 2008 | for section in conf.sections(): 2009 | for key, value in conf.items(section): 2010 | if section not in ('DEFAULT', 'bottle'): 2011 | key = section + '.' + key 2012 | self[key] = value 2013 | return self 2014 | 2015 | def load_dict(self, source, namespace=''): 2016 | ''' Load values from a dictionary structure. Nesting can be used to 2017 | represent namespaces. 2018 | 2019 | >>> c = ConfigDict() 2020 | >>> c.load_dict({'some': {'namespace': {'key': 'value'} } }) 2021 | {'some.namespace.key': 'value'} 2022 | ''' 2023 | for key, value in source.items(): 2024 | if isinstance(key, str): 2025 | nskey = (namespace + '.' + key).strip('.') 2026 | if isinstance(value, dict): 2027 | self.load_dict(value, namespace=nskey) 2028 | else: 2029 | self[nskey] = value 2030 | else: 2031 | raise TypeError('Key has type %r (not a string)' % type(key)) 2032 | return self 2033 | 2034 | def update(self, *a, **ka): 2035 | ''' If the first parameter is a string, all keys are prefixed with this 2036 | namespace. Apart from that it works just as the usual dict.update(). 2037 | Example: ``update('some.namespace', key='value')`` ''' 2038 | prefix = '' 2039 | if a and isinstance(a[0], str): 2040 | prefix = a[0].strip('.') + '.' 2041 | a = a[1:] 2042 | for key, value in dict(*a, **ka).items(): 2043 | self[prefix+key] = value 2044 | 2045 | def setdefault(self, key, value): 2046 | if key not in self: 2047 | self[key] = value 2048 | 2049 | def __setitem__(self, key, value): 2050 | if not isinstance(key, str): 2051 | raise TypeError('Key has type %r (not a string)' % type(key)) 2052 | value = self.meta_get(key, 'filter', lambda x: x)(value) 2053 | if key in self and self[key] is value: 2054 | return 2055 | self._on_change(key, value) 2056 | dict.__setitem__(self, key, value) 2057 | 2058 | def __delitem__(self, key): 2059 | self._on_change(key, None) 2060 | dict.__delitem__(self, key) 2061 | 2062 | def meta_get(self, key, metafield, default=None): 2063 | ''' Return the value of a meta field for a key. ''' 2064 | return self._meta.get(key, {}).get(metafield, default) 2065 | 2066 | def meta_set(self, key, metafield, value): 2067 | ''' Set the meta field for a key to a new value. This triggers the 2068 | on-change handler for existing keys. ''' 2069 | self._meta.setdefault(key, {})[metafield] = value 2070 | if key in self: 2071 | self[key] = self[key] 2072 | 2073 | def meta_list(self, key): 2074 | ''' Return an iterable of meta field names defined for a key. ''' 2075 | return self._meta.get(key, {}).keys() 2076 | 2077 | 2078 | class AppStack(list): 2079 | """ A stack-like list. Calling it returns the head of the stack. """ 2080 | 2081 | def __call__(self): 2082 | """ Return the current default application. """ 2083 | return self[-1] 2084 | 2085 | def push(self, value=None): 2086 | """ Add a new :class:`Bottle` instance to the stack """ 2087 | if not isinstance(value, Bottle): 2088 | value = Bottle() 2089 | self.append(value) 2090 | return value 2091 | 2092 | 2093 | class WSGIFileWrapper(object): 2094 | 2095 | def __init__(self, fp, buffer_size=1024*64): 2096 | self.fp, self.buffer_size = fp, buffer_size 2097 | for attr in ('fileno', 'close', 'read', 'readlines', 'tell', 'seek'): 2098 | if hasattr(fp, attr): setattr(self, attr, getattr(fp, attr)) 2099 | 2100 | def __iter__(self): 2101 | buff, read = self.buffer_size, self.read 2102 | while True: 2103 | part = read(buff) 2104 | if not part: return 2105 | yield part 2106 | 2107 | 2108 | class _closeiter(object): 2109 | ''' This only exists to be able to attach a .close method to iterators that 2110 | do not support attribute assignment (most of itertools). ''' 2111 | 2112 | def __init__(self, iterator, close=None): 2113 | self.iterator = iterator 2114 | self.close_callbacks = makelist(close) 2115 | 2116 | def __iter__(self): 2117 | return iter(self.iterator) 2118 | 2119 | def close(self): 2120 | for func in self.close_callbacks: 2121 | func() 2122 | 2123 | 2124 | class ResourceManager(object): 2125 | ''' This class manages a list of search paths and helps to find and open 2126 | application-bound resources (files). 2127 | 2128 | :param base: default value for :meth:`add_path` calls. 2129 | :param opener: callable used to open resources. 2130 | :param cachemode: controls which lookups are cached. One of 'all', 2131 | 'found' or 'none'. 2132 | ''' 2133 | 2134 | def __init__(self, base='./', opener=open, cachemode='all'): 2135 | self.opener = open 2136 | self.base = base 2137 | self.cachemode = cachemode 2138 | 2139 | #: A list of search paths. See :meth:`add_path` for details. 2140 | self.path = [] 2141 | #: A cache for resolved paths. ``res.cache.clear()`` clears the cache. 2142 | self.cache = {} 2143 | 2144 | def add_path(self, path, base=None, index=None, create=False): 2145 | ''' Add a new path to the list of search paths. Return False if the 2146 | path does not exist. 2147 | 2148 | :param path: The new search path. Relative paths are turned into 2149 | an absolute and normalized form. If the path looks like a file 2150 | (not ending in `/`), the filename is stripped off. 2151 | :param base: Path used to absolutize relative search paths. 2152 | Defaults to :attr:`base` which defaults to ``os.getcwd()``. 2153 | :param index: Position within the list of search paths. Defaults 2154 | to last index (appends to the list). 2155 | 2156 | The `base` parameter makes it easy to reference files installed 2157 | along with a python module or package:: 2158 | 2159 | res.add_path('./resources/', __file__) 2160 | ''' 2161 | base = os.path.abspath(os.path.dirname(base or self.base)) 2162 | path = os.path.abspath(os.path.join(base, os.path.dirname(path))) 2163 | path += os.sep 2164 | if path in self.path: 2165 | self.path.remove(path) 2166 | if create and not os.path.isdir(path): 2167 | os.makedirs(path) 2168 | if index is None: 2169 | self.path.append(path) 2170 | else: 2171 | self.path.insert(index, path) 2172 | self.cache.clear() 2173 | return os.path.exists(path) 2174 | 2175 | def __iter__(self): 2176 | ''' Iterate over all existing files in all registered paths. ''' 2177 | search = self.path[:] 2178 | while search: 2179 | path = search.pop() 2180 | if not os.path.isdir(path): continue 2181 | for name in os.listdir(path): 2182 | full = os.path.join(path, name) 2183 | if os.path.isdir(full): search.append(full) 2184 | else: yield full 2185 | 2186 | def lookup(self, name): 2187 | ''' Search for a resource and return an absolute file path, or `None`. 2188 | 2189 | The :attr:`path` list is searched in order. The first match is 2190 | returend. Symlinks are followed. The result is cached to speed up 2191 | future lookups. ''' 2192 | if name not in self.cache or DEBUG: 2193 | for path in self.path: 2194 | fpath = os.path.join(path, name) 2195 | if os.path.isfile(fpath): 2196 | if self.cachemode in ('all', 'found'): 2197 | self.cache[name] = fpath 2198 | return fpath 2199 | if self.cachemode == 'all': 2200 | self.cache[name] = None 2201 | return self.cache[name] 2202 | 2203 | def open(self, name, mode='r', *args, **kwargs): 2204 | ''' Find a resource and return a file object, or raise IOError. ''' 2205 | fname = self.lookup(name) 2206 | if not fname: raise IOError("Resource %r not found." % name) 2207 | return self.opener(fname, mode=mode, *args, **kwargs) 2208 | 2209 | 2210 | class FileUpload(object): 2211 | 2212 | def __init__(self, fileobj, name, filename, headers=None): 2213 | ''' Wrapper for file uploads. ''' 2214 | #: Open file(-like) object (BytesIO buffer or temporary file) 2215 | self.file = fileobj 2216 | #: Name of the upload form field 2217 | self.name = name 2218 | #: Raw filename as sent by the client (may contain unsafe characters) 2219 | self.raw_filename = filename 2220 | #: A :class:`HeaderDict` with additional headers (e.g. content-type) 2221 | self.headers = HeaderDict(headers) if headers else HeaderDict() 2222 | 2223 | content_type = HeaderProperty('Content-Type') 2224 | content_length = HeaderProperty('Content-Length', reader=int, default=-1) 2225 | 2226 | @cached_property 2227 | def filename(self): 2228 | ''' Name of the file on the client file system, but normalized to ensure 2229 | file system compatibility. An empty filename is returned as 'empty'. 2230 | 2231 | Only ASCII letters, digits, dashes, underscores and dots are 2232 | allowed in the final filename. Accents are removed, if possible. 2233 | Whitespace is replaced by a single dash. Leading or tailing dots 2234 | or dashes are removed. The filename is limited to 255 characters. 2235 | ''' 2236 | fname = self.raw_filename 2237 | if not isinstance(fname, unicode): 2238 | fname = fname.decode('utf8', 'ignore') 2239 | fname = normalize('NFKD', fname).encode('ASCII', 'ignore').decode('ASCII') 2240 | fname = os.path.basename(fname.replace('\\', os.path.sep)) 2241 | fname = re.sub(r'[^a-zA-Z0-9-_.\s]', '', fname).strip() 2242 | fname = re.sub(r'[-\s]+', '-', fname).strip('.-') 2243 | return fname[:255] or 'empty' 2244 | 2245 | def _copy_file(self, fp, chunk_size=2**16): 2246 | read, write, offset = self.file.read, fp.write, self.file.tell() 2247 | while 1: 2248 | buf = read(chunk_size) 2249 | if not buf: break 2250 | write(buf) 2251 | self.file.seek(offset) 2252 | 2253 | def save(self, destination, overwrite=False, chunk_size=2**16): 2254 | ''' Save file to disk or copy its content to an open file(-like) object. 2255 | If *destination* is a directory, :attr:`filename` is added to the 2256 | path. Existing files are not overwritten by default (IOError). 2257 | 2258 | :param destination: File path, directory or file(-like) object. 2259 | :param overwrite: If True, replace existing files. (default: False) 2260 | :param chunk_size: Bytes to read at a time. (default: 64kb) 2261 | ''' 2262 | if isinstance(destination, basestring): # Except file-likes here 2263 | if os.path.isdir(destination): 2264 | destination = os.path.join(destination, self.filename) 2265 | if not overwrite and os.path.exists(destination): 2266 | raise IOError('File exists.') 2267 | with open(destination, 'wb') as fp: 2268 | self._copy_file(fp, chunk_size) 2269 | else: 2270 | self._copy_file(destination, chunk_size) 2271 | 2272 | 2273 | 2274 | 2275 | 2276 | 2277 | ############################################################################### 2278 | # Application Helper ########################################################### 2279 | ############################################################################### 2280 | 2281 | 2282 | def abort(code=500, text='Unknown Error.'): 2283 | """ Aborts execution and causes a HTTP error. """ 2284 | raise HTTPError(code, text) 2285 | 2286 | 2287 | def redirect(url, code=None): 2288 | """ Aborts execution and causes a 303 or 302 redirect, depending on 2289 | the HTTP protocol version. """ 2290 | if not code: 2291 | code = 303 if request.get('SERVER_PROTOCOL') == "HTTP/1.1" else 302 2292 | res = response.copy(cls=HTTPResponse) 2293 | res.status = code 2294 | res.body = "" 2295 | res.set_header('Location', urljoin(request.url, url)) 2296 | raise res 2297 | 2298 | 2299 | def _file_iter_range(fp, offset, bytes, maxread=1024*1024): 2300 | ''' Yield chunks from a range in a file. No chunk is bigger than maxread.''' 2301 | fp.seek(offset) 2302 | while bytes > 0: 2303 | part = fp.read(min(bytes, maxread)) 2304 | if not part: break 2305 | bytes -= len(part) 2306 | yield part 2307 | 2308 | 2309 | def static_file(filename, root, mimetype='auto', download=False, charset='UTF-8'): 2310 | """ Open a file in a safe way and return :exc:`HTTPResponse` with status 2311 | code 200, 305, 403 or 404. The ``Content-Type``, ``Content-Encoding``, 2312 | ``Content-Length`` and ``Last-Modified`` headers are set if possible. 2313 | Special support for ``If-Modified-Since``, ``Range`` and ``HEAD`` 2314 | requests. 2315 | 2316 | :param filename: Name or path of the file to send. 2317 | :param root: Root path for file lookups. Should be an absolute directory 2318 | path. 2319 | :param mimetype: Defines the content-type header (default: guess from 2320 | file extension) 2321 | :param download: If True, ask the browser to open a `Save as...` dialog 2322 | instead of opening the file with the associated program. You can 2323 | specify a custom filename as a string. If not specified, the 2324 | original filename is used (default: False). 2325 | :param charset: The charset to use for files with a ``text/*`` 2326 | mime-type. (default: UTF-8) 2327 | """ 2328 | 2329 | root = os.path.abspath(root) + os.sep 2330 | filename = os.path.abspath(os.path.join(root, filename.strip('/\\'))) 2331 | headers = dict() 2332 | 2333 | if not filename.startswith(root): 2334 | return HTTPError(403, "Access denied.") 2335 | if not os.path.exists(filename) or not os.path.isfile(filename): 2336 | return HTTPError(404, "File does not exist.") 2337 | if not os.access(filename, os.R_OK): 2338 | return HTTPError(403, "You do not have permission to access this file.") 2339 | 2340 | if mimetype == 'auto': 2341 | mimetype, encoding = mimetypes.guess_type(filename) 2342 | if encoding: headers['Content-Encoding'] = encoding 2343 | 2344 | if mimetype: 2345 | if mimetype[:5] == 'text/' and charset and 'charset' not in mimetype: 2346 | mimetype += '; charset=%s' % charset 2347 | headers['Content-Type'] = mimetype 2348 | 2349 | if download: 2350 | download = os.path.basename(filename if download == True else download) 2351 | headers['Content-Disposition'] = 'attachment; filename="%s"' % download 2352 | 2353 | stats = os.stat(filename) 2354 | headers['Content-Length'] = clen = stats.st_size 2355 | lm = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(stats.st_mtime)) 2356 | headers['Last-Modified'] = lm 2357 | 2358 | ims = request.environ.get('HTTP_IF_MODIFIED_SINCE') 2359 | if ims: 2360 | ims = parse_date(ims.split(";")[0].strip()) 2361 | if ims is not None and ims >= int(stats.st_mtime): 2362 | headers['Date'] = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime()) 2363 | return HTTPResponse(status=304, **headers) 2364 | 2365 | body = '' if request.method == 'HEAD' else open(filename, 'rb') 2366 | 2367 | headers["Accept-Ranges"] = "bytes" 2368 | ranges = request.environ.get('HTTP_RANGE') 2369 | if 'HTTP_RANGE' in request.environ: 2370 | ranges = list(parse_range_header(request.environ['HTTP_RANGE'], clen)) 2371 | if not ranges: 2372 | return HTTPError(416, "Requested Range Not Satisfiable") 2373 | offset, end = ranges[0] 2374 | headers["Content-Range"] = "bytes %d-%d/%d" % (offset, end-1, clen) 2375 | headers["Content-Length"] = str(end-offset) 2376 | if body: body = _file_iter_range(body, offset, end-offset) 2377 | return HTTPResponse(body, status=206, **headers) 2378 | return HTTPResponse(body, **headers) 2379 | 2380 | 2381 | 2382 | 2383 | 2384 | 2385 | ############################################################################### 2386 | # HTTP Utilities and MISC (TODO) ############################################### 2387 | ############################################################################### 2388 | 2389 | 2390 | def debug(mode=True): 2391 | """ Change the debug level. 2392 | There is only one debug level supported at the moment.""" 2393 | global DEBUG 2394 | if mode: warnings.simplefilter('default') 2395 | DEBUG = bool(mode) 2396 | 2397 | def http_date(value): 2398 | if isinstance(value, (datedate, datetime)): 2399 | value = value.utctimetuple() 2400 | elif isinstance(value, (int, float)): 2401 | value = time.gmtime(value) 2402 | if not isinstance(value, basestring): 2403 | value = time.strftime("%a, %d %b %Y %H:%M:%S GMT", value) 2404 | return value 2405 | 2406 | def parse_date(ims): 2407 | """ Parse rfc1123, rfc850 and asctime timestamps and return UTC epoch. """ 2408 | try: 2409 | ts = email.utils.parsedate_tz(ims) 2410 | return time.mktime(ts[:8] + (0,)) - (ts[9] or 0) - time.timezone 2411 | except (TypeError, ValueError, IndexError, OverflowError): 2412 | return None 2413 | 2414 | def parse_auth(header): 2415 | """ Parse rfc2617 HTTP authentication header string (basic) and return (user,pass) tuple or None""" 2416 | try: 2417 | method, data = header.split(None, 1) 2418 | if method.lower() == 'basic': 2419 | user, pwd = touni(base64.b64decode(tob(data))).split(':',1) 2420 | return user, pwd 2421 | except (KeyError, ValueError): 2422 | return None 2423 | 2424 | def parse_range_header(header, maxlen=0): 2425 | ''' Yield (start, end) ranges parsed from a HTTP Range header. Skip 2426 | unsatisfiable ranges. The end index is non-inclusive.''' 2427 | if not header or header[:6] != 'bytes=': return 2428 | ranges = [r.split('-', 1) for r in header[6:].split(',') if '-' in r] 2429 | for start, end in ranges: 2430 | try: 2431 | if not start: # bytes=-100 -> last 100 bytes 2432 | start, end = max(0, maxlen-int(end)), maxlen 2433 | elif not end: # bytes=100- -> all but the first 99 bytes 2434 | start, end = int(start), maxlen 2435 | else: # bytes=100-200 -> bytes 100-200 (inclusive) 2436 | start, end = int(start), min(int(end)+1, maxlen) 2437 | if 0 <= start < end <= maxlen: 2438 | yield start, end 2439 | except ValueError: 2440 | pass 2441 | 2442 | def _parse_qsl(qs): 2443 | r = [] 2444 | for pair in qs.replace(';','&').split('&'): 2445 | if not pair: continue 2446 | nv = pair.split('=', 1) 2447 | if len(nv) != 2: nv.append('') 2448 | key = urlunquote(nv[0].replace('+', ' ')) 2449 | value = urlunquote(nv[1].replace('+', ' ')) 2450 | r.append((key, value)) 2451 | return r 2452 | 2453 | def _lscmp(a, b): 2454 | ''' Compares two strings in a cryptographically safe way: 2455 | Runtime is not affected by length of common prefix. ''' 2456 | return not sum(0 if x==y else 1 for x, y in zip(a, b)) and len(a) == len(b) 2457 | 2458 | 2459 | def cookie_encode(data, key): 2460 | ''' Encode and sign a pickle-able object. Return a (byte) string ''' 2461 | msg = base64.b64encode(pickle.dumps(data, -1)) 2462 | sig = base64.b64encode(hmac.new(tob(key), msg).digest()) 2463 | return tob('!') + sig + tob('?') + msg 2464 | 2465 | 2466 | def cookie_decode(data, key): 2467 | ''' Verify and decode an encoded string. Return an object or None.''' 2468 | data = tob(data) 2469 | if cookie_is_encoded(data): 2470 | sig, msg = data.split(tob('?'), 1) 2471 | if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(key), msg).digest())): 2472 | return pickle.loads(base64.b64decode(msg)) 2473 | return None 2474 | 2475 | 2476 | def cookie_is_encoded(data): 2477 | ''' Return True if the argument looks like a encoded cookie.''' 2478 | return bool(data.startswith(tob('!')) and tob('?') in data) 2479 | 2480 | 2481 | def html_escape(string): 2482 | ''' Escape HTML special characters ``&<>`` and quotes ``'"``. ''' 2483 | return string.replace('&','&').replace('<','<').replace('>','>')\ 2484 | .replace('"','"').replace("'",''') 2485 | 2486 | 2487 | def html_quote(string): 2488 | ''' Escape and quote a string to be used as an HTTP attribute.''' 2489 | return '"%s"' % html_escape(string).replace('\n',' ')\ 2490 | .replace('\r',' ').replace('\t',' ') 2491 | 2492 | 2493 | def yieldroutes(func): 2494 | """ Return a generator for routes that match the signature (name, args) 2495 | of the func parameter. This may yield more than one route if the function 2496 | takes optional keyword arguments. The output is best described by example:: 2497 | 2498 | a() -> '/a' 2499 | b(x, y) -> '/b//' 2500 | c(x, y=5) -> '/c/' and '/c//' 2501 | d(x=5, y=6) -> '/d' and '/d/' and '/d//' 2502 | """ 2503 | path = '/' + func.__name__.replace('__','/').lstrip('/') 2504 | spec = getargspec(func) 2505 | argc = len(spec[0]) - len(spec[3] or []) 2506 | path += ('/<%s>' * argc) % tuple(spec[0][:argc]) 2507 | yield path 2508 | for arg in spec[0][argc:]: 2509 | path += '/<%s>' % arg 2510 | yield path 2511 | 2512 | 2513 | def path_shift(script_name, path_info, shift=1): 2514 | ''' Shift path fragments from PATH_INFO to SCRIPT_NAME and vice versa. 2515 | 2516 | :return: The modified paths. 2517 | :param script_name: The SCRIPT_NAME path. 2518 | :param script_name: The PATH_INFO path. 2519 | :param shift: The number of path fragments to shift. May be negative to 2520 | change the shift direction. (default: 1) 2521 | ''' 2522 | if shift == 0: return script_name, path_info 2523 | pathlist = path_info.strip('/').split('/') 2524 | scriptlist = script_name.strip('/').split('/') 2525 | if pathlist and pathlist[0] == '': pathlist = [] 2526 | if scriptlist and scriptlist[0] == '': scriptlist = [] 2527 | if shift > 0 and shift <= len(pathlist): 2528 | moved = pathlist[:shift] 2529 | scriptlist = scriptlist + moved 2530 | pathlist = pathlist[shift:] 2531 | elif shift < 0 and shift >= -len(scriptlist): 2532 | moved = scriptlist[shift:] 2533 | pathlist = moved + pathlist 2534 | scriptlist = scriptlist[:shift] 2535 | else: 2536 | empty = 'SCRIPT_NAME' if shift < 0 else 'PATH_INFO' 2537 | raise AssertionError("Cannot shift. Nothing left from %s" % empty) 2538 | new_script_name = '/' + '/'.join(scriptlist) 2539 | new_path_info = '/' + '/'.join(pathlist) 2540 | if path_info.endswith('/') and pathlist: new_path_info += '/' 2541 | return new_script_name, new_path_info 2542 | 2543 | 2544 | def auth_basic(check, realm="private", text="Access denied"): 2545 | ''' Callback decorator to require HTTP auth (basic). 2546 | TODO: Add route(check_auth=...) parameter. ''' 2547 | def decorator(func): 2548 | @functools.wraps(func) 2549 | def wrapper(*a, **ka): 2550 | user, password = request.auth or (None, None) 2551 | if user is None or not check(user, password): 2552 | err = HTTPError(401, text) 2553 | err.add_header('WWW-Authenticate', 'Basic realm="%s"' % realm) 2554 | return err 2555 | return func(*a, **ka) 2556 | return wrapper 2557 | return decorator 2558 | 2559 | 2560 | # Shortcuts for common Bottle methods. 2561 | # They all refer to the current default application. 2562 | 2563 | def make_default_app_wrapper(name): 2564 | ''' Return a callable that relays calls to the current default app. ''' 2565 | @functools.wraps(getattr(Bottle, name)) 2566 | def wrapper(*a, **ka): 2567 | return getattr(app(), name)(*a, **ka) 2568 | return wrapper 2569 | 2570 | route = make_default_app_wrapper('route') 2571 | get = make_default_app_wrapper('get') 2572 | post = make_default_app_wrapper('post') 2573 | put = make_default_app_wrapper('put') 2574 | delete = make_default_app_wrapper('delete') 2575 | error = make_default_app_wrapper('error') 2576 | mount = make_default_app_wrapper('mount') 2577 | hook = make_default_app_wrapper('hook') 2578 | install = make_default_app_wrapper('install') 2579 | uninstall = make_default_app_wrapper('uninstall') 2580 | url = make_default_app_wrapper('get_url') 2581 | 2582 | 2583 | 2584 | 2585 | 2586 | 2587 | 2588 | ############################################################################### 2589 | # Server Adapter ############################################################### 2590 | ############################################################################### 2591 | 2592 | 2593 | class ServerAdapter(object): 2594 | quiet = False 2595 | def __init__(self, host='127.0.0.1', port=8080, **options): 2596 | self.options = options 2597 | self.host = host 2598 | self.port = int(port) 2599 | 2600 | def run(self, handler): # pragma: no cover 2601 | pass 2602 | 2603 | def __repr__(self): 2604 | args = ', '.join(['%s=%s'%(k,repr(v)) for k, v in self.options.items()]) 2605 | return "%s(%s)" % (self.__class__.__name__, args) 2606 | 2607 | 2608 | class CGIServer(ServerAdapter): 2609 | quiet = True 2610 | def run(self, handler): # pragma: no cover 2611 | from wsgiref.handlers import CGIHandler 2612 | def fixed_environ(environ, start_response): 2613 | environ.setdefault('PATH_INFO', '') 2614 | return handler(environ, start_response) 2615 | CGIHandler().run(fixed_environ) 2616 | 2617 | 2618 | class FlupFCGIServer(ServerAdapter): 2619 | def run(self, handler): # pragma: no cover 2620 | import flup.server.fcgi 2621 | self.options.setdefault('bindAddress', (self.host, self.port)) 2622 | flup.server.fcgi.WSGIServer(handler, **self.options).run() 2623 | 2624 | 2625 | class WSGIRefServer(ServerAdapter): 2626 | def run(self, app): # pragma: no cover 2627 | from wsgiref.simple_server import WSGIRequestHandler, WSGIServer 2628 | from wsgiref.simple_server import make_server 2629 | import socket 2630 | 2631 | class FixedHandler(WSGIRequestHandler): 2632 | def address_string(self): # Prevent reverse DNS lookups please. 2633 | return self.client_address[0] 2634 | def log_request(*args, **kw): 2635 | if not self.quiet: 2636 | return WSGIRequestHandler.log_request(*args, **kw) 2637 | 2638 | handler_cls = self.options.get('handler_class', FixedHandler) 2639 | server_cls = self.options.get('server_class', WSGIServer) 2640 | 2641 | if ':' in self.host: # Fix wsgiref for IPv6 addresses. 2642 | if getattr(server_cls, 'address_family') == socket.AF_INET: 2643 | class server_cls(server_cls): 2644 | address_family = socket.AF_INET6 2645 | 2646 | srv = make_server(self.host, self.port, app, server_cls, handler_cls) 2647 | srv.serve_forever() 2648 | 2649 | 2650 | class CherryPyServer(ServerAdapter): 2651 | def run(self, handler): # pragma: no cover 2652 | from cherrypy import wsgiserver 2653 | self.options['bind_addr'] = (self.host, self.port) 2654 | self.options['wsgi_app'] = handler 2655 | 2656 | certfile = self.options.get('certfile') 2657 | if certfile: 2658 | del self.options['certfile'] 2659 | keyfile = self.options.get('keyfile') 2660 | if keyfile: 2661 | del self.options['keyfile'] 2662 | 2663 | server = wsgiserver.CherryPyWSGIServer(**self.options) 2664 | if certfile: 2665 | server.ssl_certificate = certfile 2666 | if keyfile: 2667 | server.ssl_private_key = keyfile 2668 | 2669 | try: 2670 | server.start() 2671 | finally: 2672 | server.stop() 2673 | 2674 | 2675 | class WaitressServer(ServerAdapter): 2676 | def run(self, handler): 2677 | from waitress import serve 2678 | serve(handler, host=self.host, port=self.port) 2679 | 2680 | 2681 | class PasteServer(ServerAdapter): 2682 | def run(self, handler): # pragma: no cover 2683 | from paste import httpserver 2684 | from paste.translogger import TransLogger 2685 | handler = TransLogger(handler, setup_console_handler=(not self.quiet)) 2686 | httpserver.serve(handler, host=self.host, port=str(self.port), 2687 | **self.options) 2688 | 2689 | 2690 | class MeinheldServer(ServerAdapter): 2691 | def run(self, handler): 2692 | from meinheld import server 2693 | server.listen((self.host, self.port)) 2694 | server.run(handler) 2695 | 2696 | 2697 | class FapwsServer(ServerAdapter): 2698 | """ Extremely fast webserver using libev. See http://www.fapws.org/ """ 2699 | def run(self, handler): # pragma: no cover 2700 | import fapws._evwsgi as evwsgi 2701 | from fapws import base, config 2702 | port = self.port 2703 | if float(config.SERVER_IDENT[-2:]) > 0.4: 2704 | # fapws3 silently changed its API in 0.5 2705 | port = str(port) 2706 | evwsgi.start(self.host, port) 2707 | # fapws3 never releases the GIL. Complain upstream. I tried. No luck. 2708 | if 'BOTTLE_CHILD' in os.environ and not self.quiet: 2709 | _stderr("WARNING: Auto-reloading does not work with Fapws3.\n") 2710 | _stderr(" (Fapws3 breaks python thread support)\n") 2711 | evwsgi.set_base_module(base) 2712 | def app(environ, start_response): 2713 | environ['wsgi.multiprocess'] = False 2714 | return handler(environ, start_response) 2715 | evwsgi.wsgi_cb(('', app)) 2716 | evwsgi.run() 2717 | 2718 | 2719 | class TornadoServer(ServerAdapter): 2720 | """ The super hyped asynchronous server by facebook. Untested. """ 2721 | def run(self, handler): # pragma: no cover 2722 | import tornado.wsgi, tornado.httpserver, tornado.ioloop 2723 | container = tornado.wsgi.WSGIContainer(handler) 2724 | server = tornado.httpserver.HTTPServer(container) 2725 | server.listen(port=self.port,address=self.host) 2726 | tornado.ioloop.IOLoop.instance().start() 2727 | 2728 | 2729 | class AppEngineServer(ServerAdapter): 2730 | """ Adapter for Google App Engine. """ 2731 | quiet = True 2732 | def run(self, handler): 2733 | from google.appengine.ext.webapp import util 2734 | # A main() function in the handler script enables 'App Caching'. 2735 | # Lets makes sure it is there. This _really_ improves performance. 2736 | module = sys.modules.get('__main__') 2737 | if module and not hasattr(module, 'main'): 2738 | module.main = lambda: util.run_wsgi_app(handler) 2739 | util.run_wsgi_app(handler) 2740 | 2741 | 2742 | class TwistedServer(ServerAdapter): 2743 | """ Untested. """ 2744 | def run(self, handler): 2745 | from twisted.web import server, wsgi 2746 | from twisted.python.threadpool import ThreadPool 2747 | from twisted.internet import reactor 2748 | thread_pool = ThreadPool() 2749 | thread_pool.start() 2750 | reactor.addSystemEventTrigger('after', 'shutdown', thread_pool.stop) 2751 | factory = server.Site(wsgi.WSGIResource(reactor, thread_pool, handler)) 2752 | reactor.listenTCP(self.port, factory, interface=self.host) 2753 | if not reactor.running: 2754 | reactor.run() 2755 | 2756 | 2757 | class DieselServer(ServerAdapter): 2758 | """ Untested. """ 2759 | def run(self, handler): 2760 | from diesel.protocols.wsgi import WSGIApplication 2761 | app = WSGIApplication(handler, port=self.port) 2762 | app.run() 2763 | 2764 | 2765 | class GeventServer(ServerAdapter): 2766 | """ Untested. Options: 2767 | 2768 | * `fast` (default: False) uses libevent's http server, but has some 2769 | issues: No streaming, no pipelining, no SSL. 2770 | * See gevent.wsgi.WSGIServer() documentation for more options. 2771 | """ 2772 | def run(self, handler): 2773 | from gevent import wsgi, pywsgi, local 2774 | if not isinstance(threading.local(), local.local): 2775 | msg = "Bottle requires gevent.monkey.patch_all() (before import)" 2776 | raise RuntimeError(msg) 2777 | if not self.options.pop('fast', None): wsgi = pywsgi 2778 | self.options['log'] = None if self.quiet else 'default' 2779 | address = (self.host, self.port) 2780 | server = wsgi.WSGIServer(address, handler, **self.options) 2781 | if 'BOTTLE_CHILD' in os.environ: 2782 | import signal 2783 | signal.signal(signal.SIGINT, lambda s, f: server.stop()) 2784 | server.serve_forever() 2785 | 2786 | 2787 | class GeventSocketIOServer(ServerAdapter): 2788 | def run(self,handler): 2789 | from socketio import server 2790 | address = (self.host, self.port) 2791 | server.SocketIOServer(address, handler, **self.options).serve_forever() 2792 | 2793 | 2794 | class GunicornServer(ServerAdapter): 2795 | """ Untested. See http://gunicorn.org/configure.html for options. """ 2796 | def run(self, handler): 2797 | from gunicorn.app.base import Application 2798 | 2799 | config = {'bind': "%s:%d" % (self.host, int(self.port))} 2800 | config.update(self.options) 2801 | 2802 | class GunicornApplication(Application): 2803 | def init(self, parser, opts, args): 2804 | return config 2805 | 2806 | def load(self): 2807 | return handler 2808 | 2809 | GunicornApplication().run() 2810 | 2811 | 2812 | class EventletServer(ServerAdapter): 2813 | """ Untested """ 2814 | def run(self, handler): 2815 | from eventlet import wsgi, listen 2816 | try: 2817 | wsgi.server(listen((self.host, self.port)), handler, 2818 | log_output=(not self.quiet)) 2819 | except TypeError: 2820 | # Fallback, if we have old version of eventlet 2821 | wsgi.server(listen((self.host, self.port)), handler) 2822 | 2823 | 2824 | class RocketServer(ServerAdapter): 2825 | """ Untested. """ 2826 | def run(self, handler): 2827 | from rocket import Rocket 2828 | server = Rocket((self.host, self.port), 'wsgi', { 'wsgi_app' : handler }) 2829 | server.start() 2830 | 2831 | 2832 | class BjoernServer(ServerAdapter): 2833 | """ Fast server written in C: https://github.com/jonashaag/bjoern """ 2834 | def run(self, handler): 2835 | from bjoern import run 2836 | run(handler, self.host, self.port) 2837 | 2838 | 2839 | class AutoServer(ServerAdapter): 2840 | """ Untested. """ 2841 | adapters = [WaitressServer, PasteServer, TwistedServer, CherryPyServer, WSGIRefServer] 2842 | def run(self, handler): 2843 | for sa in self.adapters: 2844 | try: 2845 | return sa(self.host, self.port, **self.options).run(handler) 2846 | except ImportError: 2847 | pass 2848 | 2849 | server_names = { 2850 | 'cgi': CGIServer, 2851 | 'flup': FlupFCGIServer, 2852 | 'wsgiref': WSGIRefServer, 2853 | 'waitress': WaitressServer, 2854 | 'cherrypy': CherryPyServer, 2855 | 'paste': PasteServer, 2856 | 'fapws3': FapwsServer, 2857 | 'tornado': TornadoServer, 2858 | 'gae': AppEngineServer, 2859 | 'twisted': TwistedServer, 2860 | 'diesel': DieselServer, 2861 | 'meinheld': MeinheldServer, 2862 | 'gunicorn': GunicornServer, 2863 | 'eventlet': EventletServer, 2864 | 'gevent': GeventServer, 2865 | 'geventSocketIO':GeventSocketIOServer, 2866 | 'rocket': RocketServer, 2867 | 'bjoern' : BjoernServer, 2868 | 'auto': AutoServer, 2869 | } 2870 | 2871 | 2872 | 2873 | 2874 | 2875 | 2876 | ############################################################################### 2877 | # Application Control ########################################################## 2878 | ############################################################################### 2879 | 2880 | 2881 | def load(target, **namespace): 2882 | """ Import a module or fetch an object from a module. 2883 | 2884 | * ``package.module`` returns `module` as a module object. 2885 | * ``pack.mod:name`` returns the module variable `name` from `pack.mod`. 2886 | * ``pack.mod:func()`` calls `pack.mod.func()` and returns the result. 2887 | 2888 | The last form accepts not only function calls, but any type of 2889 | expression. Keyword arguments passed to this function are available as 2890 | local variables. Example: ``import_string('re:compile(x)', x='[a-z]')`` 2891 | """ 2892 | module, target = target.split(":", 1) if ':' in target else (target, None) 2893 | if module not in sys.modules: __import__(module) 2894 | if not target: return sys.modules[module] 2895 | if target.isalnum(): return getattr(sys.modules[module], target) 2896 | package_name = module.split('.')[0] 2897 | namespace[package_name] = sys.modules[package_name] 2898 | return eval('%s.%s' % (module, target), namespace) 2899 | 2900 | 2901 | def load_app(target): 2902 | """ Load a bottle application from a module and make sure that the import 2903 | does not affect the current default application, but returns a separate 2904 | application object. See :func:`load` for the target parameter. """ 2905 | global NORUN; NORUN, nr_old = True, NORUN 2906 | try: 2907 | tmp = default_app.push() # Create a new "default application" 2908 | rv = load(target) # Import the target module 2909 | return rv if callable(rv) else tmp 2910 | finally: 2911 | default_app.remove(tmp) # Remove the temporary added default application 2912 | NORUN = nr_old 2913 | 2914 | _debug = debug 2915 | def run(app=None, server='wsgiref', host='127.0.0.1', port=8080, 2916 | interval=1, reloader=False, quiet=False, plugins=None, 2917 | debug=None, **kargs): 2918 | """ Start a server instance. This method blocks until the server terminates. 2919 | 2920 | :param app: WSGI application or target string supported by 2921 | :func:`load_app`. (default: :func:`default_app`) 2922 | :param server: Server adapter to use. See :data:`server_names` keys 2923 | for valid names or pass a :class:`ServerAdapter` subclass. 2924 | (default: `wsgiref`) 2925 | :param host: Server address to bind to. Pass ``0.0.0.0`` to listens on 2926 | all interfaces including the external one. (default: 127.0.0.1) 2927 | :param port: Server port to bind to. Values below 1024 require root 2928 | privileges. (default: 8080) 2929 | :param reloader: Start auto-reloading server? (default: False) 2930 | :param interval: Auto-reloader interval in seconds (default: 1) 2931 | :param quiet: Suppress output to stdout and stderr? (default: False) 2932 | :param options: Options passed to the server adapter. 2933 | """ 2934 | if NORUN: return 2935 | if reloader and not os.environ.get('BOTTLE_CHILD'): 2936 | try: 2937 | lockfile = None 2938 | fd, lockfile = tempfile.mkstemp(prefix='bottle.', suffix='.lock') 2939 | os.close(fd) # We only need this file to exist. We never write to it 2940 | while os.path.exists(lockfile): 2941 | args = [sys.executable] + sys.argv 2942 | environ = os.environ.copy() 2943 | environ['BOTTLE_CHILD'] = 'true' 2944 | environ['BOTTLE_LOCKFILE'] = lockfile 2945 | p = subprocess.Popen(args, env=environ) 2946 | while p.poll() is None: # Busy wait... 2947 | os.utime(lockfile, None) # I am alive! 2948 | time.sleep(interval) 2949 | if p.poll() != 3: 2950 | if os.path.exists(lockfile): os.unlink(lockfile) 2951 | sys.exit(p.poll()) 2952 | except KeyboardInterrupt: 2953 | pass 2954 | finally: 2955 | if os.path.exists(lockfile): 2956 | os.unlink(lockfile) 2957 | return 2958 | 2959 | try: 2960 | if debug is not None: _debug(debug) 2961 | app = app or default_app() 2962 | if isinstance(app, basestring): 2963 | app = load_app(app) 2964 | if not callable(app): 2965 | raise ValueError("Application is not callable: %r" % app) 2966 | 2967 | for plugin in plugins or []: 2968 | app.install(plugin) 2969 | 2970 | if server in server_names: 2971 | server = server_names.get(server) 2972 | if isinstance(server, basestring): 2973 | server = load(server) 2974 | if isinstance(server, type): 2975 | server = server(host=host, port=port, **kargs) 2976 | if not isinstance(server, ServerAdapter): 2977 | raise ValueError("Unknown or unsupported server: %r" % server) 2978 | 2979 | server.quiet = server.quiet or quiet 2980 | if not server.quiet: 2981 | _stderr("Bottle v%s server starting up (using %s)...\n" % (__version__, repr(server))) 2982 | _stderr("Listening on http://%s:%d/\n" % (server.host, server.port)) 2983 | _stderr("Hit Ctrl-C to quit.\n\n") 2984 | 2985 | if reloader: 2986 | lockfile = os.environ.get('BOTTLE_LOCKFILE') 2987 | bgcheck = FileCheckerThread(lockfile, interval) 2988 | with bgcheck: 2989 | server.run(app) 2990 | if bgcheck.status == 'reload': 2991 | sys.exit(3) 2992 | else: 2993 | server.run(app) 2994 | except KeyboardInterrupt: 2995 | pass 2996 | except (SystemExit, MemoryError): 2997 | raise 2998 | except: 2999 | if not reloader: raise 3000 | if not getattr(server, 'quiet', quiet): 3001 | print_exc() 3002 | time.sleep(interval) 3003 | sys.exit(3) 3004 | 3005 | 3006 | 3007 | class FileCheckerThread(threading.Thread): 3008 | ''' Interrupt main-thread as soon as a changed module file is detected, 3009 | the lockfile gets deleted or gets to old. ''' 3010 | 3011 | def __init__(self, lockfile, interval): 3012 | threading.Thread.__init__(self) 3013 | self.lockfile, self.interval = lockfile, interval 3014 | #: Is one of 'reload', 'error' or 'exit' 3015 | self.status = None 3016 | 3017 | def run(self): 3018 | exists = os.path.exists 3019 | mtime = lambda path: os.stat(path).st_mtime 3020 | files = dict() 3021 | 3022 | for module in list(sys.modules.values()): 3023 | path = getattr(module, '__file__', '') 3024 | if path[-4:] in ('.pyo', '.pyc'): path = path[:-1] 3025 | if path and exists(path): files[path] = mtime(path) 3026 | 3027 | while not self.status: 3028 | if not exists(self.lockfile)\ 3029 | or mtime(self.lockfile) < time.time() - self.interval - 5: 3030 | self.status = 'error' 3031 | thread.interrupt_main() 3032 | for path, lmtime in list(files.items()): 3033 | if not exists(path) or mtime(path) > lmtime: 3034 | self.status = 'reload' 3035 | thread.interrupt_main() 3036 | break 3037 | time.sleep(self.interval) 3038 | 3039 | def __enter__(self): 3040 | self.start() 3041 | 3042 | def __exit__(self, exc_type, exc_val, exc_tb): 3043 | if not self.status: self.status = 'exit' # silent exit 3044 | self.join() 3045 | return exc_type is not None and issubclass(exc_type, KeyboardInterrupt) 3046 | 3047 | 3048 | 3049 | 3050 | 3051 | ############################################################################### 3052 | # Template Adapters ############################################################ 3053 | ############################################################################### 3054 | 3055 | 3056 | class TemplateError(HTTPError): 3057 | def __init__(self, message): 3058 | HTTPError.__init__(self, 500, message) 3059 | 3060 | 3061 | class BaseTemplate(object): 3062 | """ Base class and minimal API for template adapters """ 3063 | extensions = ['tpl','html','thtml','stpl'] 3064 | settings = {} #used in prepare() 3065 | defaults = {} #used in render() 3066 | 3067 | def __init__(self, source=None, name=None, lookup=[], encoding='utf8', **settings): 3068 | """ Create a new template. 3069 | If the source parameter (str or buffer) is missing, the name argument 3070 | is used to guess a template filename. Subclasses can assume that 3071 | self.source and/or self.filename are set. Both are strings. 3072 | The lookup, encoding and settings parameters are stored as instance 3073 | variables. 3074 | The lookup parameter stores a list containing directory paths. 3075 | The encoding parameter should be used to decode byte strings or files. 3076 | The settings parameter contains a dict for engine-specific settings. 3077 | """ 3078 | self.name = name 3079 | self.source = source.read() if hasattr(source, 'read') else source 3080 | self.filename = source.filename if hasattr(source, 'filename') else None 3081 | self.lookup = [os.path.abspath(x) for x in lookup] 3082 | self.encoding = encoding 3083 | self.settings = self.settings.copy() # Copy from class variable 3084 | self.settings.update(settings) # Apply 3085 | if not self.source and self.name: 3086 | self.filename = self.search(self.name, self.lookup) 3087 | if not self.filename: 3088 | raise TemplateError('Template %s not found.' % repr(name)) 3089 | if not self.source and not self.filename: 3090 | raise TemplateError('No template specified.') 3091 | self.prepare(**self.settings) 3092 | 3093 | @classmethod 3094 | def search(cls, name, lookup=[]): 3095 | """ Search name in all directories specified in lookup. 3096 | First without, then with common extensions. Return first hit. """ 3097 | if not lookup: 3098 | depr('The template lookup path list should not be empty.', True) #0.12 3099 | lookup = ['.'] 3100 | 3101 | if os.path.isabs(name) and os.path.isfile(name): 3102 | depr('Absolute template path names are deprecated.', True) #0.12 3103 | return os.path.abspath(name) 3104 | 3105 | for spath in lookup: 3106 | spath = os.path.abspath(spath) + os.sep 3107 | fname = os.path.abspath(os.path.join(spath, name)) 3108 | if not fname.startswith(spath): continue 3109 | if os.path.isfile(fname): return fname 3110 | for ext in cls.extensions: 3111 | if os.path.isfile('%s.%s' % (fname, ext)): 3112 | return '%s.%s' % (fname, ext) 3113 | 3114 | @classmethod 3115 | def global_config(cls, key, *args): 3116 | ''' This reads or sets the global settings stored in class.settings. ''' 3117 | if args: 3118 | cls.settings = cls.settings.copy() # Make settings local to class 3119 | cls.settings[key] = args[0] 3120 | else: 3121 | return cls.settings[key] 3122 | 3123 | def prepare(self, **options): 3124 | """ Run preparations (parsing, caching, ...). 3125 | It should be possible to call this again to refresh a template or to 3126 | update settings. 3127 | """ 3128 | raise NotImplementedError 3129 | 3130 | def render(self, *args, **kwargs): 3131 | """ Render the template with the specified local variables and return 3132 | a single byte or unicode string. If it is a byte string, the encoding 3133 | must match self.encoding. This method must be thread-safe! 3134 | Local variables may be provided in dictionaries (args) 3135 | or directly, as keywords (kwargs). 3136 | """ 3137 | raise NotImplementedError 3138 | 3139 | 3140 | class MakoTemplate(BaseTemplate): 3141 | def prepare(self, **options): 3142 | from mako.template import Template 3143 | from mako.lookup import TemplateLookup 3144 | options.update({'input_encoding':self.encoding}) 3145 | options.setdefault('format_exceptions', bool(DEBUG)) 3146 | lookup = TemplateLookup(directories=self.lookup, **options) 3147 | if self.source: 3148 | self.tpl = Template(self.source, lookup=lookup, **options) 3149 | else: 3150 | self.tpl = Template(uri=self.name, filename=self.filename, lookup=lookup, **options) 3151 | 3152 | def render(self, *args, **kwargs): 3153 | for dictarg in args: kwargs.update(dictarg) 3154 | _defaults = self.defaults.copy() 3155 | _defaults.update(kwargs) 3156 | return self.tpl.render(**_defaults) 3157 | 3158 | 3159 | class CheetahTemplate(BaseTemplate): 3160 | def prepare(self, **options): 3161 | from Cheetah.Template import Template 3162 | self.context = threading.local() 3163 | self.context.vars = {} 3164 | options['searchList'] = [self.context.vars] 3165 | if self.source: 3166 | self.tpl = Template(source=self.source, **options) 3167 | else: 3168 | self.tpl = Template(file=self.filename, **options) 3169 | 3170 | def render(self, *args, **kwargs): 3171 | for dictarg in args: kwargs.update(dictarg) 3172 | self.context.vars.update(self.defaults) 3173 | self.context.vars.update(kwargs) 3174 | out = str(self.tpl) 3175 | self.context.vars.clear() 3176 | return out 3177 | 3178 | 3179 | class Jinja2Template(BaseTemplate): 3180 | def prepare(self, filters=None, tests=None, globals={}, **kwargs): 3181 | from jinja2 import Environment, FunctionLoader 3182 | self.env = Environment(loader=FunctionLoader(self.loader), **kwargs) 3183 | if filters: self.env.filters.update(filters) 3184 | if tests: self.env.tests.update(tests) 3185 | if globals: self.env.globals.update(globals) 3186 | if self.source: 3187 | self.tpl = self.env.from_string(self.source) 3188 | else: 3189 | self.tpl = self.env.get_template(self.filename) 3190 | 3191 | def render(self, *args, **kwargs): 3192 | for dictarg in args: kwargs.update(dictarg) 3193 | _defaults = self.defaults.copy() 3194 | _defaults.update(kwargs) 3195 | return self.tpl.render(**_defaults) 3196 | 3197 | def loader(self, name): 3198 | fname = self.search(name, self.lookup) 3199 | if not fname: return 3200 | with open(fname, "rb") as f: 3201 | return f.read().decode(self.encoding) 3202 | 3203 | 3204 | class SimpleTemplate(BaseTemplate): 3205 | 3206 | def prepare(self, escape_func=html_escape, noescape=False, syntax=None, **ka): 3207 | self.cache = {} 3208 | enc = self.encoding 3209 | self._str = lambda x: touni(x, enc) 3210 | self._escape = lambda x: escape_func(touni(x, enc)) 3211 | self.syntax = syntax 3212 | if noescape: 3213 | self._str, self._escape = self._escape, self._str 3214 | 3215 | @cached_property 3216 | def co(self): 3217 | return compile(self.code, self.filename or '', 'exec') 3218 | 3219 | @cached_property 3220 | def code(self): 3221 | source = self.source or open(self.filename, 'rb').read() 3222 | try: 3223 | source, encoding = touni(source), 'utf8' 3224 | except UnicodeError: 3225 | depr('Template encodings other than utf8 are no longer supported.') #0.11 3226 | source, encoding = touni(source, 'latin1'), 'latin1' 3227 | parser = StplParser(source, encoding=encoding, syntax=self.syntax) 3228 | code = parser.translate() 3229 | self.encoding = parser.encoding 3230 | return code 3231 | 3232 | def _rebase(self, _env, _name=None, **kwargs): 3233 | _env['_rebase'] = (_name, kwargs) 3234 | 3235 | def _include(self, _env, _name=None, **kwargs): 3236 | env = _env.copy() 3237 | env.update(kwargs) 3238 | if _name not in self.cache: 3239 | self.cache[_name] = self.__class__(name=_name, lookup=self.lookup) 3240 | return self.cache[_name].execute(env['_stdout'], env) 3241 | 3242 | def execute(self, _stdout, kwargs): 3243 | env = self.defaults.copy() 3244 | env.update(kwargs) 3245 | env.update({'_stdout': _stdout, '_printlist': _stdout.extend, 3246 | 'include': functools.partial(self._include, env), 3247 | 'rebase': functools.partial(self._rebase, env), '_rebase': None, 3248 | '_str': self._str, '_escape': self._escape, 'get': env.get, 3249 | 'setdefault': env.setdefault, 'defined': env.__contains__ }) 3250 | eval(self.co, env) 3251 | if env.get('_rebase'): 3252 | subtpl, rargs = env.pop('_rebase') 3253 | rargs['base'] = ''.join(_stdout) #copy stdout 3254 | del _stdout[:] # clear stdout 3255 | return self._include(env, subtpl, **rargs) 3256 | return env 3257 | 3258 | def render(self, *args, **kwargs): 3259 | """ Render the template using keyword arguments as local variables. """ 3260 | env = {}; stdout = [] 3261 | for dictarg in args: env.update(dictarg) 3262 | env.update(kwargs) 3263 | self.execute(stdout, env) 3264 | return ''.join(stdout) 3265 | 3266 | 3267 | class StplSyntaxError(TemplateError): pass 3268 | 3269 | 3270 | class StplParser(object): 3271 | ''' Parser for stpl templates. ''' 3272 | _re_cache = {} #: Cache for compiled re patterns 3273 | # This huge pile of voodoo magic splits python code into 8 different tokens. 3274 | # 1: All kinds of python strings (trust me, it works) 3275 | _re_tok = '((?m)[urbURB]?(?:\'\'(?!\')|""(?!")|\'{6}|"{6}' \ 3276 | '|\'(?:[^\\\\\']|\\\\.)+?\'|"(?:[^\\\\"]|\\\\.)+?"' \ 3277 | '|\'{3}(?:[^\\\\]|\\\\.|\\n)+?\'{3}' \ 3278 | '|"{3}(?:[^\\\\]|\\\\.|\\n)+?"{3}))' 3279 | _re_inl = _re_tok.replace('|\\n','') # We re-use this string pattern later 3280 | # 2: Comments (until end of line, but not the newline itself) 3281 | _re_tok += '|(#.*)' 3282 | # 3,4: Keywords that start or continue a python block (only start of line) 3283 | _re_tok += '|^([ \\t]*(?:if|for|while|with|try|def|class)\\b)' \ 3284 | '|^([ \\t]*(?:elif|else|except|finally)\\b)' 3285 | # 5: Our special 'end' keyword (but only if it stands alone) 3286 | _re_tok += '|((?:^|;)[ \\t]*end[ \\t]*(?=(?:%(block_close)s[ \\t]*)?\\r?$|;|#))' 3287 | # 6: A customizable end-of-code-block template token (only end of line) 3288 | _re_tok += '|(%(block_close)s[ \\t]*(?=$))' 3289 | # 7: And finally, a single newline. The 8th token is 'everything else' 3290 | _re_tok += '|(\\r?\\n)' 3291 | # Match the start tokens of code areas in a template 3292 | _re_split = '(?m)^[ \t]*(\\\\?)((%(line_start)s)|(%(block_start)s))' 3293 | # Match inline statements (may contain python strings) 3294 | _re_inl = '%%(inline_start)s((?:%s|[^\'"\n]*?)+)%%(inline_end)s' % _re_inl 3295 | 3296 | default_syntax = '<% %> % {{ }}' 3297 | 3298 | def __init__(self, source, syntax=None, encoding='utf8'): 3299 | self.source, self.encoding = touni(source, encoding), encoding 3300 | self.set_syntax(syntax or self.default_syntax) 3301 | self.code_buffer, self.text_buffer = [], [] 3302 | self.lineno, self.offset = 1, 0 3303 | self.indent, self.indent_mod = 0, 0 3304 | 3305 | def get_syntax(self): 3306 | ''' Tokens as a space separated string (default: <% %> % {{ }}) ''' 3307 | return self._syntax 3308 | 3309 | def set_syntax(self, syntax): 3310 | self._syntax = syntax 3311 | self._tokens = syntax.split() 3312 | if not syntax in self._re_cache: 3313 | names = 'block_start block_close line_start inline_start inline_end' 3314 | etokens = map(re.escape, self._tokens) 3315 | pattern_vars = dict(zip(names.split(), etokens)) 3316 | patterns = (self._re_split, self._re_tok, self._re_inl) 3317 | patterns = [re.compile(p%pattern_vars) for p in patterns] 3318 | self._re_cache[syntax] = patterns 3319 | self.re_split, self.re_tok, self.re_inl = self._re_cache[syntax] 3320 | 3321 | syntax = property(get_syntax, set_syntax) 3322 | 3323 | def translate(self): 3324 | if self.offset: raise RuntimeError('Parser is a one time instance.') 3325 | while True: 3326 | m = self.re_split.search(self.source[self.offset:]) 3327 | if m: 3328 | text = self.source[self.offset:self.offset+m.start()] 3329 | self.text_buffer.append(text) 3330 | self.offset += m.end() 3331 | if m.group(1): # Escape syntax 3332 | line, sep, _ = self.source[self.offset:].partition('\n') 3333 | self.text_buffer.append(m.group(2)+line+sep) 3334 | self.offset += len(line+sep)+1 3335 | continue 3336 | self.flush_text() 3337 | self.read_code(multiline=bool(m.group(4))) 3338 | else: break 3339 | self.text_buffer.append(self.source[self.offset:]) 3340 | self.flush_text() 3341 | return ''.join(self.code_buffer) 3342 | 3343 | def read_code(self, multiline): 3344 | code_line, comment = '', '' 3345 | while True: 3346 | m = self.re_tok.search(self.source[self.offset:]) 3347 | if not m: 3348 | code_line += self.source[self.offset:] 3349 | self.offset = len(self.source) 3350 | self.write_code(code_line.strip(), comment) 3351 | return 3352 | code_line += self.source[self.offset:self.offset+m.start()] 3353 | self.offset += m.end() 3354 | _str, _com, _blk1, _blk2, _end, _cend, _nl = m.groups() 3355 | if code_line and (_blk1 or _blk2): # a if b else c 3356 | code_line += _blk1 or _blk2 3357 | continue 3358 | if _str: # Python string 3359 | code_line += _str 3360 | elif _com: # Python comment (up to EOL) 3361 | comment = _com 3362 | if multiline and _com.strip().endswith(self._tokens[1]): 3363 | multiline = False # Allow end-of-block in comments 3364 | elif _blk1: # Start-block keyword (if/for/while/def/try/...) 3365 | code_line, self.indent_mod = _blk1, -1 3366 | self.indent += 1 3367 | elif _blk2: # Continue-block keyword (else/elif/except/...) 3368 | code_line, self.indent_mod = _blk2, -1 3369 | elif _end: # The non-standard 'end'-keyword (ends a block) 3370 | self.indent -= 1 3371 | elif _cend: # The end-code-block template token (usually '%>') 3372 | if multiline: multiline = False 3373 | else: code_line += _cend 3374 | else: # \n 3375 | self.write_code(code_line.strip(), comment) 3376 | self.lineno += 1 3377 | code_line, comment, self.indent_mod = '', '', 0 3378 | if not multiline: 3379 | break 3380 | 3381 | def flush_text(self): 3382 | text = ''.join(self.text_buffer) 3383 | del self.text_buffer[:] 3384 | if not text: return 3385 | parts, pos, nl = [], 0, '\\\n'+' '*self.indent 3386 | for m in self.re_inl.finditer(text): 3387 | prefix, pos = text[pos:m.start()], m.end() 3388 | if prefix: 3389 | parts.append(nl.join(map(repr, prefix.splitlines(True)))) 3390 | if prefix.endswith('\n'): parts[-1] += nl 3391 | parts.append(self.process_inline(m.group(1).strip())) 3392 | if pos < len(text): 3393 | prefix = text[pos:] 3394 | lines = prefix.splitlines(True) 3395 | if lines[-1].endswith('\\\\\n'): lines[-1] = lines[-1][:-3] 3396 | elif lines[-1].endswith('\\\\\r\n'): lines[-1] = lines[-1][:-4] 3397 | parts.append(nl.join(map(repr, lines))) 3398 | code = '_printlist((%s,))' % ', '.join(parts) 3399 | self.lineno += code.count('\n')+1 3400 | self.write_code(code) 3401 | 3402 | def process_inline(self, chunk): 3403 | if chunk[0] == '!': return '_str(%s)' % chunk[1:] 3404 | return '_escape(%s)' % chunk 3405 | 3406 | def write_code(self, line, comment=''): 3407 | code = ' ' * (self.indent+self.indent_mod) 3408 | code += line.lstrip() + comment + '\n' 3409 | self.code_buffer.append(code) 3410 | 3411 | 3412 | def template(*args, **kwargs): 3413 | ''' 3414 | Get a rendered template as a string iterator. 3415 | You can use a name, a filename or a template string as first parameter. 3416 | Template rendering arguments can be passed as dictionaries 3417 | or directly (as keyword arguments). 3418 | ''' 3419 | tpl = args[0] if args else None 3420 | adapter = kwargs.pop('template_adapter', SimpleTemplate) 3421 | lookup = kwargs.pop('template_lookup', TEMPLATE_PATH) 3422 | tplid = (id(lookup), tpl) 3423 | if tplid not in TEMPLATES or DEBUG: 3424 | settings = kwargs.pop('template_settings', {}) 3425 | if isinstance(tpl, adapter): 3426 | TEMPLATES[tplid] = tpl 3427 | if settings: TEMPLATES[tplid].prepare(**settings) 3428 | elif "\n" in tpl or "{" in tpl or "%" in tpl or '$' in tpl: 3429 | TEMPLATES[tplid] = adapter(source=tpl, lookup=lookup, **settings) 3430 | else: 3431 | TEMPLATES[tplid] = adapter(name=tpl, lookup=lookup, **settings) 3432 | if not TEMPLATES[tplid]: 3433 | abort(500, 'Template (%s) not found' % tpl) 3434 | for dictarg in args[1:]: kwargs.update(dictarg) 3435 | return TEMPLATES[tplid].render(kwargs) 3436 | 3437 | mako_template = functools.partial(template, template_adapter=MakoTemplate) 3438 | cheetah_template = functools.partial(template, template_adapter=CheetahTemplate) 3439 | jinja2_template = functools.partial(template, template_adapter=Jinja2Template) 3440 | 3441 | 3442 | def view(tpl_name, **defaults): 3443 | ''' Decorator: renders a template for a handler. 3444 | The handler can control its behavior like that: 3445 | 3446 | - return a dict of template vars to fill out the template 3447 | - return something other than a dict and the view decorator will not 3448 | process the template, but return the handler result as is. 3449 | This includes returning a HTTPResponse(dict) to get, 3450 | for instance, JSON with autojson or other castfilters. 3451 | ''' 3452 | def decorator(func): 3453 | @functools.wraps(func) 3454 | def wrapper(*args, **kwargs): 3455 | result = func(*args, **kwargs) 3456 | if isinstance(result, (dict, DictMixin)): 3457 | tplvars = defaults.copy() 3458 | tplvars.update(result) 3459 | return template(tpl_name, **tplvars) 3460 | elif result is None: 3461 | return template(tpl_name, defaults) 3462 | return result 3463 | return wrapper 3464 | return decorator 3465 | 3466 | mako_view = functools.partial(view, template_adapter=MakoTemplate) 3467 | cheetah_view = functools.partial(view, template_adapter=CheetahTemplate) 3468 | jinja2_view = functools.partial(view, template_adapter=Jinja2Template) 3469 | 3470 | 3471 | 3472 | 3473 | 3474 | 3475 | ############################################################################### 3476 | # Constants and Globals ######################################################## 3477 | ############################################################################### 3478 | 3479 | 3480 | TEMPLATE_PATH = ['./', './views/'] 3481 | TEMPLATES = {} 3482 | DEBUG = False 3483 | NORUN = False # If set, run() does nothing. Used by load_app() 3484 | 3485 | #: A dict to map HTTP status codes (e.g. 404) to phrases (e.g. 'Not Found') 3486 | HTTP_CODES = httplib.responses 3487 | HTTP_CODES[418] = "I'm a teapot" # RFC 2324 3488 | HTTP_CODES[428] = "Precondition Required" 3489 | HTTP_CODES[429] = "Too Many Requests" 3490 | HTTP_CODES[431] = "Request Header Fields Too Large" 3491 | HTTP_CODES[511] = "Network Authentication Required" 3492 | _HTTP_STATUS_LINES = dict((k, '%d %s'%(k,v)) for (k,v) in HTTP_CODES.items()) 3493 | 3494 | #: The default template used for error pages. Override with @error() 3495 | ERROR_PAGE_TEMPLATE = """ 3496 | %%try: 3497 | %%from %s import DEBUG, request 3498 | 3499 | 3500 | 3501 | Error: {{e.status}} 3502 | 3508 | 3509 | 3510 |

Error: {{e.status}}

3511 |

Sorry, the requested URL {{repr(request.url)}} 3512 | caused an error:

3513 |
{{e.body}}
3514 | %%if DEBUG and e.exception: 3515 |

Exception:

3516 |
{{repr(e.exception)}}
3517 | %%end 3518 | %%if DEBUG and e.traceback: 3519 |

Traceback:

3520 |
{{e.traceback}}
3521 | %%end 3522 | 3523 | 3524 | %%except ImportError: 3525 | ImportError: Could not generate the error page. Please add bottle to 3526 | the import path. 3527 | %%end 3528 | """ % __name__ 3529 | 3530 | #: A thread-safe instance of :class:`LocalRequest`. If accessed from within a 3531 | #: request callback, this instance always refers to the *current* request 3532 | #: (even on a multithreaded server). 3533 | request = LocalRequest() 3534 | 3535 | #: A thread-safe instance of :class:`LocalResponse`. It is used to change the 3536 | #: HTTP response for the *current* request. 3537 | response = LocalResponse() 3538 | 3539 | #: A thread-safe namespace. Not used by Bottle. 3540 | local = threading.local() 3541 | 3542 | # Initialize app stack (create first empty Bottle app) 3543 | # BC: 0.6.4 and needed for run() 3544 | app = default_app = AppStack() 3545 | app.push() 3546 | 3547 | #: A virtual package that redirects import statements. 3548 | #: Example: ``import bottle.ext.sqlite`` actually imports `bottle_sqlite`. 3549 | ext = _ImportRedirect('bottle.ext' if __name__ == '__main__' else __name__+".ext", 'bottle_%s').module 3550 | 3551 | if __name__ == '__main__': 3552 | opt, args, parser = _cmd_options, _cmd_args, _cmd_parser 3553 | if opt.version: 3554 | _stdout('Bottle %s\n'%__version__) 3555 | sys.exit(0) 3556 | if not args: 3557 | parser.print_help() 3558 | _stderr('\nError: No application specified.\n') 3559 | sys.exit(1) 3560 | 3561 | sys.path.insert(0, '.') 3562 | sys.modules.setdefault('bottle', sys.modules['__main__']) 3563 | 3564 | host, port = (opt.bind or 'localhost'), 8080 3565 | if ':' in host and host.rfind(']') < host.rfind(':'): 3566 | host, port = host.rsplit(':', 1) 3567 | host = host.strip('[]') 3568 | 3569 | run(args[0], host=host, port=int(port), server=opt.server, 3570 | reloader=opt.reload, plugins=opt.plugin, debug=opt.debug) 3571 | 3572 | 3573 | 3574 | 3575 | # THE END 3576 | -------------------------------------------------------------------------------- /metadata/otp.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import time 3 | import hmac 4 | import hashlib 5 | import base64 6 | 7 | class Totp(object): 8 | def __init__(self, secret, interval_secs=30, digits=6, digest=hashlib.sha1): 9 | """ 10 | Create a new TOTP code generator. 11 | 12 | Parameters: 13 | secret (string|list of byte): shared secret as either a base32 encoded string or byte array 14 | interval_secs (int): interval, in seconds, to generate codes at 15 | digits (int): number of digits in the generated codes 16 | digest (function): HMAC digest function to use 17 | """ 18 | if isinstance(secret, str): 19 | secret = secret_to_bytes(secret) 20 | 21 | self.secret = secret 22 | self.interval_secs = interval_secs 23 | self.digits = digits 24 | self.digest = digest 25 | 26 | def generate(self, at=None): 27 | """ 28 | Generate a new OTP code. 29 | 30 | Parameters: 31 | at (datetime): timestamp to generate the code for or None to use current time 32 | 33 | Returns: 34 | (int): generated code 35 | """ 36 | timecode = self.__timecode(at or datetime.now()) 37 | hmac_hash = hmac.new(self.secret, timecode, self.digest).digest() 38 | 39 | offset = ord(hmac_hash[19]) & 0xf 40 | code = ((ord(hmac_hash[offset]) & 0x7f) << 24 | 41 | (ord(hmac_hash[offset + 1]) & 0xff) << 16 | 42 | (ord(hmac_hash[offset + 2]) & 0xff) << 8 | 43 | (ord(hmac_hash[offset + 3]) & 0xff)) 44 | 45 | return code % 10 ** self.digits 46 | 47 | def __timecode(self, at): 48 | return timestamp_to_bytestring(int(time.mktime(at.timetuple()) / self.interval_secs)) 49 | 50 | def secret_to_bytes(secret): 51 | """Convert base32 encoded secret string to bytes""" 52 | return base64.b32decode(secret) 53 | 54 | def timestamp_to_bytestring(val, padding=8): 55 | """Convert Unix timestamp to bytes""" 56 | result = [] 57 | 58 | while val != 0: 59 | result.append(chr(val & 0xFF)) 60 | val = val >> 8 61 | 62 | return ''.join(reversed(result)).rjust(padding, '\0') 63 | -------------------------------------------------------------------------------- /metadata/prompt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import os 4 | try: 5 | from tkinter import Tk, Label, Button, Entry, ACTIVE 6 | except: 7 | from Tkinter import Tk, Label, Button, Entry, ACTIVE 8 | 9 | root = Tk() 10 | root.wm_title("Enter MFA Token") 11 | 12 | Label(root, text="Token").pack() 13 | entry = Entry(root) 14 | entry.pack(padx=5) 15 | 16 | 17 | def done(): 18 | print(entry.get()) 19 | root.destroy() 20 | 21 | b = Button(root, text="OK", default=ACTIVE, command=done) 22 | b.pack(pady=5) 23 | 24 | entry.focus_force() 25 | root.bind('', (lambda e, b=b: b.invoke())) 26 | os.system(('''/usr/bin/osascript -e 'tell app "Finder" to set ''' 27 | '''frontmost of process "Python" to true' ''')) 28 | root.mainloop() 29 | -------------------------------------------------------------------------------- /metadata/routes.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from boto.exception import BotoServerError 4 | 5 | from metadata.bottle import route, response, view, delete, post, request 6 | 7 | 8 | @route('/latest/meta-data/local-hostname') 9 | def localhost(): 10 | response.content_type = 'text/plain; charset=UTF-8' 11 | return 'localhost' 12 | 13 | 14 | @route('/latest/meta-data/hostname') 15 | def host(): 16 | response.content_type = 'text/plain; charset=UTF-8' 17 | return 'localhost' 18 | 19 | 20 | @route('/latest/meta-data/public-hostname') 21 | def public_host(): 22 | response.content_type = 'text/plain; charset=UTF-8' 23 | return 'localhost' 24 | 25 | 26 | @route('/latest/meta-data/mac') 27 | def vpc_id(): 28 | response.content_type = 'text/plain; charset=UTF-8' 29 | return 'mac1234' 30 | 31 | 32 | @route('/latest/meta-data/network/interfaces/macs/mac1234/vpc-id') 33 | def vpc_id(): 34 | response.content_type = 'text/plain; charset=UTF-8' 35 | return 'vpc-1234' 36 | 37 | 38 | @route('/latest/meta-data/local-ipv4') 39 | def localip4(): 40 | response.content_type = 'text/plain; charset=UTF-8' 41 | return '127.0.1.1' 42 | 43 | 44 | @route('/latest/meta-data/instance-type') 45 | def instance_type(): 46 | response.content_type = 'text/plain; charset=UTF-8' 47 | return 'r3.2xlarge' 48 | 49 | @route('/latest/meta-data/iam/security-credentials') 50 | @route('/latest/meta-data/iam/security-credentials/') 51 | def list_profiles(): 52 | response.content_type = 'text/plain; charset=UTF-8' 53 | return 'local-credentials' 54 | 55 | 56 | @route('/latest/meta-data/iam/security-credentials/local-credentials') 57 | def get_credentials(): 58 | try: 59 | session = request.app.config.meta_get('metadata', 'obj').get_session() 60 | 61 | return { 62 | 'Code': 'Success', 63 | 'AccessKeyId': session.access_key, 64 | 'SecretAccessKey': session.secret_key, 65 | 'Token': session.session_token, 66 | 'Expiration': session.expiration 67 | } 68 | except BotoServerError as e: 69 | response.status = e.status 70 | return {'error': {'message': e.message}} 71 | 72 | @route('/latest/dynamic/instance-identity/document') 73 | def get_identity_document(): 74 | return { 75 | 'privateIp' : '127.0.0.1', 76 | 'devpayProductCodes' : None, 77 | 'availabilityZone' : 'us-east-1a', 78 | 'version' : '2010-08-31', 79 | 'instanceId' : 'i-12345678', 80 | 'billingProducts' : None, 81 | 'instanceType' : 't2.nano', 82 | 'accountId' : '123456789012', 83 | 'architecture' : 'x86_64', 84 | 'kernelId' : None, 85 | 'ramdiskId' : None, 86 | 'imageId' : 'ami-12345678', 87 | 'pendingTime' : '2016-01-01T00:00:00Z', 88 | 'region' : 'us-east-1' 89 | } 90 | 91 | @route('/manage') 92 | @view('manage') 93 | def manage(): 94 | metadata = request.app.config.meta_get('metadata', 'obj') 95 | 96 | return { 97 | 'session': metadata.session, 98 | 'profile_name': metadata.profile_name, 99 | 'profiles': metadata.profiles 100 | } 101 | 102 | 103 | @route('/manage/profiles') 104 | def get_profiles(): 105 | metadata = request.app.config.meta_get('metadata', 'obj') 106 | 107 | return {'profiles': [ 108 | _profile_info(metadata, name, profile) 109 | for name, profile in metadata.profiles.items() 110 | ]} 111 | 112 | 113 | @route('/manage/profiles/') 114 | def get_profile(name): 115 | metadata = request.app.config.meta_get('metadata', 'obj') 116 | 117 | if name not in metadata.profiles: 118 | response.status = 404 119 | return {'error': {'message': 'profile does not exist'}} 120 | 121 | return {'profile': _profile_info(metadata, name, metadata.profiles[name])} 122 | 123 | 124 | @route('/manage/session') 125 | def get_session(): 126 | metadata = request.app.config.meta_get('metadata', 'obj') 127 | result = { 128 | 'profile': _profile_response(metadata.profile_name, 129 | metadata.profile) 130 | } 131 | 132 | if not metadata.session_expired: 133 | result['session'] = _session_response(metadata.session) 134 | 135 | return result 136 | 137 | 138 | @delete('/manage/session') 139 | def delete_session(): 140 | request.app.config.meta_get('metadata', 'obj').clear_session() 141 | 142 | 143 | @post('/manage/session') 144 | def create_session(): 145 | metadata = request.app.config.meta_get('metadata', 'obj') 146 | token = _get_value(request, 'token') 147 | profile = _get_value(request, 'profile') 148 | 149 | if not token and not profile: 150 | response.status = 400 151 | return { 152 | 'error': { 153 | 'message': 'token and/or profile is required' 154 | } 155 | } 156 | 157 | if profile: 158 | metadata.profile_name = profile 159 | 160 | if token: 161 | try: 162 | request.app.config.meta_get('metadata', 'obj').get_session(token) 163 | except BotoServerError as e: 164 | response.status = e.status 165 | return {'error': {'message': e.message}} 166 | 167 | return get_session() 168 | 169 | 170 | def _get_value(request, key): 171 | value = request.forms.get(key) 172 | 173 | if value is None and request.json: 174 | value = request.json.get(key) 175 | 176 | return value 177 | 178 | 179 | def _session_response(session): 180 | return { 181 | 'accessKey': session.access_key, 182 | 'secretKey': session.secret_key, 183 | 'sessionToken': session.session_token, 184 | 'expiration': session.expiration 185 | } 186 | 187 | 188 | def _profile_info(metadata, name, profile): 189 | result = _profile_response(name, profile) 190 | 191 | if not profile.session_expired: 192 | result['session'] = _session_response(profile.session) 193 | 194 | if metadata.profile_name == name: 195 | result['active'] = True 196 | 197 | return result 198 | 199 | 200 | def _profile_response(name, profile): 201 | response = { 202 | 'accessKey': profile.access_key, 203 | 'region': profile.region 204 | } 205 | 206 | if profile.token_duration: 207 | response['tokenDuration'] = profile.token_duration 208 | 209 | if profile.role_arn: 210 | response['roleArn'] = profile.role_arn 211 | 212 | return response 213 | -------------------------------------------------------------------------------- /metadata/util.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from functools import reduce 3 | 4 | def get_value(d, *keys): 5 | return reduce(lambda a, b: a[b], keys, d) 6 | 7 | 8 | def first_item(l): 9 | """Returns first item in the list or None if empty.""" 10 | return l[0] if len(l) > 0 else None 11 | 12 | 13 | class cache(object): 14 | '''Computes attribute value and caches it in the instance. 15 | Python Cookbook (Denis Otkidach) 16 | http://stackoverflow.com/users/168352/denis-otkidach 17 | This decorator allows you to create a property which can be computed once 18 | and accessed many times. Sort of like memoization. 19 | ''' 20 | def __init__(self, method, name=None): 21 | # record the unbound-method and the name 22 | self.method = method 23 | self.name = name or method.__name__ 24 | self.__doc__ = method.__doc__ 25 | 26 | def __get__(self, inst, cls): 27 | if inst is None: 28 | # instance attribute accessed on class, return self 29 | # You get here if you write `Foo.bar` 30 | return self 31 | # compute, cache and return the instance's attribute value 32 | result = self.method(inst) 33 | # setattr redefines the instance's attribute so this doesn't get called 34 | # again 35 | setattr(inst, self.name, result) 36 | return result 37 | -------------------------------------------------------------------------------- /metadata/views/manage.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | Local Metadata Service 4 | 5 | 26 | 27 | 28 | 29 |

Session

30 |

Profile: {{profile_name}}

31 | %if session: 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
Access Key{{session.access_key}}
Secret Key*******
Session Token*******
Expiration
40 | 41 | 42 | 43 | 61 | %else: 62 |

No session available

63 | %end 64 | 65 |
66 |

Create New Session

67 |
68 | Token: 69 |
70 |
71 | 72 | 87 | 88 |

Profiles

89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | %for name, profile in profiles.items(): 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | %end 112 | 113 |
NameAccess KeyToken DurationRole ARNRegion
{{name}}{{profile.access_key}}{{profile.token_duration}}{{profile.role_arn}}{{profile.region}}
114 | 115 | 116 | 117 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | boto 2 | --------------------------------------------------------------------------------