├── .gitignore ├── docs ├── license.txt └── spec.txt ├── pywebapp ├── __init__.py ├── call-script.py ├── service.py └── validator.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | -------------------------------------------------------------------------------- /docs/license.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2007 Ian Bicking and Contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /docs/spec.txt: -------------------------------------------------------------------------------- 1 | PyWebApps 2 | ========= 3 | 4 | This describes, somewhat pedantically and in a non-helpful order, 5 | parts of what makes up a PyWebApp. Known-open-questions are marked 6 | with "FIXME". 7 | 8 | Process Initialization 9 | ---------------------- 10 | 11 | This is the process that the container must go through when 12 | starting/activating an application. 13 | 14 | You should instantiate ``app = pywebapp.PyWebApp()`` 15 | 16 | Then you should create the settings module with 17 | ``app.setup_settings()``. This creates a module ``websettings``. 18 | 19 | Next you must activate all the services. This is something your tool 20 | itself must do. At its most basic you do:: 21 | 22 | websettings.add_setting(service_name, settings) 23 | 24 | For example:: 25 | 26 | websettings.add_setting('mysql', {'username': 'root', ...}) 27 | 28 | This creates a value ``websettings.mysql`` (but the function does 29 | other checks for validity). There are some helpers you can use (in 30 | ``pywebapp.services``), but these are a convenience to you. 31 | 32 | **After** you have setup the services you call ``app.activate_path()`` 33 | to set ``sys.path``. This also may import code that uses settings, so 34 | it is important to setup services first. 35 | 36 | Get the app from ``app.wsgi_app`` 37 | 38 | Process Configuration 39 | --------------------- 40 | 41 | The ``websettings`` module contains configuration from the 42 | host/container of the application that is being sent to the 43 | application. Anything can go in here, including ad hoc settings, but 44 | some settings are expected. 45 | 46 | websettings.config_dir: 47 | The path to the configuration (a directory). May be None. Must 48 | be set. 49 | 50 | websettings.canonical_hostname: 51 | The "base" hostname. The application may be on a wildcard domain 52 | or something like that, but this is at least one hostname that 53 | will point back to the application. It may be the only hostname. 54 | This like the Host header: ``domain:port``. 55 | 56 | websettings.canonical_scheme: 57 | The expected scheme, generally either ``http`` or ``https``. 58 | ``environ['wsgi.url_scheme']`` also of course must be set. 59 | Generally this is set to https when the container is forcing all 60 | requests to be https. 61 | 62 | websettings.log_dir: 63 | A path where log files can be written. It is simply writable, and 64 | is entirely under control of the application. (FIXME: maybe a 65 | couple names here could be reserved by the container? Or... maybe 66 | best not?) 67 | 68 | Also some environmental context should be set properly: 69 | 70 | current directory: 71 | Must be the application root 72 | 73 | ``$TEMP``: 74 | A temporary directory. This should be application-private, and as 75 | such a suitable place to put cache files and the like. The 76 | container should try to clear this out at appropriate times (like 77 | an update). 78 | 79 | Also expect that over time specific settings will be documented and 80 | validated by this module. E.g., the contents of ``websettings.mysql`` 81 | may be standardized specifically. 82 | 83 | FIXME: some "version" of this specifically itself should go in here, 84 | and also be possible to require or request in the application 85 | description. 86 | 87 | Application Description 88 | ----------------------- 89 | 90 | Configuration files are generally YAML throughout the system. The 91 | application description is in the root of the application directory, 92 | and is called ``app.yaml``. 93 | 94 | app_platform: 95 | This is the basic platform of the application. ``wsgi`` is the 96 | only value we've thought through. Something like Tornado requires 97 | the server itself to be run, an application entry point doesn't 98 | work; it would be another kind of platform (e.g., 99 | ``python-server``?) 100 | 101 | runner: 102 | A Python file. It does not need to be importable, and may have 103 | for example ``-`` in its name, or use an extension like ``.wsgi`` 104 | instead of ``.py``. 105 | 106 | This file when exec'd should produce a global variable 107 | ``application``. This is a WSGI application. 108 | 109 | add_paths: 110 | A single path or a list of paths that should be added to 111 | ``sys.path``. All paths will get priority over system-wide paths, 112 | and ``.pth`` files will be interpreted. Also if a 113 | ``sitecustomize.py`` file exists in any path it will be exec'd. 114 | Multiple ``sitecustomize.py`` files may be exec'd! By default 115 | ``lib/pythonX.Y``, ``lib/pythonX.Y/site-packages``, and 116 | ``lib/python`` will be loaded. It is best to just use the last 117 | (``lib/python``). 118 | 119 | static_path: 120 | A path that will contain static files. These will be available 121 | *at the root* of the application, so if you do ``static_path: 122 | static/`` then ``static/favicon.ico`` will be available at the URL 123 | path ``/favicon.ico``. If you want a file at, for instance, 124 | ``/static/blank.gif`` you must put it in 125 | ``static/static/blank.gif``. 126 | 127 | This value defaults to ``static/`` 128 | 129 | FIXME: there should be a way to set mimetypes 130 | 131 | require_py_version: 132 | Indicates the Python versions supported. (FIXME: just 133 | Setuptools-style requirement, e.g., >=2.7,<3.0 ?) 134 | 135 | require_platform: 136 | This is the hosting platform you require (a list of options). 137 | Generally ``posix`` and ``win`` are the options. 138 | 139 | deb: 140 | These are settings specific to Debian and Ubuntu systems. 141 | 142 | deb.packages: 143 | Packages that should be installed on Debian/Ubuntu systems. 144 | 145 | rpm: 146 | Values specific to RPM-like systems (Redhat, CentOS, etc). 147 | 148 | rpm.packages: 149 | Packages that should be installed on RPM-based systems. (FIXME: 150 | often specific packages are needed, and there isn't a central 151 | repository). (FIXME: maybe we should allow ``rpm.requirements`` 152 | being a ``requirements.txt`` file containing anything that isn't 153 | available in a package, but less than the global ``requirements`` 154 | file?) 155 | 156 | requirements: 157 | A path to a pip ``requirements.txt`` file. (FIXME: does deb/rpm 158 | configuration takes precedence over this? Or do we just make sure 159 | those packages are installed first, so that any already-met 160 | requirements don't then need to be reinstalled?) 161 | 162 | config: 163 | This relates to configuration that the application requires. This 164 | is for applications that are not self-configured. Configuration 165 | is simply a directory, which may contain one or more files, in any 166 | format, to be determined by the application itself. 167 | 168 | Note that configuration should be things that a deployer might 169 | want to change in some useful fashion. E.g., a blog title. 170 | 171 | config.required: 172 | If true then configuration is required. 173 | 174 | config.template: 175 | This is a template for creating a valid configuration (which the 176 | deployer may then edit). What kind of template is not yet 177 | defined. 178 | 179 | Probably it would contain some kind of structured description of 180 | what parameters the template requires, and then a routine that 181 | given the parameters will create a directory structure. 182 | 183 | config.checker: 184 | This checks a configuration for validity (presumably after the 185 | deployer edits it). This is a command. Not fully defined. 186 | 187 | config.default: 188 | This is a relative path which points to the default configuration 189 | if one is not given. If this value is provided then 190 | ``config.required`` won't really matter, as the application will 191 | always have at least its default configuration. 192 | 193 | services: 194 | This contains a number of named services. It is up to the 195 | container to interpret these and setup the services. (FIXME: 196 | clearly this needs to be expanded.) 197 | 198 | Commands 199 | -------- 200 | 201 | Several configuration values are "commands", that is: something that 202 | can be executed. All commands are run in the activated environment 203 | (i.e., after ``sys.path`` has been updated, and with service 204 | configuration). 205 | 206 | Commands take one of several formats: 207 | 208 | URL: 209 | This is a URL that will be fetched. It may be fetched through an 210 | artificial WSGI request (i.e., not over-the-wire HTTP). 211 | 212 | A URL starts with ``url:`` or simply anything that starts with 213 | ``/``. E.g., ``/__heartbeat__`` indicates a request to that URL. 214 | FIXME: also there should be a way to tell that it is being called 215 | as a command, not as an external URL (e.g., special environ key). 216 | 217 | Python script: 218 | This is a script that will be run. It will be run with 219 | ``execfile()``, and in it ``__name__ == '__main__'``, so any 220 | normal script can be used. 221 | 222 | A Python script starts with ``pyscript:`` or any path that *does 223 | not* start with ``/`` and ends in ``.py``. All paths of course 224 | are relative to the root of the application. 225 | 226 | General script: 227 | This is a script that is run in a subprocess, and could be for 228 | instance a shell script. FIXME: we would need to define the 229 | environment? 230 | 231 | The script will be run with the current working directory of the 232 | application root. It will be run with something akin to 233 | ``os.system()``, i.e., as a shell script. 234 | 235 | General scripts must start with ``script:``. 236 | 237 | Python functions: 238 | This is a function to be called. The function cannot take any 239 | arguments. 240 | 241 | A Python function must start with ``pyfunc:`` and have either a 242 | complete dotted-notation path, or ``module:object.attr`` 243 | (Setuptools-style). Also anything that does not start with ``/`` 244 | and is contains only valid Python identifiers and ``.`` will 245 | automatically be considered a Python function. 246 | 247 | Commands can only generally return success or failure, plus readable 248 | messages. The failure case is specific: 249 | 250 | * For URLs, 2xx is success, all other status is a failure. Output is 251 | the body of the response. 252 | 253 | * For a Python script, calling ``SystemExit`` (or ``sys.exit()``) with 254 | a non-zero code is failure, an exception is failure, otherwise it is 255 | success. The output is what is printed to ``sys.stdout`` and 256 | ``sys.stderr``. 257 | 258 | * For a General script, exit code and stdout/stderr. 259 | 260 | * For a Python function, an exception is a failure, all else is 261 | success. Output is stdout/stderr or a string return value (if both, 262 | the string is appended to output). (FIXME: non-string, truish 263 | return value?) 264 | 265 | The command environment for scripts should be: 266 | 267 | ``$PYWEBAPP_LOCATION``: 268 | This environmental variable points to the application root. 269 | 270 | current directory: 271 | This also should(?) be at the application root. 272 | 273 | In the case of exceptions, the output value is preserved and the 274 | ``str()`` of the exception is added. (FIXME: also the traceback?) 275 | 276 | It may be sensible to allow a combination of a rich object (e.g., 277 | response with headers, list of interleaved stdout/stderr, etc) and a 278 | string fallback. 279 | 280 | FIXME: General scripts and URLs can implicitly have arguments (URLs 281 | having the query string), but the others can't (maybe Python scripts 282 | also can have arguments?). Maybe we should allow shell-quoted 283 | arguments to all commands (except URLs). 284 | 285 | Events/hooks 286 | ------------ 287 | 288 | At different stages of an application's deployment lifecycle 289 | 290 | install: 291 | Called when an application is first installed. This would be the 292 | place to create database tables, for instance. 293 | 294 | before_update: 295 | Called before an update is applied. This is called in the context 296 | of the previous installation/version. 297 | 298 | update: 299 | Called when an application is updated, called after the update in 300 | the context of the new version. 301 | 302 | before_delete: 303 | Called before deleting an application. 304 | 305 | ping: 306 | Called to check if an application is alive; must be low in 307 | resource usage. A URL is most preferable for this parameter. 308 | 309 | health_check: 310 | Called to check if an application is in good shape. May do 311 | integrity checks on data, for instance. May be high in resource 312 | usage. 313 | 314 | config.validator: 315 | Called to check if the configuration is valid. 316 | 317 | check_environment: 318 | Can be called to confirm that the environment is properly 319 | configured, for instance to check that all necessary command-line 320 | programs are available. This check is optional, the environment 321 | need not run it. 322 | 323 | If only one of ``install`` or ``update`` are defined, then they are 324 | used interchangeably. E.g., if ``install`` is defined, it is called 325 | on updates. Or if only ``update`` is defined, it is called on install. 326 | 327 | Kinds of Services 328 | ----------------- 329 | 330 | Services are things the provider provides, and can represent a variety 331 | of things. All application state must be represented through 332 | services. As such you can't do much of interest without at least some 333 | services. 334 | 335 | files 336 | ~~~~~ 337 | 338 | This represents just a place to keep files. The files don't do 339 | anything, they are to be read and written by the application. 340 | 341 | The configuration looks like:: 342 | 343 | websettings.files = {'dir': } 344 | 345 | You can write to this directory. An optional key ``"quota"`` is the 346 | most data you are allowed to put in this directory (in bytes). 347 | 348 | public_files 349 | ~~~~~~~~~~~~ 350 | 351 | This is a place to keep files that you want served up. These files 352 | take precedence over your own application! 353 | 354 | The directory layout starts with the domain of the request, or 355 | ``default`` for any/all domains. So if you want to write out a file 356 | that will be served up in ``/user-content/public.html`` then write it 357 | to ``/default/user-content/public.html`` 358 | 359 | Note that this has obvious security concerns, so you should write 360 | things carefully. 361 | 362 | Configuration looks like:: 363 | 364 | websettings.public_files = {"dir": } 365 | 366 | ``"quota"`` is also supported. 367 | 368 | Databases 369 | ~~~~~~~~~ 370 | 371 | Several databases act similarly. 372 | 373 | The configuration parameters that are generally necessary are kept in 374 | a dictionary with these keys: 375 | 376 | host: 377 | The host that the database is on (e.g., ``"localhost"``) 378 | 379 | port: 380 | The port of the database 381 | 382 | dbname: 383 | The name of the database (e.g., ``db_1234``) 384 | 385 | user: 386 | The user to connect as. May be None. 387 | 388 | password: 389 | The password to connect with. May be None. 390 | 391 | low_security_user: 392 | Entirely optional, this is a second user that could be created for 393 | use during runtime (as opposed to application setup). This user 394 | might not have permission to create or delete tables, for instance. 395 | 396 | low_security_password: 397 | Accompanying password. 398 | -------------------------------------------------------------------------------- /pywebapp/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import yaml 4 | import new 5 | import zipfile 6 | import tempfile 7 | import subprocess 8 | from site import addsitedir 9 | 10 | 11 | class PyWebApp(object): 12 | 13 | def __init__(self, path, is_zip, config=None): 14 | self.path = path 15 | self.is_zip = is_zip 16 | self._config = config 17 | 18 | @classmethod 19 | def from_path(cls, path): 20 | is_zip = os.path.isfile(path) 21 | ## FIXME: test if valid zip 22 | return cls(path, is_zip=is_zip) 23 | 24 | @property 25 | def config(self): 26 | if self._config is None: 27 | fp = self.get_file('app.yaml') 28 | try: 29 | return yaml.load(fp) 30 | finally: 31 | fp.close() 32 | return self._config 33 | 34 | ## Helpers for file names and handling: 35 | 36 | def get_file(self, relpath): 37 | if self.is_zip: 38 | zf = zipfile.ZipFile(self.path, 'r') 39 | return zf.open(relpath, 'rb') 40 | else: 41 | filename = os.path.join(self.path, relpath) 42 | return open(filename, 'rb') 43 | 44 | def expanded(self, path=None, tmpdir=None): 45 | if not self.is_zip: 46 | return self 47 | if path is None: 48 | path = tempfile.mkdtemp(dir=tmpdir) 49 | zf = zipfile.ZipFile(self.path, 'r') 50 | ## FIXME: this can escape the path (per zipfile docs): 51 | zf.extractall(path) 52 | return self.__class__.from_path(path) 53 | 54 | def abspath(self, *paths): 55 | return os.path.normcase(os.path.abspath(os.path.join(self.path, *paths))) 56 | 57 | def exists(self, path): 58 | if self.is_zip: 59 | zf = zipfile.ZipFile(path, 'r') 60 | try: 61 | try: 62 | zf.getinfo(path) 63 | return True 64 | except KeyError: 65 | return False 66 | finally: 67 | zf.close() 68 | else: 69 | return os.path.exists(self.abspath(path)) 70 | 71 | ## Properties to read and normalize specific configuration values: 72 | 73 | @property 74 | def name(self): 75 | return self.config['name'] 76 | 77 | @property 78 | def static_path(self): 79 | """The path of static files""" 80 | if 'static' in self.config: 81 | return self.abspath(self.config['static']) 82 | elif self.exists('static'): 83 | return self.abspath('static') 84 | else: 85 | return None 86 | 87 | @property 88 | def runner(self): 89 | """The runner value (where the application is instantiated)""" 90 | runner = self.config.get('runner') 91 | if not runner: 92 | return None 93 | return self.abspath(runner) 94 | 95 | @property 96 | def config_required(self): 97 | """Bool: is the configuration required""" 98 | return self.config.get('config', {}).get('required') 99 | 100 | @property 101 | def config_template(self): 102 | """Path: where a configuration template exists""" 103 | v = self.config.get('config', {}).get('template') 104 | if v: 105 | return self.abspath(v) 106 | return None 107 | 108 | @property 109 | def config_validator(self): 110 | """Object: validator for the configuration""" 111 | v = self.config.get('config', {}).get('validator') 112 | if v: 113 | return self.objloader(v, 'config.validator') 114 | return None 115 | 116 | @property 117 | def config_default(self): 118 | """Path: default configuration if no other is provided""" 119 | dir = self.config.get('config', {}).get('default') 120 | if dir: 121 | return self.abspath(dir) 122 | return None 123 | 124 | @property 125 | def add_paths(self): 126 | """List of paths: things to add to sys.path""" 127 | dirs = self.config.get('add_paths', []) 128 | if isinstance(dirs, basestring): 129 | dirs = [dirs] 130 | ## FIXME: should ensure all paths are relative 131 | return [self.abspath(dir) for dir in dirs] 132 | 133 | @property 134 | def services(self): 135 | """Dict of {service_name: config}: all the configured services. Config may be None""" 136 | services = self.config.get('services', []) 137 | if isinstance(services, list): 138 | services = dict((v, None) for v in services) 139 | return services 140 | 141 | ## Process initialization 142 | 143 | def activate_path(self): 144 | add_paths = list(self.add_paths) 145 | add_paths.extend([ 146 | self.abspath('lib/python%s' % sys.version[:3]), 147 | self.abspath('lib/python%s/site-packages' % sys.version[:3]), 148 | self.abspath('lib/python'), 149 | ]) 150 | for path in reversed(add_paths): 151 | self.add_path(path) 152 | 153 | def setup_settings(self): 154 | """Create the settings that the application itself can import""" 155 | if 'websettings' in sys.modules: 156 | return 157 | module = new.module('websettings') 158 | module.add_setting = _add_setting 159 | sys.modules[module.__name__] = module 160 | return module 161 | 162 | def add_sys_path(self, path): 163 | """Adds one path to sys.path. 164 | 165 | This also reads .pth files, and makes sure all paths end up at the front, ahead 166 | of any system paths. 167 | """ 168 | if not os.path.exists(path): 169 | return 170 | old_path = [os.path.normcase(os.path.abspath(p)) for p in sys.path 171 | if os.path.exists(p)] 172 | addsitedir(path) 173 | new_paths = list(sys.path) 174 | sys.path[:] = old_path 175 | new_sitecustomizes = [] 176 | for path in new_paths: 177 | path = os.path.normcase(os.path.abspath(path)) 178 | if path not in sys.path: 179 | sys.path.insert(0, path) 180 | if os.path.exists(os.path.join(path, 'sitecustomize.py')): 181 | new_sitecustomizes.append(os.path.join(path, 'sitecustomize.py')) 182 | for sitecustomize in new_sitecustomizes: 183 | ns = {'__file__': sitecustomize, '__name__': 'sitecustomize'} 184 | execfile(sitecustomize, ns) 185 | 186 | @property 187 | def wsgi_app(self): 188 | runner = self.runner 189 | if runner is None: 190 | raise Exception( 191 | "No runner has been defined") 192 | ns = {'__file__': runner, '__name__': 'main_py'} 193 | execfile(runner, ns) 194 | if 'application' in ns: 195 | return ns['application'] 196 | else: 197 | raise NameError("No application defined in %s" % runner) 198 | 199 | def call_script(self, script_path, arguments, env_overrides=None, cwd=None, python_exe=None, 200 | stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE): 201 | """Calls a script, returning the subprocess.Proc object 202 | """ 203 | env = os.environ.copy() 204 | script_path = os.path.join(self.path, script_path) 205 | if env_overrides: 206 | env.update(env_overrides) 207 | if not cwd: 208 | cwd = self.path 209 | if not python_exe: 210 | python_exe = sys.executable 211 | calling_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'call-script.py') 212 | args = [python_exe, calling_script, self.path, script_path] 213 | args.extend(arguments) 214 | env['PYWEBAPP_LOCATION'] = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 215 | proc = subprocess.Popen(args, stdout=stdout, stderr=stderr, stdin=stdin, 216 | environ=env, cwd=cwd) 217 | return proc 218 | 219 | ## FIXME: need something to run "commands" (as defined in the spec) 220 | 221 | 222 | def _add_setting(name, value): 223 | _check_settings_value(name, value) 224 | setattr(sys.modules['websettings'], name, value) 225 | 226 | 227 | def _check_settings_value(name, value): 228 | """Checks that a setting value is correct. 229 | 230 | Settings values can only be JSON-compatible types, i.e., list, 231 | dict, string, int/float, bool, None. 232 | """ 233 | if isinstance(value, dict): 234 | for key in value: 235 | if not isinstance(key, basestring): 236 | raise ValueError("Setting %s has invalid key (not a string): %r" 237 | % key) 238 | _check_settings_value(name + "." + key, value[key]) 239 | elif isinstance(value, list): 240 | for index, item in enumerate(value): 241 | _check_settings_value("%s[%r]" % (name, index), item) 242 | elif isinstance(value, (basestring, int, float, bool)): 243 | pass 244 | elif value is None: 245 | pass 246 | else: 247 | raise ValueError("Setting %s is not a valid type: %r" % (name, value)) 248 | -------------------------------------------------------------------------------- /pywebapp/call-script.py: -------------------------------------------------------------------------------- 1 | """Incomplete support file for calling scripts. 2 | 3 | The motivation is in part to create a clean environment for calling a script. 4 | """ 5 | 6 | import sys 7 | import os 8 | 9 | pywebapp_location = os.environ['PYWEBAPP_LOCATION'] 10 | if pywebapp_location not in sys.path: 11 | sys.path.insert(0, pywebapp_location) 12 | ## Doesn't also pick up yaml and maybe other modules, but at least 13 | ## a try? 14 | del os.environ['PYWEBAPP_LOCATION'] 15 | 16 | import pywebapp 17 | 18 | 19 | def main(): 20 | appdir = sys.argv[1] 21 | script_path = sys.argv[2] 22 | rest = sys.argv[3:] 23 | app = pywebapp.PyWebApp.from_path(appdir) 24 | app.setup_settings() 25 | setup_services(app) 26 | app.activate_path() 27 | sys.argv[0] = script_path 28 | sys.argv[1:] = rest 29 | ns = dict(__name__='__main__', __file__=script_path) 30 | execfile(script_path, ns) 31 | 32 | 33 | ## FIXME: this is where I started confused, because we have to call 34 | ## back into the container at this point. 35 | def setup_services(app): 36 | service_setup = os.environ['PYWEBAPP_SERVICE_SETUP'] 37 | mod, callable = service_setup.split(':', 1) 38 | __import__(mod) 39 | mod = sys.modules[mod] 40 | callable = getattr(mod, callable) 41 | callable(app) 42 | 43 | 44 | if __name__ == '__main__': 45 | main() 46 | -------------------------------------------------------------------------------- /pywebapp/service.py: -------------------------------------------------------------------------------- 1 | """This is experimental abstract base classes for implementing 2 | services. 3 | 4 | A container might use this, but it's very optional. 5 | """ 6 | 7 | import sys 8 | import traceback 9 | 10 | 11 | class AbstractService(object): 12 | 13 | name = None 14 | 15 | def __init__(self, app, service_settings): 16 | self.app = app 17 | self.service_settings = service_settings 18 | assert self.name is not None 19 | 20 | def settings(self): 21 | """Return the settings that should be put into websettings""" 22 | raise NotImplemented 23 | 24 | def install(self): 25 | """Implement per-service and per-tool installation for this service""" 26 | raise NotImplemented 27 | 28 | def backup(self, output_dir): 29 | """Back up this service to files in output_dir""" 30 | raise NotImplemented 31 | 32 | def restore(self, input_dir): 33 | """Restore from files, the inverse of backup""" 34 | raise NotImplemented 35 | 36 | def clear(self): 37 | """Clear the service's data, if applicable""" 38 | raise NotImplemented 39 | 40 | def check_setup(self): 41 | """Checks that the service is working, raise an error if not, 42 | return a string if there is a warning. 43 | 44 | For instance this might try to open a database connection to 45 | confirm the database is really accessible. 46 | """ 47 | raise NotImplemented 48 | 49 | 50 | class ServiceFinder(object): 51 | 52 | def __init__(self, module=None, package=None, 53 | class_template='%(capital)sService'): 54 | if not module and not package: 55 | raise ValueError("You must pass in module or package") 56 | self.module = module 57 | self.package = package 58 | self.class_template = class_template 59 | 60 | def get_module(self): 61 | if isinstance(self.module, basestring): 62 | self.module = self.load_module(self.module) 63 | return self.module 64 | 65 | def get_package(self, name): 66 | if self.package is None: 67 | return None 68 | if not isinstance(self.package, basestring): 69 | self.package = self.package.__name__ 70 | module = self.package + '.' + name 71 | return self.load_module(module) 72 | 73 | def load_module(self, module_name): 74 | if module_name not in sys.modules: 75 | __import__(module_name) 76 | return sys.modules[module_name] 77 | 78 | def get_service(self, name): 79 | class_name = self.class_template % dict( 80 | capital=name.capitalize(), 81 | upper=name.upper(), 82 | lower=name.lower(), 83 | name=name) 84 | module = self.get_module() 85 | obj = None 86 | if module: 87 | if self.package: 88 | obj = getattr(module, class_name, None) 89 | else: 90 | obj = getattr(module, class_name) 91 | if obj is None: 92 | package = self.get_package(name) 93 | if package: 94 | obj = getattr(package, class_name) 95 | if obj is None: 96 | raise ImportError("Could not find service %r" % name) 97 | return obj 98 | 99 | 100 | def load_services(app, finder, services=None): 101 | result = {} 102 | if services is None: 103 | services = app.services 104 | for service_name in services: 105 | ServiceClass = finder.get_service(service_name) 106 | service = ServiceClass(app, services[service_name]) 107 | result[service_name] = service 108 | return result 109 | 110 | 111 | def call_services_method(failure_callback, services, method_name, *args, **kw): 112 | result = [] 113 | last_exc = None 114 | for service_name, service in sorted(services.items()): 115 | method = getattr(service, method_name) 116 | try: 117 | result.append((service_name, method(*args, **kw))) 118 | except NotImplemented: 119 | failure_callback( 120 | service_name, '%s does not implement %s' % (service_name, method_name)) 121 | except: 122 | last_exc = sys.exc_info() 123 | exc = traceback.format_exc() 124 | failure_callback( 125 | service_name, 'Exception in %s.%s:\n%s' % (service_name, method_name, exc)) 126 | if last_exc: 127 | raise last_exc[0], last_exc[1], last_exc[2] 128 | return result 129 | -------------------------------------------------------------------------------- /pywebapp/validator.py: -------------------------------------------------------------------------------- 1 | """Validate all aspects of an application""" 2 | 3 | import re 4 | import os 5 | 6 | 7 | def validate(app): 8 | errors = [] 9 | if not re.search(r'^[a-zA-Z][a-zA-Z0-9_-]*$', app.name): 10 | errors.append( 11 | "Application name (%r) must be letters and number, _, and -" % app.name) 12 | if app.static_path: 13 | if not os.path.exists(app.static_path): 14 | errors.append( 15 | "Application static path (%s) does not exist" % app.static_path) 16 | elif not os.path.isdir(app.static_path): 17 | errors.append( 18 | "Application static path (%s) must be a directory" % app.static_path) 19 | if not app.runner: 20 | errors.append( 21 | "Application runner is not set") 22 | elif not os.path.exists(app.runner): 23 | errors.append( 24 | "Application runner file (%s) does not exist" % app.runner) 25 | ## FIXME: validate config_template and config_validator 26 | if app.config_default: 27 | if not os.path.exists(app.config_default): 28 | errors.append( 29 | "Application config.default (%s) does not exist" % app.config_default) 30 | elif not os.path.isdir(app.config_default): 31 | errors.append( 32 | "Application config.default (%s) is not a directory" % app.config_default) 33 | if app.add_paths: 34 | for index, path in enumerate(app.add_paths): 35 | if not os.path.exists(path): 36 | errors.append( 37 | "Application add_paths[%r] (%s) does not exist" 38 | % (index, path)) 39 | elif not os.path.isdir(path): 40 | ## FIXME: I guess it could be a zip file? 41 | errors.append( 42 | "Application add_paths[%r] (%s) is not a directory" 43 | % (index, path)) 44 | return errors 45 | 46 | 47 | if __name__ == '__main__': 48 | import sys 49 | if not sys.argv[1:]: 50 | print 'Usage: %s APP_DIR' % sys.argv[0] 51 | sys.exit(1) 52 | import pywebapp 53 | app = pywebapp.PyWebApp.from_path(sys.argv[1]) 54 | errors = validate(app) 55 | if not errors: 56 | print 'Application OK' 57 | else: 58 | print 'Errors in application:' 59 | for line in errors: 60 | print ' * %s' % line 61 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import sys, os 3 | 4 | version = '0.0' 5 | 6 | setup(name='pywebapp', 7 | version=version, 8 | description="Support library for Python Web Applications", 9 | long_description="""\ 10 | """, 11 | classifiers=[], # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers 12 | keywords='wsgi web', 13 | author='Ian Bicking', 14 | author_email='ianb@colorstudy.com', 15 | url='', 16 | license='MIT', 17 | packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), 18 | include_package_data=True, 19 | zip_safe=False, 20 | install_requires=[ 21 | "pyyaml", 22 | ], 23 | entry_points=""" 24 | # -*- Entry points: -*- 25 | """, 26 | ) 27 | --------------------------------------------------------------------------------