├── .gitignore ├── .travis.yml ├── MANIFEST.in ├── README.md ├── distribute_setup.py ├── docs ├── Makefile ├── advanced.rst ├── conf.py ├── deploying-keystone.rst ├── http-errors.rst ├── index.rst ├── keystonelexer.py ├── nothing.txt ├── quickstart.rst ├── tutorial.rst ├── tutorial │ ├── installing-keystone.rst │ ├── keystone-1-index.png │ ├── keystone-2-index.png │ ├── keystone-2-pageone.png │ ├── keystone-3-counting.png │ ├── keystone-bob.png │ ├── keystone-count-form.png │ ├── make-it-dynamic.rst │ ├── reacting-to-the-web.rst │ ├── the-first-page.rst │ └── whats-next.rst └── view-variables.rst ├── keystone ├── __init__.py ├── http.py ├── main.py ├── render.py └── scripts.py ├── setup.cfg ├── setup.py └── test ├── __init__.py ├── test_main.py ├── test_render.py └── util.py /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | *.pyc 3 | .coverage 4 | distribute-*.egg 5 | distribute-*.tar.gz 6 | keystone.egg-info 7 | dist 8 | build 9 | docs/_build 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 2.5 4 | - 2.6 5 | - 2.7 6 | - 3.2 7 | install: python setup.py -q install 8 | script: python setup.py -q nosetests 9 | matrix: 10 | allow_failures: 11 | - python: 3.2 12 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include distribute_setup.py 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Keystone Is... 2 | 3 | * An easy-to learn Python web framework 4 | * That puts templates first 5 | * That builds on [high](http://werkzeug.pocoo.org) [quality](http://jinja.pocoo.org) components 6 | * That will only take minutes to learn 7 | * That encourages best practices 8 | 9 | ## Keystone in 30 seconds or less 10 | 11 | $ mkdir helloworld 12 | $ cat << EOF > helloworld/index.ks 13 | name = 'World' 14 | ---- 15 | 16 | 17 | 18 | Welcome to Keystone 19 | 20 | 21 |

Hello, {{name}}

22 | 23 | 24 | EOF 25 | $ keystone helloworld 26 | $ open http://localhost:5000/ 27 | 28 | ![helloworld.png](http://f.cl.ly/items/2r400y3r2x3P3F1x3u22/helloworld.png) 29 | 30 | ---- 31 | 32 | [![Build Status](https://secure.travis-ci.org/dcrosta/keystone.png?branch=master)](http://travis-ci.org/dcrosta/keystone) 33 | -------------------------------------------------------------------------------- /distribute_setup.py: -------------------------------------------------------------------------------- 1 | #!python 2 | """Bootstrap distribute installation 3 | 4 | If you want to use setuptools in your package's setup.py, just include this 5 | file in the same directory with it, and add this to the top of your setup.py:: 6 | 7 | from distribute_setup import use_setuptools 8 | use_setuptools() 9 | 10 | If you want to require a specific version of setuptools, set a download 11 | mirror, or use an alternate download directory, you can do so by supplying 12 | the appropriate options to ``use_setuptools()``. 13 | 14 | This file can also be run as a script to install or upgrade setuptools. 15 | """ 16 | import os 17 | import sys 18 | import time 19 | import fnmatch 20 | import tempfile 21 | import tarfile 22 | from distutils import log 23 | 24 | try: 25 | from site import USER_SITE 26 | except ImportError: 27 | USER_SITE = None 28 | 29 | try: 30 | import subprocess 31 | 32 | def _python_cmd(*args): 33 | args = (sys.executable,) + args 34 | return subprocess.call(args) == 0 35 | 36 | except ImportError: 37 | # will be used for python 2.3 38 | def _python_cmd(*args): 39 | args = (sys.executable,) + args 40 | # quoting arguments if windows 41 | if sys.platform == 'win32': 42 | def quote(arg): 43 | if ' ' in arg: 44 | return '"%s"' % arg 45 | return arg 46 | args = [quote(arg) for arg in args] 47 | return os.spawnl(os.P_WAIT, sys.executable, *args) == 0 48 | 49 | DEFAULT_VERSION = "0.6.24" 50 | DEFAULT_URL = "http://pypi.python.org/packages/source/d/distribute/" 51 | SETUPTOOLS_FAKED_VERSION = "0.6c11" 52 | 53 | SETUPTOOLS_PKG_INFO = """\ 54 | Metadata-Version: 1.0 55 | Name: setuptools 56 | Version: %s 57 | Summary: xxxx 58 | Home-page: xxx 59 | Author: xxx 60 | Author-email: xxx 61 | License: xxx 62 | Description: xxx 63 | """ % SETUPTOOLS_FAKED_VERSION 64 | 65 | 66 | def _install(tarball): 67 | # extracting the tarball 68 | tmpdir = tempfile.mkdtemp() 69 | log.warn('Extracting in %s', tmpdir) 70 | old_wd = os.getcwd() 71 | try: 72 | os.chdir(tmpdir) 73 | tar = tarfile.open(tarball) 74 | _extractall(tar) 75 | tar.close() 76 | 77 | # going in the directory 78 | subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) 79 | os.chdir(subdir) 80 | log.warn('Now working in %s', subdir) 81 | 82 | # installing 83 | log.warn('Installing Distribute') 84 | if not _python_cmd('setup.py', 'install'): 85 | log.warn('Something went wrong during the installation.') 86 | log.warn('See the error message above.') 87 | finally: 88 | os.chdir(old_wd) 89 | 90 | 91 | def _build_egg(egg, tarball, to_dir): 92 | # extracting the tarball 93 | tmpdir = tempfile.mkdtemp() 94 | log.warn('Extracting in %s', tmpdir) 95 | old_wd = os.getcwd() 96 | try: 97 | os.chdir(tmpdir) 98 | tar = tarfile.open(tarball) 99 | _extractall(tar) 100 | tar.close() 101 | 102 | # going in the directory 103 | subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) 104 | os.chdir(subdir) 105 | log.warn('Now working in %s', subdir) 106 | 107 | # building an egg 108 | log.warn('Building a Distribute egg in %s', to_dir) 109 | _python_cmd('setup.py', '-q', 'bdist_egg', '--dist-dir', to_dir) 110 | 111 | finally: 112 | os.chdir(old_wd) 113 | # returning the result 114 | log.warn(egg) 115 | if not os.path.exists(egg): 116 | raise IOError('Could not build the egg.') 117 | 118 | 119 | def _do_download(version, download_base, to_dir, download_delay): 120 | egg = os.path.join(to_dir, 'distribute-%s-py%d.%d.egg' 121 | % (version, sys.version_info[0], sys.version_info[1])) 122 | if not os.path.exists(egg): 123 | tarball = download_setuptools(version, download_base, 124 | to_dir, download_delay) 125 | _build_egg(egg, tarball, to_dir) 126 | sys.path.insert(0, egg) 127 | import setuptools 128 | setuptools.bootstrap_install_from = egg 129 | 130 | 131 | def use_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, 132 | to_dir=os.curdir, download_delay=15, no_fake=True): 133 | # making sure we use the absolute path 134 | to_dir = os.path.abspath(to_dir) 135 | was_imported = 'pkg_resources' in sys.modules or \ 136 | 'setuptools' in sys.modules 137 | try: 138 | try: 139 | import pkg_resources 140 | if not hasattr(pkg_resources, '_distribute'): 141 | if not no_fake: 142 | _fake_setuptools() 143 | raise ImportError 144 | except ImportError: 145 | return _do_download(version, download_base, to_dir, download_delay) 146 | try: 147 | pkg_resources.require("distribute>="+version) 148 | return 149 | except pkg_resources.VersionConflict: 150 | e = sys.exc_info()[1] 151 | if was_imported: 152 | sys.stderr.write( 153 | "The required version of distribute (>=%s) is not available,\n" 154 | "and can't be installed while this script is running. Please\n" 155 | "install a more recent version first, using\n" 156 | "'easy_install -U distribute'." 157 | "\n\n(Currently using %r)\n" % (version, e.args[0])) 158 | sys.exit(2) 159 | else: 160 | del pkg_resources, sys.modules['pkg_resources'] # reload ok 161 | return _do_download(version, download_base, to_dir, 162 | download_delay) 163 | except pkg_resources.DistributionNotFound: 164 | return _do_download(version, download_base, to_dir, 165 | download_delay) 166 | finally: 167 | if not no_fake: 168 | _create_fake_setuptools_pkg_info(to_dir) 169 | 170 | def download_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, 171 | to_dir=os.curdir, delay=15): 172 | """Download distribute from a specified location and return its filename 173 | 174 | `version` should be a valid distribute version number that is available 175 | as an egg for download under the `download_base` URL (which should end 176 | with a '/'). `to_dir` is the directory where the egg will be downloaded. 177 | `delay` is the number of seconds to pause before an actual download 178 | attempt. 179 | """ 180 | # making sure we use the absolute path 181 | to_dir = os.path.abspath(to_dir) 182 | try: 183 | from urllib.request import urlopen 184 | except ImportError: 185 | from urllib2 import urlopen 186 | tgz_name = "distribute-%s.tar.gz" % version 187 | url = download_base + tgz_name 188 | saveto = os.path.join(to_dir, tgz_name) 189 | src = dst = None 190 | if not os.path.exists(saveto): # Avoid repeated downloads 191 | try: 192 | log.warn("Downloading %s", url) 193 | src = urlopen(url) 194 | # Read/write all in one block, so we don't create a corrupt file 195 | # if the download is interrupted. 196 | data = src.read() 197 | dst = open(saveto, "wb") 198 | dst.write(data) 199 | finally: 200 | if src: 201 | src.close() 202 | if dst: 203 | dst.close() 204 | return os.path.realpath(saveto) 205 | 206 | def _no_sandbox(function): 207 | def __no_sandbox(*args, **kw): 208 | try: 209 | from setuptools.sandbox import DirectorySandbox 210 | if not hasattr(DirectorySandbox, '_old'): 211 | def violation(*args): 212 | pass 213 | DirectorySandbox._old = DirectorySandbox._violation 214 | DirectorySandbox._violation = violation 215 | patched = True 216 | else: 217 | patched = False 218 | except ImportError: 219 | patched = False 220 | 221 | try: 222 | return function(*args, **kw) 223 | finally: 224 | if patched: 225 | DirectorySandbox._violation = DirectorySandbox._old 226 | del DirectorySandbox._old 227 | 228 | return __no_sandbox 229 | 230 | def _patch_file(path, content): 231 | """Will backup the file then patch it""" 232 | existing_content = open(path).read() 233 | if existing_content == content: 234 | # already patched 235 | log.warn('Already patched.') 236 | return False 237 | log.warn('Patching...') 238 | _rename_path(path) 239 | f = open(path, 'w') 240 | try: 241 | f.write(content) 242 | finally: 243 | f.close() 244 | return True 245 | 246 | _patch_file = _no_sandbox(_patch_file) 247 | 248 | def _same_content(path, content): 249 | return open(path).read() == content 250 | 251 | def _rename_path(path): 252 | new_name = path + '.OLD.%s' % time.time() 253 | log.warn('Renaming %s into %s', path, new_name) 254 | os.rename(path, new_name) 255 | return new_name 256 | 257 | def _remove_flat_installation(placeholder): 258 | if not os.path.isdir(placeholder): 259 | log.warn('Unkown installation at %s', placeholder) 260 | return False 261 | found = False 262 | for file in os.listdir(placeholder): 263 | if fnmatch.fnmatch(file, 'setuptools*.egg-info'): 264 | found = True 265 | break 266 | if not found: 267 | log.warn('Could not locate setuptools*.egg-info') 268 | return 269 | 270 | log.warn('Removing elements out of the way...') 271 | pkg_info = os.path.join(placeholder, file) 272 | if os.path.isdir(pkg_info): 273 | patched = _patch_egg_dir(pkg_info) 274 | else: 275 | patched = _patch_file(pkg_info, SETUPTOOLS_PKG_INFO) 276 | 277 | if not patched: 278 | log.warn('%s already patched.', pkg_info) 279 | return False 280 | # now let's move the files out of the way 281 | for element in ('setuptools', 'pkg_resources.py', 'site.py'): 282 | element = os.path.join(placeholder, element) 283 | if os.path.exists(element): 284 | _rename_path(element) 285 | else: 286 | log.warn('Could not find the %s element of the ' 287 | 'Setuptools distribution', element) 288 | return True 289 | 290 | _remove_flat_installation = _no_sandbox(_remove_flat_installation) 291 | 292 | def _after_install(dist): 293 | log.warn('After install bootstrap.') 294 | placeholder = dist.get_command_obj('install').install_purelib 295 | _create_fake_setuptools_pkg_info(placeholder) 296 | 297 | def _create_fake_setuptools_pkg_info(placeholder): 298 | if not placeholder or not os.path.exists(placeholder): 299 | log.warn('Could not find the install location') 300 | return 301 | pyver = '%s.%s' % (sys.version_info[0], sys.version_info[1]) 302 | setuptools_file = 'setuptools-%s-py%s.egg-info' % \ 303 | (SETUPTOOLS_FAKED_VERSION, pyver) 304 | pkg_info = os.path.join(placeholder, setuptools_file) 305 | if os.path.exists(pkg_info): 306 | log.warn('%s already exists', pkg_info) 307 | return 308 | 309 | log.warn('Creating %s', pkg_info) 310 | f = open(pkg_info, 'w') 311 | try: 312 | f.write(SETUPTOOLS_PKG_INFO) 313 | finally: 314 | f.close() 315 | 316 | pth_file = os.path.join(placeholder, 'setuptools.pth') 317 | log.warn('Creating %s', pth_file) 318 | f = open(pth_file, 'w') 319 | try: 320 | f.write(os.path.join(os.curdir, setuptools_file)) 321 | finally: 322 | f.close() 323 | 324 | _create_fake_setuptools_pkg_info = _no_sandbox(_create_fake_setuptools_pkg_info) 325 | 326 | def _patch_egg_dir(path): 327 | # let's check if it's already patched 328 | pkg_info = os.path.join(path, 'EGG-INFO', 'PKG-INFO') 329 | if os.path.exists(pkg_info): 330 | if _same_content(pkg_info, SETUPTOOLS_PKG_INFO): 331 | log.warn('%s already patched.', pkg_info) 332 | return False 333 | _rename_path(path) 334 | os.mkdir(path) 335 | os.mkdir(os.path.join(path, 'EGG-INFO')) 336 | pkg_info = os.path.join(path, 'EGG-INFO', 'PKG-INFO') 337 | f = open(pkg_info, 'w') 338 | try: 339 | f.write(SETUPTOOLS_PKG_INFO) 340 | finally: 341 | f.close() 342 | return True 343 | 344 | _patch_egg_dir = _no_sandbox(_patch_egg_dir) 345 | 346 | def _before_install(): 347 | log.warn('Before install bootstrap.') 348 | _fake_setuptools() 349 | 350 | 351 | def _under_prefix(location): 352 | if 'install' not in sys.argv: 353 | return True 354 | args = sys.argv[sys.argv.index('install')+1:] 355 | for index, arg in enumerate(args): 356 | for option in ('--root', '--prefix'): 357 | if arg.startswith('%s=' % option): 358 | top_dir = arg.split('root=')[-1] 359 | return location.startswith(top_dir) 360 | elif arg == option: 361 | if len(args) > index: 362 | top_dir = args[index+1] 363 | return location.startswith(top_dir) 364 | if arg == '--user' and USER_SITE is not None: 365 | return location.startswith(USER_SITE) 366 | return True 367 | 368 | 369 | def _fake_setuptools(): 370 | log.warn('Scanning installed packages') 371 | try: 372 | import pkg_resources 373 | except ImportError: 374 | # we're cool 375 | log.warn('Setuptools or Distribute does not seem to be installed.') 376 | return 377 | ws = pkg_resources.working_set 378 | try: 379 | setuptools_dist = ws.find(pkg_resources.Requirement.parse('setuptools', 380 | replacement=False)) 381 | except TypeError: 382 | # old distribute API 383 | setuptools_dist = ws.find(pkg_resources.Requirement.parse('setuptools')) 384 | 385 | if setuptools_dist is None: 386 | log.warn('No setuptools distribution found') 387 | return 388 | # detecting if it was already faked 389 | setuptools_location = setuptools_dist.location 390 | log.warn('Setuptools installation detected at %s', setuptools_location) 391 | 392 | # if --root or --preix was provided, and if 393 | # setuptools is not located in them, we don't patch it 394 | if not _under_prefix(setuptools_location): 395 | log.warn('Not patching, --root or --prefix is installing Distribute' 396 | ' in another location') 397 | return 398 | 399 | # let's see if its an egg 400 | if not setuptools_location.endswith('.egg'): 401 | log.warn('Non-egg installation') 402 | res = _remove_flat_installation(setuptools_location) 403 | if not res: 404 | return 405 | else: 406 | log.warn('Egg installation') 407 | pkg_info = os.path.join(setuptools_location, 'EGG-INFO', 'PKG-INFO') 408 | if (os.path.exists(pkg_info) and 409 | _same_content(pkg_info, SETUPTOOLS_PKG_INFO)): 410 | log.warn('Already patched.') 411 | return 412 | log.warn('Patching...') 413 | # let's create a fake egg replacing setuptools one 414 | res = _patch_egg_dir(setuptools_location) 415 | if not res: 416 | return 417 | log.warn('Patched done.') 418 | _relaunch() 419 | 420 | 421 | def _relaunch(): 422 | log.warn('Relaunching...') 423 | # we have to relaunch the process 424 | # pip marker to avoid a relaunch bug 425 | if sys.argv[:3] == ['-c', 'install', '--single-version-externally-managed']: 426 | sys.argv[0] = 'setup.py' 427 | args = [sys.executable] + sys.argv 428 | sys.exit(subprocess.call(args)) 429 | 430 | 431 | def _extractall(self, path=".", members=None): 432 | """Extract all members from the archive to the current working 433 | directory and set owner, modification time and permissions on 434 | directories afterwards. `path' specifies a different directory 435 | to extract to. `members' is optional and must be a subset of the 436 | list returned by getmembers(). 437 | """ 438 | import copy 439 | import operator 440 | from tarfile import ExtractError 441 | directories = [] 442 | 443 | if members is None: 444 | members = self 445 | 446 | for tarinfo in members: 447 | if tarinfo.isdir(): 448 | # Extract directories with a safe mode. 449 | directories.append(tarinfo) 450 | tarinfo = copy.copy(tarinfo) 451 | tarinfo.mode = 448 # decimal for oct 0700 452 | self.extract(tarinfo, path) 453 | 454 | # Reverse sort directories. 455 | if sys.version_info < (2, 4): 456 | def sorter(dir1, dir2): 457 | return cmp(dir1.name, dir2.name) 458 | directories.sort(sorter) 459 | directories.reverse() 460 | else: 461 | directories.sort(key=operator.attrgetter('name'), reverse=True) 462 | 463 | # Set correct owner, mtime and filemode on directories. 464 | for tarinfo in directories: 465 | dirpath = os.path.join(path, tarinfo.name) 466 | try: 467 | self.chown(tarinfo, dirpath) 468 | self.utime(tarinfo, dirpath) 469 | self.chmod(tarinfo, dirpath) 470 | except ExtractError: 471 | e = sys.exc_info()[1] 472 | if self.errorlevel > 1: 473 | raise 474 | else: 475 | self._dbg(1, "tarfile: %s" % e) 476 | 477 | 478 | def main(argv, version=DEFAULT_VERSION): 479 | """Install or upgrade setuptools and EasyInstall""" 480 | tarball = download_setuptools() 481 | _install(tarball) 482 | 483 | 484 | if __name__ == '__main__': 485 | main(sys.argv[1:]) 486 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Keystone.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Keystone.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Keystone" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Keystone" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/advanced.rst: -------------------------------------------------------------------------------- 1 | Advanced Topics 2 | =============== 3 | 4 | 5 | Returning Binary Data 6 | --------------------- 7 | 8 | By default, Keystone assumes a `Content-Type` of ``text/html`` for responses 9 | generated from ``.ks`` views, and requires a valid template section which 10 | must produce UTF-8 output. You can override this behavior on a per-view 11 | basis with :func:`return_response`, which bypasses the template 12 | entirely. 13 | 14 | .. code-block:: keystone 15 | 16 | fp = file('/tmp/generated_file.pdf', 'rb') 17 | header('Content-Type', 'application/pdf') 18 | return_response(fp) 19 | ---- 20 | 21 | The `body` argument to :func:`return_response` may be a string or any 22 | iterable type (list, generator, file object) which yields strings. 23 | 24 | .. note:: 25 | 26 | As of Keystone |version|, you must still have a template section in your 27 | ``.ks`` file when using :func:`return_response()`, though it may be 28 | empty. This may change in a future version of Keystone. 29 | 30 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Keystone documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Nov 22 16:30:56 2011. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'keystonelexer'] 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # The suffix of source filenames. 34 | source_suffix = '.rst' 35 | 36 | # The encoding of source files. 37 | #source_encoding = 'utf-8-sig' 38 | 39 | # The master toctree document. 40 | master_doc = 'index' 41 | 42 | # General information about the project. 43 | project = u'Keystone' 44 | copyright = u'2011, Dan Crosta' 45 | 46 | # The version info for the project you're documenting, acts as replacement for 47 | # |version| and |release|, also used in various other places throughout the 48 | # built documents. 49 | # 50 | # The short X.Y version. 51 | from keystone import __version_info__ as vtuple 52 | version = '.'.join(map(str, vtuple[:2])) 53 | # The full version, including alpha/beta/rc tags. 54 | release = '.'.join(map(str, vtuple)) 55 | 56 | # The language for content autogenerated by Sphinx. Refer to documentation 57 | # for a list of supported languages. 58 | #language = None 59 | 60 | # There are two options for replacing |today|: either, you set today to some 61 | # non-false value, then it is used: 62 | #today = '' 63 | # Else, today_fmt is used as the format for a strftime call. 64 | #today_fmt = '%B %d, %Y' 65 | 66 | # List of patterns, relative to source directory, that match files and 67 | # directories to ignore when looking for source files. 68 | exclude_patterns = ['_build'] 69 | 70 | # The reST default role (used for this markup: `text`) to use for all documents. 71 | #default_role = None 72 | 73 | # If true, '()' will be appended to :func: etc. cross-reference text. 74 | #add_function_parentheses = True 75 | 76 | # If true, the current module name will be prepended to all description 77 | # unit titles (such as .. function::). 78 | #add_module_names = True 79 | 80 | # If true, sectionauthor and moduleauthor directives will be shown in the 81 | # output. They are ignored by default. 82 | #show_authors = False 83 | 84 | # The name of the Pygments (syntax highlighting) style to use. 85 | pygments_style = 'sphinx' 86 | 87 | # A list of ignored prefixes for module index sorting. 88 | #modindex_common_prefix = [] 89 | 90 | 91 | # -- Options for HTML output --------------------------------------------------- 92 | 93 | # The theme to use for HTML and HTML Help pages. See the documentation for 94 | # a list of builtin themes. 95 | html_theme = 'nature' 96 | 97 | # Theme options are theme-specific and customize the look and feel of a theme 98 | # further. For a list of options available for each theme, see the 99 | # documentation. 100 | #html_theme_options = {} 101 | 102 | # Add any paths that contain custom themes here, relative to this directory. 103 | #html_theme_path = [] 104 | 105 | # The name for this set of Sphinx documents. If None, it defaults to 106 | # " v documentation". 107 | #html_title = None 108 | 109 | # A shorter title for the navigation bar. Default is the same as html_title. 110 | #html_short_title = None 111 | 112 | # The name of an image file (relative to this directory) to place at the top 113 | # of the sidebar. 114 | #html_logo = None 115 | 116 | # The name of an image file (within the static path) to use as favicon of the 117 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 118 | # pixels large. 119 | #html_favicon = None 120 | 121 | # Add any paths that contain custom static files (such as style sheets) here, 122 | # relative to this directory. They are copied after the builtin static files, 123 | # so a file named "default.css" will overwrite the builtin "default.css". 124 | html_static_path = ['_static'] 125 | 126 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 127 | # using the given strftime format. 128 | #html_last_updated_fmt = '%b %d, %Y' 129 | 130 | # If true, SmartyPants will be used to convert quotes and dashes to 131 | # typographically correct entities. 132 | #html_use_smartypants = True 133 | 134 | # Custom sidebar templates, maps document names to template names. 135 | #html_sidebars = {} 136 | 137 | # Additional templates that should be rendered to pages, maps page names to 138 | # template names. 139 | #html_additional_pages = {} 140 | 141 | # If false, no module index is generated. 142 | #html_domain_indices = True 143 | 144 | # If false, no index is generated. 145 | #html_use_index = True 146 | 147 | # If true, the index is split into individual pages for each letter. 148 | #html_split_index = False 149 | 150 | # If true, links to the reST sources are added to the pages. 151 | #html_show_sourcelink = True 152 | 153 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 154 | #html_show_sphinx = True 155 | 156 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 157 | #html_show_copyright = True 158 | 159 | # If true, an OpenSearch description file will be output, and all pages will 160 | # contain a tag referring to it. The value of this option must be the 161 | # base URL from which the finished HTML is served. 162 | #html_use_opensearch = '' 163 | 164 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 165 | #html_file_suffix = None 166 | 167 | # Output file base name for HTML help builder. 168 | htmlhelp_basename = 'Keystonedoc' 169 | 170 | 171 | # -- Options for LaTeX output -------------------------------------------------- 172 | 173 | latex_elements = { 174 | # The paper size ('letterpaper' or 'a4paper'). 175 | #'papersize': 'letterpaper', 176 | 177 | # The font size ('10pt', '11pt' or '12pt'). 178 | #'pointsize': '10pt', 179 | 180 | # Additional stuff for the LaTeX preamble. 181 | #'preamble': '', 182 | } 183 | 184 | # Grouping the document tree into LaTeX files. List of tuples 185 | # (source start file, target name, title, author, documentclass [howto/manual]). 186 | latex_documents = [ 187 | ('index', 'Keystone.tex', u'Keystone Documentation', 188 | u'Dan Crosta', 'manual'), 189 | ] 190 | 191 | # The name of an image file (relative to this directory) to place at the top of 192 | # the title page. 193 | #latex_logo = None 194 | 195 | # For "manual" documents, if this is true, then toplevel headings are parts, 196 | # not chapters. 197 | #latex_use_parts = False 198 | 199 | # If true, show page references after internal links. 200 | #latex_show_pagerefs = False 201 | 202 | # If true, show URL addresses after external links. 203 | #latex_show_urls = False 204 | 205 | # Documents to append as an appendix to all manuals. 206 | #latex_appendices = [] 207 | 208 | # If false, no module index is generated. 209 | #latex_domain_indices = True 210 | 211 | 212 | # -- Options for manual page output -------------------------------------------- 213 | 214 | # One entry per manual page. List of tuples 215 | # (source start file, name, description, authors, manual section). 216 | man_pages = [ 217 | ('index', 'keystone', u'Keystone Documentation', 218 | [u'Dan Crosta'], 1) 219 | ] 220 | 221 | # If true, show URL addresses after external links. 222 | #man_show_urls = False 223 | 224 | 225 | # -- Options for Texinfo output ------------------------------------------------ 226 | 227 | # Grouping the document tree into Texinfo files. List of tuples 228 | # (source start file, target name, title, author, 229 | # dir menu entry, description, category) 230 | texinfo_documents = [ 231 | ('index', 'Keystone', u'Keystone Documentation', 232 | u'Dan Crosta', 'Keystone', 'One line description of project.', 233 | 'Miscellaneous'), 234 | ] 235 | 236 | # Documents to append as an appendix to all manuals. 237 | #texinfo_appendices = [] 238 | 239 | # If false, no module index is generated. 240 | #texinfo_domain_indices = True 241 | 242 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 243 | #texinfo_show_urls = 'footnote' 244 | 245 | 246 | # Example configuration for intersphinx: refer to the Python standard library. 247 | intersphinx_mapping = { 248 | 'python': ('http://docs.python.org/', None), 249 | 'werkzeug': ('http://werkzeug.pocoo.org/docs/', None) 250 | } 251 | -------------------------------------------------------------------------------- /docs/deploying-keystone.rst: -------------------------------------------------------------------------------- 1 | Deploying Keystone 2 | ================== 3 | 4 | Keystone is a WSGI framework, and as such can run within any WSGI-compliant 5 | container, such as Apache with mod_wsgi, Nginx with uWSGI, Gunicorn, etc. 6 | The :class:`~keystone.main.Keystone` class implements a WSGI application in 7 | its :meth:`~keystone.main.Keystone.__call__` method. 8 | 9 | You can automatically generate a ``wsgi.py`` file for use in such scenarios 10 | using the ``keystone`` script:: 11 | 12 | $ keystone --configure wsgi 13 | $ cat wsgi.py 14 | from os.path import abspath, dirname 15 | here = abspath(dirname(__file__)) 16 | from keystone.main import Keystone 17 | application = Keystone(here) 18 | 19 | The ``wsgi.py`` file is intended as a working starting point, but may 20 | require customization to work within your deployment environment. 21 | 22 | 23 | Deploying Keystone to PaaS Providers 24 | ------------------------------------ 25 | 26 | The ``keystone`` script has options to automatically generate boilerplate 27 | for several Platform-as-a-Service ("PaaS") providers. As of Keystone 0.2, 28 | supported providers are `Heroku `_, `DotCloud 29 | `_, and `ep.io `_. In the 30 | future, additional providers may be supported. 31 | 32 | To create files necessary for deployment on a PaaS provider, invoke the 33 | ``keystone`` script with ``--configure`` and the name of one of the 34 | supported providers. For example, for Heroku:: 35 | 36 | $ keystone --configure heroku 37 | $ cat wsgi.py 38 | from keystone.main import Keystone 39 | application = Keystone("/app") 40 | $ cat requirements.txt 41 | keystone == 0.2.0 42 | gunicorn >= 0.13.4 43 | $ cat Procfile 44 | web: gunicorn wsgi:application -w 4 -b 0.0.0.0:$PORT 45 | 46 | The exact files created by ``--configure`` and their contents will vary 47 | from one PaaS provider to another. The generated files are meant as a 48 | starting point, and may require customization. 49 | 50 | The version of Keystone required in ``requirements.txt`` will be the version 51 | you are running when you invoke ``--configure``, and not necessarily the 52 | latest version available. 53 | 54 | .. note:: 55 | 56 | When deploying to Heroku, be sure to use the "Cedar" stack. 57 | -------------------------------------------------------------------------------- /docs/http-errors.rst: -------------------------------------------------------------------------------- 1 | ``http`` Module 2 | =============== 3 | 4 | These exceptions may be raised from within view code to send non-200 5 | responses back to the user agent. Within views, this module is available as 6 | the ``http`` :doc:`View Variable `. 7 | 8 | Redirection 3xx 9 | --------------- 10 | 11 | .. autoclass:: keystone.http.MovedPermanently 12 | .. autoclass:: keystone.http.Found 13 | .. autoclass:: keystone.http.SeeOther 14 | .. autoclass:: keystone.http.NotModified 15 | .. autoclass:: keystone.http.UseProxy 16 | .. autoclass:: keystone.http.TemporaryRedirect 17 | 18 | Client Error 4xx 19 | ---------------- 20 | 21 | .. autoclass:: keystone.http.BadRequest 22 | .. autoclass:: keystone.http.Unauthorized 23 | .. autoclass:: keystone.http.Forbidden 24 | .. autoclass:: keystone.http.NotFound 25 | .. autoclass:: keystone.http.MethodNotAllowed 26 | .. autoclass:: keystone.http.NotAcceptable 27 | .. autoclass:: keystone.http.RequestTimeout 28 | .. autoclass:: keystone.http.Conflict 29 | .. autoclass:: keystone.http.Gone 30 | .. autoclass:: keystone.http.LengthRequired 31 | .. autoclass:: keystone.http.PreconditionFailed 32 | .. autoclass:: keystone.http.RequestEntityTooLarge 33 | .. autoclass:: keystone.http.RequestURITooLarge 34 | .. autoclass:: keystone.http.UnsupportedMediaType 35 | .. autoclass:: keystone.http.RequestedRangeNotSatisfiable 36 | .. autoclass:: keystone.http.ExpectationFailed 37 | .. autoclass:: keystone.http.ImATeapot 38 | 39 | Server Error 5xx 40 | ---------------- 41 | 42 | .. autoclass:: keystone.http.InternalServerError 43 | .. autoclass:: keystone.http.NotImplemented 44 | .. autoclass:: keystone.http.BadGateway 45 | .. autoclass:: keystone.http.ServiceUnavailable 46 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Keystone |release| Documentation 2 | ================================ 3 | 4 | Keystone is... 5 | 6 | * An easy-to learn Python web framework 7 | * That puts templates first 8 | * That takes only minutes to learn 9 | * That encourages best practices 10 | 11 | 12 | Documentation Sections 13 | ---------------------- 14 | 15 | :doc:`tutorial` 16 | Learn HTML and Python web development in 30 minutes. For those new to 17 | Python and web programming. 18 | 19 | :doc:`quickstart` 20 | If you're comfortable with Python and web programming, go here to learn 21 | what's different about Keystone. 22 | 23 | :doc:`view-variables` 24 | Keystone makes most things your Python code will need automatically 25 | available to view code. 26 | 27 | :doc:`advanced` 28 | Advanced topics 29 | 30 | :doc:`deploying-keystone` 31 | How to deploy Keystone 32 | 33 | .. toctree:: 34 | :hidden: 35 | 36 | tutorial 37 | quickstart 38 | view-variables 39 | advanced 40 | deploying-keystone 41 | -------------------------------------------------------------------------------- /docs/keystonelexer.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pygments.lexer import Lexer 3 | from pygments.lexers.templates import HtmlDjangoLexer 4 | from pygments.lexers.agile import PythonLexer 5 | from pygments.token import Text 6 | 7 | class KeystoneLexer(Lexer): 8 | name = 'keystone' 9 | aliases = [] 10 | filenames = ['*.ks'] 11 | 12 | def __init__(self, **options): 13 | self.options = options.copy() 14 | super(KeystoneLexer, self).__init__(**options) 15 | 16 | def get_tokens_unprocessed(self, text): 17 | offset = 0 18 | if re.search(r'^----\s*$', text, re.MULTILINE): 19 | py, _, text = text.partition('----') 20 | 21 | lexer = PythonLexer(**self.options) 22 | for i, token, value in lexer.get_tokens_unprocessed(py): 23 | yield i, token, value 24 | 25 | offset = i + 1 26 | yield offset, Text, u'----' 27 | offset += 1 28 | 29 | lexer = HtmlDjangoLexer(**self.options) 30 | for i, token, value in lexer.get_tokens_unprocessed(text): 31 | yield offset + i, token, value 32 | 33 | 34 | def setup(sphinx): 35 | lexer = KeystoneLexer() 36 | for alias in [KeystoneLexer.name] + KeystoneLexer.aliases: 37 | sphinx.add_lexer(alias, lexer) 38 | 39 | -------------------------------------------------------------------------------- /docs/nothing.txt: -------------------------------------------------------------------------------- 1 | Advanced Topics 2 | =============== 3 | 4 | Keystone Main Idea 5 | ------------------ 6 | 7 | In Keystone, rather than separating view code from template code, both are 8 | stored and edited together in ``.ks`` files. The Python section comes first 9 | (since the view code executes first), followed by four hyphens as a 10 | separator (``----``), followed by `Jinja `_ 11 | template code. Here's a relatively complete example: 12 | 13 | .. code-block:: keystone 14 | 15 | import db 16 | 17 | from wtforms import * 18 | from wtforms.validators import * 19 | 20 | class SignupForm(Form): 21 | username = TextField(validators=[Required()]) 22 | email = TextField(validators=[Email()]) 23 | password = PasswordField(validators=[Length(min=6)]) 24 | confirm_password = PasswordField(validators=[EqualTo('password')]) 25 | 26 | def validate_username(form, field): 27 | if not db.username_available(form.username.data): 28 | raise ValidationError('Username taken') 29 | 30 | if request.method == 'POST': 31 | form = SignupForm(request.form) 32 | if form.validate(): 33 | db.create_user( 34 | username=form.username.data, 35 | email=form.email.data, 36 | password=form.password.data, 37 | ) 38 | else: 39 | form = SignupForm() 40 | ---- 41 | {% macro field_row(field) %} 42 | 43 | {{field.label}} 44 | {{field}} 45 | {% if field.errors %} 46 |
    47 | {% for err in field.errors %} 48 |
  • {{err}}
  • 49 | {% endfor %} 50 |
51 | {% endif %} 52 | 53 | {% endmacro %} 54 | 55 | 56 | 57 | My Great Site 58 | 59 | 60 | 61 |
62 |

Sign Up!

63 |
64 | 65 | {% for field in form %} 66 | {{field_row(field)}} 67 | {% endfor %} 68 | 69 | 70 | 71 | 72 |
 
73 |
74 |
75 | 76 | 77 | 78 | Any static files found within the Keystone application directory are served 79 | verbatim. Files whose detected content type matches ``text/*`` (including 80 | the rendered output of ``.ks`` templates) are served with charset utf-8 and 81 | an appropriate MIME type in the HTTP Content-Type header. 82 | 83 | .. note:: 84 | Files with extensions ``.ks``, ``.py``, ``.pyc``, and ``.pyo`` are never 85 | served as static files by Keystone. 86 | 87 | 88 | Keystone and Python 89 | ------------------- 90 | 91 | Keystone compiles and caches Python bytecode for the view section and 92 | executes it for each request. The application directory is added to the 93 | Python import path when Keystone starts up, so any Python modules or 94 | packages defined within the application are available for import. 95 | 96 | All Python code in the ``.ks`` file is executed on each request, including 97 | things which would normally be executed only once by the Python virtual 98 | machine, like function and class definitions. Therefore, it is advisable to 99 | avoid defining classes or functions within ``.ks`` files. Instead, most 100 | Python code (particularly anything that may be shared between different 101 | views, such as class and function definitions, database connections, etc) 102 | should be implemented in ordinary Python modules or packages. 103 | 104 | When a change in any ``.ks`` file's mtime is detected, cached bytecode is 105 | discarded and the file is re-parsed. Python modules imported by view code 106 | are not re-imported unless the Python process running Keystone is restarted. 107 | 108 | When Keystone is started, if ``startup.py`` exists within the application 109 | directory, it is imported. This is where application-level initialization 110 | code should go (for instance, setting up database connection pools). Like 111 | any Python module imported from within Keystone views, the ``startup`` 112 | module is imported (and thus executed) only once. 113 | 114 | 115 | Keystone and Jinja 116 | ------------------ 117 | 118 | All in-scope Python variables, including :doc:`/view-variables` set by 119 | Keystone itself, are passed into the Jinja2 context during rendering. 120 | However, it is not advised to maipulate the :doc:`/view-variables` from 121 | within template code, as this will lead to difficult-to-maintain code. 122 | 123 | Keystone implements a special Jinja2 template loader to load templates from 124 | ``.ks`` files. In addition, it can load plain HTML files (with extension 125 | ``.html``) found within the application directory (e.g. for template 126 | inheritance). 127 | 128 | If a view's template extends the template of another view, the parent view's 129 | Python code `is not` executed during the request; thus if you require 130 | certain template variables in a parent template, the child view must set 131 | them itself. 132 | 133 | `Template filters `_ can be 134 | registered with the Jinja Environment using the ``@template_filter`` 135 | decorator (imported from ``keystone.render``). The name of the function is 136 | used as the filter name within the Jinja environment. 137 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | Keystone Quickstart 2 | =================== 3 | 4 | This quickstart guide is designed for those familiar with Python and web 5 | programming concepts. If you're new to Python or web programming, you may be 6 | interested in :doc:`tutorial`, which introduces these concepts more gently. 7 | 8 | Install Keystone 9 | ---------------- 10 | 11 | Keystone packages are available at `PyPi 12 | `_ and source is available at `GitHub 13 | `_. Keystone can be installed with 14 | `Pip `_, and works well 15 | with `virtualenv `_. 16 | 17 | 18 | Application Directory 19 | --------------------- 20 | 21 | Keystone uses the hierarchy of the filesystem, specifically of your 22 | `application directory`, for view routing. Throughout this Quickstart, we 23 | will use ``$APP`` to refer to the root of your application directory. 24 | 25 | 26 | Running Keystone 27 | ---------------- 28 | 29 | The ``keystone`` script runs a local web server suitable for development of 30 | Keystone applications. By default it listens on port 5000 and assumes 31 | ``$APP`` to be the current working directory. Run ``keystone --help`` for 32 | details on usage. 33 | 34 | 35 | Defining Views 36 | -------------- 37 | 38 | Views in Keystone are files with extension ``.ks``, and contain both Python 39 | code and `Jinja `_ template code, separated by four 40 | hyphens (``----``). If a view does not have a separator, it is assumed to 41 | contain only template code and no Python. 42 | 43 | The URL of a Keystone view is the path, relative to ``$APP``, of the file in 44 | question, without the ``.ks`` extension. Views named ``index.ks`` are 45 | treated specially, in that they are accessible both by their full path 46 | (ending in `/index`) and at the bare directory path (ending in `/`). 47 | 48 | Here's an example view: 49 | 50 | .. code-block:: keystone 51 | 52 | import random 53 | greeting = random.choice(['Hello', 'Goodbye']) 54 | name = random.choice(['World', 'Moon']) 55 | ---- 56 | 57 | 58 | 59 | {{greeting}} from Keystone 60 | 61 | 62 |

{{greeting}}, {{name}}

63 | 64 | 65 | 66 | If this were saved as :file:`{$APP}/index.ks`, then this view would be 67 | available at both `http://localhost:5000/` and 68 | `http://localhost:5000/index`. 69 | 70 | 71 | Static Files 72 | ------------ 73 | 74 | Most files other than ``.ks`` files are served as static files by Keystone. 75 | The HTTP `Content-Type` header is set according to the MIME type guessed by 76 | :func:`mimetypes.guess_type`, and if the MIME type begins with "``text/``", 77 | it is served with charset UTF-8. 78 | 79 | .. note:: 80 | 81 | Files with extension ``.py``, ``.pyc``, ``.pyo``, ``.ks``, and any file 82 | whose name begins with a dot or underscore are never served as static 83 | files by Keystone. Requests for such files will receive a 404 response 84 | even if such a file exists. 85 | 86 | Keystone makes all static responses cacheable by setting the `Last-Modified` 87 | header to the file's :func:`mtime `, `ETag` to the MD5 hex digest 88 | of the `mtime`, and a `Expires` to 1 day from the current date and time. To 89 | change the `Expires` value, use the ``static_expires`` keyword argument to 90 | the :class:`~keystone.main.Keystone` class or the ``--static-expires`` 91 | command line option to the `keystone` script. It is not yet possible to 92 | customize the `Expires` value on a per-file, or per-mimetype basis. 93 | 94 | 95 | 96 | HTTP Request and Response 97 | ------------------------- 98 | 99 | Keystone makes a ``request`` object available to your view code, with 100 | several useful attributes and methods. Full documentation on the ``Request`` 101 | object is available in :doc:`view-variables`. 102 | 103 | The response object is not actually available to Keystone views, but several 104 | objects and functions to control aspects of the response are. These, too, 105 | are fully documented in :doc:`view-variables`. 106 | 107 | 108 | Parameterized Paths 109 | ------------------- 110 | 111 | Any directory or Keystone view file whose name begins with ``%`` defines a 112 | parameterized path, and acts like a wildcard. Any requests to URLs which 113 | match a parameterized path have :doc:`view-variables` defined for the 114 | matched sections of the path. Such variables are always strings. 115 | 116 | For example, suppose you have the following application directory:: 117 | 118 | $APP/ 119 | + index.ks 120 | + account/ 121 | + %username.ks 122 | + %username/ 123 | + profile.ks 124 | 125 | Then requests to the following paths would map as follows: 126 | 127 | `/` or `/index` 128 | :file:`{$APP}/index.ks` 129 | 130 | `/account/` or `/account/index` 131 | :file:`{$APP}/account/index.ks` 132 | 133 | `/account/alice` or `/account/bob` 134 | :file:`{$APP}/account/%username.ks` with variable ``username`` set to 135 | "alice" or "bob", respectively 136 | 137 | `/account/alice/profile` or `/account/bob/profile` 138 | :file:`{$APP}/account/%username/profile.ks` with variable ``username`` set to 139 | "alice" or "bob", respectively 140 | 141 | 142 | Application Initialization 143 | -------------------------- 144 | 145 | If a file :file:`{$APP}/startup.py` exists, it will be imported as a normal 146 | Python module when Keystone starts up. Use this hook to define shared 147 | resources (like database connections), perform application initialization, 148 | or tweak Keystone's behavior (like registering custom template filters). 149 | -------------------------------------------------------------------------------- /docs/tutorial.rst: -------------------------------------------------------------------------------- 1 | Learn Web Programming with Keystone 2 | =================================== 3 | 4 | Keystone is, by design, incredibly easy to work with. This tutorial will 5 | walk you through setting up Keystone for local development, teach you the 6 | basics of Python web development and, by example, of HTML, and end with you 7 | deploying your first application to a free web hosting service. 8 | 9 | This tutorial is designed for those unfamiliar with web programming 10 | concepts. If you've worked with web programming frameworks before, you may 11 | be interested in :doc:`quickstart`, which only covers what's different about 12 | Keystone. 13 | 14 | Keystone Tutorial 15 | ----------------- 16 | 17 | .. toctree:: 18 | :maxdepth: 1 19 | 20 | tutorial/installing-keystone 21 | tutorial/the-first-page 22 | tutorial/make-it-dynamic 23 | tutorial/reacting-to-the-web 24 | tutorial/whats-next 25 | 26 | -------------------------------------------------------------------------------- /docs/tutorial/installing-keystone.rst: -------------------------------------------------------------------------------- 1 | Installing Keystone 2 | =================== 3 | 4 | Keystone is a `Python `_ application, so you'll need 5 | to have Python installed on your computer. The exact steps to do so will 6 | depend on your operating system, and are documented on `the Python downloads 7 | page `_. Keystone requires Python 2.5 or 8 | greater (and suggests you use the latest 2.x release available). Keystone 9 | does not yet support Python 3. 10 | 11 | Once you have Python installed, open the Terminal (Windows users: use 12 | "Command Prompt"), and install Keystone: 13 | 14 | .. code-block:: bash 15 | 16 | $ pip install Keystone 17 | 18 | If you get a permission error, you may need to re-run the install command 19 | using ``sudo``, which will prompt you for your password. 20 | 21 | Even though you haven't yet begun to build your web site, you can actually 22 | run Keystone using the ``keystone`` command, which will start a web server at 23 | `http://localhost:5000/ `_. 24 | 25 | .. code-block:: bash 26 | 27 | $ keystone 28 | * Running on http://0.0.0.0:5000/ 29 | * Restarting with reloader 30 | 31 | Those two lines indicate that the web server has started and is waiting for 32 | the first request. Each time your browser requests a page, the Keystone web 33 | server will print a line that looks something like this: 34 | 35 | .. code-block:: bash 36 | 37 | 127.0.0.1 - - [22/Nov/2011 16:55:10] "GET / HTTP/1.1" 404 - 38 | 39 | This shows, from left to right, the IP address of the computer that made the 40 | request ("127.0.0.1" is a special value that indicates the computer you're 41 | currently on), the date and time of the request, the request line (which 42 | page the browser is asking for), and the status code of the response. 43 | Normally we want to see a "200" status code, which indicates the request was 44 | successfully served; in this case, "404" indicates that no page matching "/" 45 | was found, which should not be surprising as we have not yet built any pages 46 | for the web site. 47 | 48 | But I digress; the fact that we can see these messages in the Terminal, and 49 | that our browser gets a "Not found" message means that Keystone is working, 50 | and we're ready to begin building websites. 51 | 52 | -------------------------------------------------------------------------------- /docs/tutorial/keystone-1-index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcrosta/keystone/5c76da08023ab058998653ce952fe45a9bc0924a/docs/tutorial/keystone-1-index.png -------------------------------------------------------------------------------- /docs/tutorial/keystone-2-index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcrosta/keystone/5c76da08023ab058998653ce952fe45a9bc0924a/docs/tutorial/keystone-2-index.png -------------------------------------------------------------------------------- /docs/tutorial/keystone-2-pageone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcrosta/keystone/5c76da08023ab058998653ce952fe45a9bc0924a/docs/tutorial/keystone-2-pageone.png -------------------------------------------------------------------------------- /docs/tutorial/keystone-3-counting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcrosta/keystone/5c76da08023ab058998653ce952fe45a9bc0924a/docs/tutorial/keystone-3-counting.png -------------------------------------------------------------------------------- /docs/tutorial/keystone-bob.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcrosta/keystone/5c76da08023ab058998653ce952fe45a9bc0924a/docs/tutorial/keystone-bob.png -------------------------------------------------------------------------------- /docs/tutorial/keystone-count-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcrosta/keystone/5c76da08023ab058998653ce952fe45a9bc0924a/docs/tutorial/keystone-count-form.png -------------------------------------------------------------------------------- /docs/tutorial/make-it-dynamic.rst: -------------------------------------------------------------------------------- 1 | Make It Dynamic 2 | =============== 3 | 4 | So far, we haven't done anything that wouldn't be possible with pure HTML 5 | files, but Keystone offers a whole lot more flexibility than that. One thing 6 | you may have noticed above is that there is a lot of repetition between the 7 | two HTML files. Imagine a site with hundreds of pages. Would you want to 8 | type all of that boilerplate HTML each time? 9 | 10 | Fortunately, Keystone's templating language, `Jinja 11 | `_, offers a better solution, through "template 12 | inheritance". We will create a template which holds the structure of our 13 | page, and define replaceable "blocks" within the template where individual 14 | pages can insert their content. 15 | 16 | Create ``_base.html`` with the following content: 17 | 18 | .. code-block:: keystone 19 | 20 | 21 | 22 | 23 | {% block title %}{% endblock %} 24 | 25 | 26 | 27 | {% block body %}{% endblock %} 28 | 29 | 30 | 31 | Now we can simplify our individual pages considerably. Here's ``index.ks``: 32 | 33 | .. code-block:: keystone 34 | 35 | {% extends "_base.html" %} 36 | 37 | {% block title %}My First Keystone Website{% endblock %} 38 | 39 | {% block body %} 40 |

Hello, World

41 |

New! Check out Page One 42 | {% endblock %} 43 | 44 | and ``pageone.ks``: 45 | 46 | .. code-block:: keystone 47 | 48 | {% extends "_base.html" %} 49 | 50 | {% block title %}Page One{% endblock %} 51 | 52 | {% block body %} 53 |

This is Page One

54 |

Would you like to return home

55 | {% endblock %} 56 | 57 | OK, in this very simple exmaple, we haven't actually saved very much code, 58 | if any, but you can imagine that if ``_base.html`` were very long, and 59 | defined common elements present on all pages (like navigation, header and 60 | footer messages, included javascript and CSS, etc), that this would help. 61 | Additionally, if we want to radically change the design of our site, we now 62 | have only one file to do it in, rather than needing to replicate our changes 63 | to each page. 64 | 65 | More importantly, you can see now that this is more powerful than a simple 66 | static web site. The HTML that is returned to the browser by these new 67 | versions of the pages is effectively identical to what we had before; but it 68 | is dynamically assembled by Keystone, in order to help save you development 69 | time. 70 | 71 | Jinja also allows parameterizing particular bits of text within the page, 72 | through variable substitution. Replace ``index.ks`` with the following: 73 | 74 | .. code-block:: keystone 75 | 76 | import random 77 | name = random.choice(['World', 'Friend']) 78 | ---- 79 | {% extends "_base.html" %} 80 | 81 | {% block title %}My First Keystone Website{% endblock %} 82 | 83 | {% block body %} 84 |

Hello, {{name}}

85 |

New! Check out Page One

86 | {% endblock %} 87 | 88 | Open `http://localhost:5000/ `_ in your browser, and 89 | refresh a few times. You should see the greeting vary between "Hello, World" 90 | and "Hello, Friend" depending on which of the two was chosen at random. 91 | 92 | So what's happening here? We've added a section of Python code to our page 93 | (everything before the "``----``"), which is executed by Keystone before the 94 | page is rendered. This code assigns either "World" or "Friend" at random to 95 | the ``name`` variable. When the template is rendered, the ``{{name}}`` token 96 | is replaced by the value assigned to the ``name`` variable in the Python 97 | section. 98 | 99 | You can do much more than randomly choose a word in the Python section of a 100 | Keystone page -- anything which is possible in Python (which is, 101 | essentially, anything) can be done in a a Keystone page, and all the 102 | variables defined in the Python section become available for use within the 103 | template section. Here's another example: 104 | 105 | .. code-block:: keystone 106 | 107 | import random 108 | count_to = random.randint(5, 15) 109 | numbers = range(1, count_to + 1) 110 | ---- 111 | {% extends "_base.html" %} 112 | 113 | {% block title %}My First Keystone Website{% endblock %} 114 | 115 | {% block body %} 116 |

I can count to {{count_to}}

117 |

118 | {% for number in numbers %} 119 | {{number}} 120 | {% if not loop.last %} ... {% endif %} 121 | {% endfor %}! 122 |

123 | {% endblock %} 124 | 125 | .. image:: keystone-3-counting.png 126 | 127 | Of course, since we've chosen a number to count to at random, you'll see a 128 | different count each time you refresh the page. 129 | 130 | -------------------------------------------------------------------------------- /docs/tutorial/reacting-to-the-web.rst: -------------------------------------------------------------------------------- 1 | Reacting to the Web 2 | =================== 3 | 4 | Substituting random variables into your templates isn't the most exciting 5 | (or useful) web programming technique, though it's charming in its own way. 6 | In most cases, we want web sites to react to input from the site's visitors 7 | in some meaningful way. 8 | 9 | Building on the last example, we can create a page which counts up to a 10 | user-supplied number. (Python is very good at counting, as it turns out). To 11 | do so, we'll need some way to get input from a user, and use that to 12 | determine the behavior of the web page. Enter the query string. 13 | 14 | The query string is a set of parameters that can be passed to a web page by 15 | way of the URL by adding a ``?`` to the end of the URL, and a series of 16 | parameter names and values separated by ``=``. Multiple parameter name/value 17 | pairs are separated by an ``&``, so a complete URL with query string looks 18 | like ``http://www.exmaple.com/?first_name=Dan&last_name=Crosta``. 19 | 20 | In Keystone, you can access query string parameters with the 21 | ``request.values`` object (this object, and many other :doc:`/view-variables` are 22 | available by default in the Python portion of your page, as if by magic): 23 | 24 | .. code-block:: keystone 25 | 26 | if 'count_to' in request.values: 27 | count_to = request.values.get('count_to') 28 | count_to = int(count_to) 29 | else: 30 | count_to = 10 31 | numbers = range(1, count_to + 1) 32 | ---- 33 | {% extends "_base.html" %} 34 | 35 | {% block title %}My First Keystone Website{% endblock %} 36 | 37 | {% block body %} 38 |

I can count to {{count_to}}

39 |

40 | {% for number in numbers %} 41 | {{number}} 42 | {% if not loop.last %} ... {% endif %} 43 | {% endfor %}! 44 |

45 | {% endblock %} 46 | 47 | First we check if the query string parameter "count_to" exists for this 48 | request (it might not, if the viewer didn't click a link containing the 49 | query string, or if they did not type it by hand), and if it does, we set 50 | the variable ``count_to`` to have that value. After that, we convert the 51 | value to an integer (since integers are easy to count with), and move on 52 | with the rest of the page as before. 53 | 54 | However, if there is no "count_to" query string parameter, the test in the 55 | line ``if 'count_to' in request.values:`` will fail, and the program will 56 | jump to the ``else:`` block, and set a default value of 10 to count to. Since 57 | "``10``" is the syntax for expressing an integer in Python, we don't need to 58 | convert it to an integer (as it already is one, and the conversion would do 59 | nothing). 60 | 61 | Try experimenting with a few different values for the ``count_to`` query 62 | string parameter. Try counting to 100, and 1000. Try counting to one million 63 | (this might take a little while -- how long would it take you?) 64 | 65 | Now try counting to `Bob `_. You should 66 | see something like this: 67 | 68 | .. image:: keystone-bob.png 69 | 70 | Congratulations, you've made your first `bug 71 | `_! It turns out Python doesn't 72 | know how to count to Bob (and neither do I, for that matter), but if you 73 | learn to read this output, it will point you to your error, which helps 74 | tremendously in the web development process. Just below the big "ValueError" 75 | heading is the exact error message: "Bob" is not a valid integer (well, we 76 | knew that). 77 | 78 | When programming for the web, especially when dealing with user input, it's 79 | best to "program defensively," that is, to make sure that you don't trust 80 | user input unless you've checked it first. In our case, we can use the 81 | ``isdigit`` method of strings (which returns ``True`` if the string consists 82 | only of characters that represent digits, and ``False`` otherwise) to see if 83 | it can be a valid number or not: 84 | 85 | .. code-block:: keystone 86 | 87 | if 'count_to' in request.values: 88 | count_to = request.values.get('count_to') 89 | if count_to.isdigit(): 90 | count_to = int(count_to) 91 | else: 92 | count_to = 10 93 | else: 94 | count_to = 10 95 | numbers = range(1, count_to + 1) 96 | ---- 97 | {% extends "_base.html" %} 98 | 99 | {% block title %}My First Keystone Website{% endblock %} 100 | 101 | {% block body %} 102 |

I can count to {{count_to}}

103 |

104 | {% for number in numbers %} 105 | {{number}} 106 | {% if not loop.last %} ... {% endif %} 107 | {% endfor %}! 108 |

109 | {% endblock %} 110 | 111 | Now, no matter what value a user supplies for the ``count_to`` query string 112 | parameter, we know that our code will only try to count to it if it's an 113 | integer (and in all other cases it will simply count to 10). 114 | 115 | Manually typing in query string parameters does get rather tiresome, though, 116 | and it might be too much to ask of your visitors (they might simply decide 117 | not to use your site any more). Instead, we can present an HTML form to our 118 | users, and ask them to fill it out, resulting in a far better user 119 | experience. Continuing to build out ``index.ks``, let's add a form: 120 | 121 | .. code-block:: keystone 122 | 123 | if 'count_to' in request.values: 124 | count_to = request.values.get('count_to') 125 | if count_to.isdigit(): 126 | count_to = int(count_to) 127 | else: 128 | count_to = 10 129 | else: 130 | count_to = 10 131 | numbers = range(1, count_to + 1) 132 | ---- 133 | {% extends "_base.html" %} 134 | 135 | {% block title %}My First Keystone Website{% endblock %} 136 | 137 | {% block body %} 138 |

I can count to {{count_to}}

139 |
140 | Count to: 141 | 142 | 143 |
144 |

145 | {% for number in numbers %} 146 | {{number}} 147 | {% if not loop.last %} ... {% endif %} 148 | {% endfor %}! 149 |

150 | {% endblock %} 151 | 152 | (Recall that the ``{{count_to}}`` syntax means "put the value of the 153 | ``count_to`` variable here in the HTML".) 154 | 155 | Fill out the form, click the "Count It" button, and see what happens: 156 | 157 | .. image:: keystone-count-form.png 158 | 159 | By default, form submissions go to the same page as you are currently on, 160 | and store the input field values in the query string. This works well for 161 | small forms, or forms without sensitive data (since query strings are part 162 | of the URL and are logged by most web servers), but in many cases you will 163 | want to use a "POST" request, which sends the form data along side the URL, 164 | but not actually in it. You can do this by replacing "``
``" with 165 | "````" in the template. 166 | 167 | .. code-block:: keystone 168 | 169 | if 'count_to' in request.values: 170 | count_to = request.values.get('count_to') 171 | if count_to.isdigit(): 172 | count_to = int(count_to) 173 | else: 174 | count_to = 10 175 | else: 176 | count_to = 10 177 | numbers = range(1, count_to + 1) 178 | ---- 179 | {% extends "_base.html" %} 180 | 181 | {% block title %}My First Keystone Website{% endblock %} 182 | 183 | {% block body %} 184 |

I can count to {{count_to}}

185 | 186 | Count to: 187 | 188 | 189 |
190 |

191 | {% for number in numbers %} 192 | {{number}} 193 | {% if not loop.last %} ... {% endif %} 194 | {% endfor %}! 195 |

196 | {% endblock %} 197 | 198 | You can now load `http://localhost:5000/ `_ (that 199 | is, without any query string parameters), fill out the form, and submit. 200 | 201 | One annoyance you may notice is that if you attempt to refresh the browser 202 | after submitting a POST request, you will get a warning message asking you 203 | whether it's OK to submit data again. The reasons behind this message are 204 | partly historical, partly practical, and entirely likely to start flame wars 205 | between web developers, and are best not addressed here. Regardless of your 206 | stance on the philosophical issues behind this debate, the agreed-upon best 207 | behavior is to receive a POST request, do some appropriate processing, and 208 | then redirect the user's browser to a new page (this new page will be 209 | accessed with a normal GET request, which does not trigger the browser 210 | warning). 211 | 212 | In order to use this method in our counting example, we could redirect from 213 | the POST to a page using GET and the query string, but the point of using a 214 | POST request in the first place was to avoid the query string. Instead, 215 | we'll create a page whose name itself is a parameter. To do so, create 216 | ``~/Documents/keystone/count/%count_to.ks``. The "%" at the start of the 217 | filename indicates to Keystone that this page should match any URL request 218 | which gets to it -- in other words, ``http://localhost:5000/count/25``, 219 | ``http://localhost:5000/count/100``, and even 220 | ``http://localhost:5000/count/Bob`` will all match. Inside ``%count_to.ks``, 221 | the variable ``count_to`` will contain the (string) URL segment that 222 | matched (following the previous examples, "25", "100", and "Bob", 223 | respectively). 224 | 225 | First, update ``index.ks`` to the following: 226 | 227 | .. code-block:: keystone 228 | 229 | if 'count_to' in request.values: 230 | count_to = request.values.get('count_to') 231 | raise http.SeeOther('/count/' + count_to) 232 | ---- 233 | {% extends "_base.html" %} 234 | 235 | {% block title %}My First Keystone Website{% endblock %} 236 | 237 | {% block body %} 238 |

How high can you count?

239 |
240 | Count to: 241 | 242 |
243 | 244 |
245 | {% endblock %} 246 | 247 | This is similar to what ``index.ks`` contained before, but rather than 248 | doing any counting, it simply generates the URL (e.g. "``/count/100``"), and 249 | sends a "SeeOther" (i.e. a redirect) message back to the browser. The 250 | ``raise`` statement here breaks the usual flow of processing the Python code 251 | then rendering the template, so that Keystone knows to send a redirect 252 | message to the user's browser. 253 | 254 | Next, make ``%count_to.ks`` contain the following: 255 | 256 | .. code-block:: keystone 257 | 258 | if count_to.isdigit(): 259 | count_to = int(count_to) 260 | else: 261 | count_to = 10 262 | numbers = range(1, count_to + 1) 263 | ---- 264 | {% extends "_base.html" %} 265 | 266 | {% block title %}My First Keystone Website{% endblock %} 267 | 268 | {% block body %} 269 |

I can count to {{count_to}}

270 |

271 | {% for number in numbers %} 272 | {{number}} 273 | {% if not loop.last %} ... {% endif %} 274 | {% endfor %}! 275 |

276 |

Count again

277 | {% endblock %} 278 | 279 | Rembmer that the ``count_to`` variable is set based on the name of the 280 | file; if you had named the file ``%max_number.ks``, then the variable 281 | ``count_to`` would have to be updated to be ``max_number`` in the Python 282 | section of this file. As before, we have to convert it from a string to an 283 | integer, and have a default value on hand in case it cannot be converted. 284 | 285 | -------------------------------------------------------------------------------- /docs/tutorial/the-first-page.rst: -------------------------------------------------------------------------------- 1 | The First Page 2 | ============== 3 | 4 | Keystone uses files on your computer's drive to store the contents of the 5 | web site, so we'll need a place to work. I'll use the ``keystone`` directory 6 | of my ``Documents`` directory, written as ``~/Documents/keystone``. 7 | 8 | Inside that directory, I'll create my first web page in a file named 9 | ``index.ks``: 10 | 11 | .. code-block:: keystone 12 | 13 | 14 | 15 | 16 | My First Keystone Website 17 | 18 | 19 |

Hello, World

20 |

How are you doing?

21 | 22 | 23 | 24 | If you already know HTML, you'll recnogize this as a very basic HTML page. 25 | If you don't, this might be a good time to `learn HTML5 26 | `_. You'll probably be able to follow this 27 | guide without being an expert, but some familiarity will help. 28 | 29 | If you still have ``keystone`` running from before, you can kill it now by 30 | hitting `Ctrl-C` (hold the "control" key and type "c") in the Terminal. 31 | Navigate to the ``~/Documents/keystone`` and start ``keystone`` there: 32 | 33 | .. code-block:: bash 34 | 35 | $ cd ~/Documents/keystone/ 36 | ~/Documents/keystone/ $ keystone 37 | * Running on http://0.0.0.0:5000/ 38 | * Restarting with reloader 39 | 40 | Now go to `http://localhost:5000/ `_ in your 41 | browser, and you should see your page: 42 | 43 | .. image:: keystone-1-index.png 44 | 45 | It's not very exciting so far, but we'll get there. First, let's add some 46 | links and additional pages. Edit ``index.ks`` so that it looks like this: 47 | 48 | .. code-block:: keystone 49 | 50 | 51 | 52 | 53 | My First Keystone Website 54 | 55 | 56 | 57 |

Hello, World

58 |

New! Check out Page One

59 | 60 | 61 | 62 | which becomes: 63 | 64 | .. image:: keystone-2-index.png 65 | 66 | And then create ``pageone.ks``: 67 | 68 | .. code-block:: keystone 69 | 70 | 71 | 72 | 73 | Page One 74 | 75 | 76 | 77 |

This is Page One

78 |

Would you like to return home

79 | 80 | 81 | 82 | which becomes: 83 | 84 | .. image:: keystone-2-pageone.png 85 | 86 | and ``static/style.css`` (you will need to create the 87 | ``~/Documents/keystone/static/`` folder for this file): 88 | 89 | .. code-block:: css 90 | 91 | * { 92 | font-family:sans-serif; 93 | } 94 | 95 | a, a:visited { 96 | color:red; 97 | } 98 | 99 | As you can see, there's a simple mapping between filenames and the URLs that 100 | your web pages are accessed at: to convert from a filename to a URL, drop 101 | the ".ks" file extension, with the special-case that "index.ks" is 102 | accessible at both its normal URL (``/index``) and the directory root for 103 | the directory it appears in (``/``). To convert from a URL to a file, take 104 | the URL path (everything after the ``http://server.com/``), and add the 105 | ".ks" file extension. Web requests for static files (like 106 | ``/static/style.css``) are served directly if the file exists. 107 | 108 | You may also have noticed (and it bears pointing out anyway) that when you 109 | change a file, Keystone notices this and renders the new version 110 | immediately. Thus, under normal circumstances, there's no need to restart 111 | ``keystone`` during development of your web site. 112 | 113 | -------------------------------------------------------------------------------- /docs/tutorial/whats-next.rst: -------------------------------------------------------------------------------- 1 | Congratulations! 2 | ================ 3 | 4 | That's it! You're now a web programmer! This may not be the most impressive 5 | web application ever designed, but, hey, everyone's got to start somewhere. 6 | The important thing is that you now have a grasp of some of the fundamental 7 | concepts and tools with which to build more complex web applications that do 8 | more interesting things. 9 | 10 | 11 | What's Next? 12 | ------------ 13 | 14 | Keysotne uses Python for server-side code, so if you are new to Python, you 15 | should `learn Python `_. If you're already 16 | familiar with Python, you can move on to :doc:`/advanced`. 17 | 18 | -------------------------------------------------------------------------------- /docs/view-variables.rst: -------------------------------------------------------------------------------- 1 | View Variables 2 | ============== 3 | 4 | Keystone makes several objects and functions available within Views as 5 | global variables --- that is, they need not be declared, they are simply 6 | available to your Python and template code. 7 | 8 | ``request`` 9 | ----------- 10 | 11 | .. py:class:: Request 12 | 13 | A Werkzeug :class:`~werkzeug.wrappers.Request` instance, available in 14 | views as ``request``. The `Werkzeug documentation 15 | `_ 16 | contains a more comlete list of available attributes. 17 | 18 | .. py:attribute:: method 19 | 20 | HTTP method name 21 | 22 | .. py:attribute:: cookies 23 | 24 | HTTP cookies, as an 25 | :class:`~werkzeug.datastructures.ImmutableTypeConversionDict` 26 | 27 | .. py:attribute:: args 28 | 29 | HTTP GET parameters (query string), as an 30 | :class:`~werkzeug.datastructures.ImmutableMultiDict` 31 | 32 | .. py:attribute:: form 33 | 34 | HTTP POST parameters, as an 35 | :class:`~werkzeug.datastructures.ImmutableMultiDict` 36 | 37 | .. py:attribute:: values 38 | 39 | Union of ``args`` and ``form``. 40 | 41 | 42 | ``headers`` 43 | ----------- 44 | 45 | The actual :class:`~werkzeug.wrappers.Response` instance is not constructed 46 | until after a view's Python code executes, but aspects of it can be 47 | controlled through several :doc:`view-variables`: 48 | 49 | .. py:class:: Headers 50 | 51 | A Werkzeug :class:`~werkzeug.datastructures.Headers` instance, available 52 | in views as ``headers``. The `Werkzeug documentation 53 | `_ 54 | contains a more comlete list of available attributes and methods. 55 | 56 | .. py:method:: add(key, value, **kw) 57 | 58 | Add the ``value`` to the header named ``key``. Keyword arguments can 59 | be used to specify additional parameters for the header: 60 | 61 | .. code-block:: python 62 | 63 | headers.add('Content-Type', 'text/plain') 64 | headers.add('Content-Disposition', 'attachment', filename='blah.txt') 65 | 66 | .. py:method:: set(key, value, **kw) 67 | 68 | Similar to :meth:`add`, but overwrites any previously set values for 69 | headers which accept multiple values. 70 | 71 | .. py:method:: get(key, default=None, type=None) 72 | 73 | Get the value of the header named ``key``, or the default value if no 74 | such header is set. Optionally convert using ``type`` (a callable of 75 | one argument). 76 | 77 | .. py:method:: has_key(key) 78 | 79 | Return ``True`` if the header named ``key`` exists, ``False`` 80 | otherwise. 81 | 82 | 83 | ``return_response`` 84 | ------------------- 85 | 86 | .. py:function:: return_response(body) 87 | 88 | Bypass template rendering and immediately return the given `body`. `body` 89 | may be any iterable object or string. 90 | 91 | 92 | ``http`` 93 | -------- 94 | 95 | The ``http`` view variable is a module which contains subclasses of 96 | :class:`~werkzeug.exceptions.HTTPException` for returning non-200-status 97 | HTTP responses. Full documentation on the exceptions is available at 98 | :doc:`http-errors`. 99 | 100 | 101 | ``set_cookie`` 102 | -------------- 103 | 104 | .. py:method:: set_cookie(key, value='', max_age=None, expires=None, path='/', domain=None, seucre=None, httponly=None) 105 | 106 | Set a cookie in the HTTP response. Cookies set using :meth:`set_cookie` 107 | will not be available in :class:`headers ` until the subsequent 108 | request from the user. 109 | 110 | See :meth:`~werkzeug.wrappers.BaseResponse.set_cookie` for an explanation 111 | of the arguments. 112 | 113 | 114 | ``delete_cookie`` 115 | ----------------- 116 | 117 | .. py:method:: delete_cookie(key, path='/', domain=None) 118 | 119 | Delete a cookie in the HTTP response. Cookies deleted using 120 | :meth:`delete_cookie` will still appear in :class:`headers ` 121 | until the subsequent request from the user. 122 | 123 | See :meth:`~werkzeug.wrappers.BaseResponse.delete_cookie` for an 124 | explanation of the arguments. 125 | 126 | 127 | ``app_dir`` 128 | ----------- 129 | 130 | .. py:attribute:: app_dir 131 | 132 | The full, absolute path to the root of the Keystone application 133 | directory. 134 | 135 | -------------------------------------------------------------------------------- /keystone/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2011, Daniel Crosta 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # * Redistributions of source code must retain the above copyright notice, 8 | # this list of conditions and the following disclaimer. 9 | # 10 | # * Redistributions in binary form must reproduce the above copyright notice, 11 | # this list of conditions and the following disclaimer in the documentation 12 | # and/or other materials provided with the distribution. 13 | # 14 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 17 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 18 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 19 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 20 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 21 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 22 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 23 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 24 | # POSSIBILITY OF SUCH DAMAGE. 25 | 26 | __version_info__ = (0, 2, 1) 27 | __version__ = '.'.join(str(i) for i in __version_info__) 28 | 29 | 30 | -------------------------------------------------------------------------------- /keystone/http.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2011, Daniel Crosta 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # * Redistributions of source code must retain the above copyright notice, 8 | # this list of conditions and the following disclaimer. 9 | # 10 | # * Redistributions in binary form must reproduce the above copyright notice, 11 | # this list of conditions and the following disclaimer in the documentation 12 | # and/or other materials provided with the distribution. 13 | # 14 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 17 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 18 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 19 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 20 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 21 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 22 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 23 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 24 | # POSSIBILITY OF SUCH DAMAGE. 25 | 26 | 27 | __all__ = ( 28 | # from Werkzeug 29 | 'BadRequest', 'Unauthorized', 'Forbidden', 'NotFound', 'MethodNotAllowed', 30 | 'NotAcceptable', 'RequestTimeout', 'Conflict', 'Gone', 'LengthRequired', 31 | 'PreconditionFailed', 'RequestEntityTooLarge', 'RequestURITooLarge', 32 | 'UnsupportedMediaType', 'RequestedRangeNotSatisfiable', 'ExpectationFailed', 33 | 'ImATeapot', 'InternalServerError', 'NotImplemented', 'BadGateway', 34 | 'ServiceUnavailable', 35 | 36 | # from Keystone 37 | 'MovedPermanently', 'Found', 'SeeOther', 'NotModified', 'UseProxy', 38 | 'TemporaryRedirect') 39 | 40 | from werkzeug.exceptions import * 41 | from werkzeug.datastructures import Headers 42 | from werkzeug.utils import redirect 43 | 44 | class ThreeOhX(HTTPException): 45 | def __init__(self, location): 46 | self.location = location 47 | 48 | def get_headers(self, environ): 49 | return Headers([('Location', self.location), ('Content-Type', 'text/html')]) 50 | 51 | def get_description(self, environ): 52 | return '

%s: %s

' % (self.message, self.location, self.location) 53 | 54 | class MovedPermanently(ThreeOhX): 55 | """`301 Moved Permanently` 56 | 57 | Raise if the resource has moved, and the user agent should always 58 | redirect the user to the new location. 59 | """ 60 | code = 301 61 | message = 'Moved Permanently' 62 | 63 | class Found(ThreeOhX): 64 | """`302 Found` 65 | 66 | Raise if the resource has moved, but the user agent should request 67 | this request URI again in the future. 68 | """ 69 | code = 302 70 | message = 'Found' 71 | 72 | class SeeOther(ThreeOhX): 73 | """`303 See Other` 74 | 75 | Raise if the response to the request can be found at another location, 76 | usually sent after successfully processing a ``POST`` request. 77 | """ 78 | code = 303 79 | message = 'See Other' 80 | 81 | class NotModified(HTTPException): 82 | """`304 Not Modified` 83 | 84 | Sent in response to a conditional GET request when the user agent's 85 | cached copy is already up to date. 86 | """ 87 | code = 304 88 | description = None 89 | 90 | class UseProxy(ThreeOhX): 91 | """`305 Use Proxy` 92 | 93 | Indicate that the user-agent should retry the request using the proxy as 94 | defined in the ``Location`` header. 95 | """ 96 | code = 305 97 | description = 'Use Proxy' 98 | 99 | # 306 is unused 100 | 101 | class TemporaryRedirect(ThreeOhX): 102 | """`307 Temporary Redirect` 103 | 104 | Raise if the resource may have moved, and to indicate that the user 105 | agent should request this request URI again in the future. 106 | """ 107 | code = 307 108 | description = 'Temporary Redirect' 109 | 110 | -------------------------------------------------------------------------------- /keystone/main.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2011, Daniel Crosta 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # * Redistributions of source code must retain the above copyright notice, 8 | # this list of conditions and the following disclaimer. 9 | # 10 | # * Redistributions in binary form must reproduce the above copyright notice, 11 | # this list of conditions and the following disclaimer in the documentation 12 | # and/or other materials provided with the distribution. 13 | # 14 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 17 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 18 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 19 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 20 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 21 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 22 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 23 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 24 | # POSSIBILITY OF SUCH DAMAGE. 25 | 26 | 27 | from datetime import datetime 28 | from itertools import izip 29 | import hashlib 30 | import mimetypes 31 | import os, os.path 32 | import sys 33 | import urlparse 34 | import warnings 35 | 36 | from werkzeug.wrappers import Request, Response 37 | from werkzeug.exceptions import HTTPException 38 | 39 | from keystone import http 40 | from keystone.render import * 41 | 42 | # requests for paths ending in these extensions 43 | # will be rejected with status 404 44 | HIDDEN_EXTS = set(('.ks', '.py', '.pyc', '.pyo')) 45 | HIDDEN_PREFIXES = set(('.', '_')) 46 | 47 | class Keystone(object): 48 | 49 | def __init__(self, app_dir=os.getcwd(), static_expires=86400): 50 | self.app_dir = os.path.abspath(app_dir) 51 | self.static_expires = 86400 52 | self.engine = RenderEngine(self) 53 | 54 | if self.app_dir not in sys.path: 55 | sys.path.insert(0, self.app_dir) 56 | 57 | try: 58 | import startup 59 | except ImportError: 60 | pass 61 | 62 | def __call__(self, environ, start_response): 63 | request = Request(environ) 64 | response = self.dispatch(request) 65 | return response(environ, start_response) 66 | 67 | def dispatch(self, request): 68 | try: 69 | found = self._find(request.path) 70 | 71 | if isinstance(found, Template): 72 | return self.render_keystone(request, found) 73 | elif isinstance(found, file): 74 | return self.render_static(request, found) 75 | 76 | raise http.NotFound() 77 | 78 | except HTTPException, httpe: 79 | # TODO: error handler hooks 80 | return httpe.get_response(request.environ) 81 | 82 | def render_keystone(self, request, template): 83 | response = Response(mimetype='text/html') 84 | 85 | viewlocals = { 86 | 'request': request, 87 | 'http': http, 88 | 'headers': response.headers, 89 | 'set_cookie': response.set_cookie, 90 | 'delete_cookie': response.delete_cookie, 91 | 'return_response': return_response, 92 | 'app_dir': self.app_dir, 93 | } 94 | 95 | try: 96 | response.response = self.engine.render(template, viewlocals) 97 | except HTTPException, ex: 98 | return ex.get_response(request.environ) 99 | except: 100 | raise http.InternalServerError() 101 | 102 | return response 103 | 104 | def render_static(self, request, fileobj): 105 | if request.method != 'GET': 106 | raise http.MethodNotAllowed(['GET']) 107 | 108 | content_type, _ = mimetypes.guess_type(fileobj.name) 109 | 110 | stat = os.stat(fileobj.name) 111 | etag = hashlib.md5(str(stat.st_mtime)).hexdigest() 112 | 113 | response = Response(fileobj, mimetype=content_type) 114 | response.content_length = stat.st_size 115 | response.add_etag(etag) 116 | response.last_modified = datetime.utcfromtimestamp(stat.st_mtime) 117 | response.expires = datetime.utcfromtimestamp(stat.st_mtime + self.static_expires) 118 | 119 | response.make_conditional(request) 120 | return response 121 | 122 | def _find(self, path): 123 | if any(path.endswith(ext) for ext in HIDDEN_EXTS): 124 | return None 125 | 126 | if path.startswith('/'): 127 | path = path[1:] 128 | if path == '': 129 | path = 'index' 130 | 131 | # use urljoin, since it preserves the trailing / 132 | # that may be a part of path; since self.app_dir 133 | # was abspath'd, we must unconditionally add a 134 | # trailing slash to *it*, since the second arg 135 | # to urljoin is treated relative to the first 136 | fspath = urlparse.urljoin(self.app_dir + '/', path) 137 | 138 | # first: see if an exact match exists 139 | if os.path.isfile(fspath): 140 | filename = os.path.basename(fspath) 141 | if any(filename.startswith(pre) for pre in HIDDEN_PREFIXES): 142 | return None 143 | return file(fspath, 'rb') 144 | 145 | # next: see if an exact path match with 146 | # extension ".ks" exists, and load template 147 | fspath += '.ks' 148 | if os.path.isfile(fspath): 149 | return self.engine.get_template(path + '.ks') 150 | 151 | # finally: see if a parameterized path matches 152 | # the request path. 153 | candidates = [] 154 | 155 | pathparts = path.split('/') 156 | pathdepth = path.count('/') 157 | for dirpath, dirnames, filenames in os.walk(self.app_dir): 158 | dirpath = dirpath[len(self.app_dir):] 159 | depth = dirpath.count('/') 160 | for dirname in list(dirnames): 161 | if dirname == pathparts[depth]: 162 | continue 163 | if dirname.startswith('%'): 164 | continue 165 | dirnames.remove(dirname) 166 | 167 | if pathdepth == depth: 168 | dirpath = dirpath.lstrip('/') 169 | for filename in filenames: 170 | if filename.startswith('%'): 171 | candidates.append(os.path.join(dirpath, filename)) 172 | elif filename.endswith('.ks'): 173 | if pathparts[-1] == '' and filename == 'index.ks' or \ 174 | filename == pathparts[-1] + '.ks': 175 | candidates.append(os.path.join(dirpath, filename)) 176 | elif filename == pathparts[-1]: 177 | candidates.append(os.path.join(dirpath, filename)) 178 | 179 | if not candidates: 180 | return None 181 | 182 | scores = self._score_candidates(path, candidates) 183 | maxscore = max(scores) 184 | candidates = [c for c, s in izip(candidates, scores) if s == maxscore] 185 | 186 | if len(candidates) > 1: 187 | # choose the first one alphabetically; 188 | # this is arbitrary, but consistent 189 | candidates.sort() 190 | warnings.warn( 191 | 'Multiple parameterized paths matched: %r, choosing %r' % 192 | (candidates, candidates[0])) 193 | 194 | winner = candidates[0] 195 | if not winner.endswith('.ks'): 196 | # we've matched a static file with a wildcard 197 | # path, so just return a file object on it 198 | fspath = os.path.join(self.app_dir, winner) 199 | return file(fspath, 'rb') 200 | 201 | urlparams = {} 202 | for pathpart, urlpart in izip(path.split('/'), winner.split('/')): 203 | if urlpart.startswith('%'): 204 | name = urlpart[1:] 205 | if name.endswith('.ks'): 206 | name = name[:-3] 207 | urlparams[name] = pathpart 208 | 209 | template = self.engine.get_template(winner).copy() 210 | template.urlparams = urlparams 211 | return template 212 | 213 | def _score_candidates(self, path, candidates): 214 | """ 215 | When several templates may match a path, we score the 216 | candidate templates according to this algorithm: 217 | 218 | * Assign one point to each candidate for each path segment 219 | that matches exactly 220 | * Assign two points to each candidate if the final path 221 | segment is the empty string (that is, the path ended in 222 | a forward slash) and the candidate's final segment is 223 | "index.ks" 224 | 225 | Returns a list of scores in the same order as candidates. 226 | """ 227 | pparts = path.split('/') 228 | scores = [] 229 | 230 | for candidate in candidates: 231 | cparts = candidate.split('/') 232 | score = 0 233 | for pathpart, candidatepart in izip(pparts, cparts): 234 | if candidatepart.endswith('.ks'): 235 | candidatepart = candidatepart[:-3] 236 | if pathpart == candidatepart: 237 | score += 1 238 | elif pathpart == '' and candidatepart == 'index': 239 | score += 2 240 | scores.append(score) 241 | 242 | return scores 243 | 244 | -------------------------------------------------------------------------------- /keystone/render.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2011, Daniel Crosta 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # * Redistributions of source code must retain the above copyright notice, 8 | # this list of conditions and the following disclaimer. 9 | # 10 | # * Redistributions in binary form must reproduce the above copyright notice, 11 | # this list of conditions and the following disclaimer in the documentation 12 | # and/or other materials provided with the distribution. 13 | # 14 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 17 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 18 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 19 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 20 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 21 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 22 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 23 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 24 | # POSSIBILITY OF SUCH DAMAGE. 25 | 26 | 27 | __all__ = ('return_response', 'template_filter', 'Template', 28 | 'RenderEngine', 'InvalidTemplate') 29 | 30 | import compiler 31 | from compiler.ast import Import, From 32 | import jinja2 33 | import os, os.path 34 | 35 | class InvalidTemplate(Exception): 36 | """Indicates that a .ks template has more than one separator.""" 37 | 38 | class TemplateNotFound(Exception): 39 | """Indicates that a .ks template by the given name does not exist.""" 40 | 41 | class StopViewFunc(Exception): 42 | """Raised by return_response() to prevent template rendering.""" 43 | def __init__(self, body): 44 | self.body = body 45 | 46 | def return_response(body): 47 | """Passed into viewlocals to allow view code to immediately 48 | respond, bypassing templates (e.g. to return binary content 49 | from a file or database). 50 | """ 51 | if isinstance(body, basestring): 52 | body = (body, ) 53 | raise StopViewFunc(body) 54 | 55 | def template_filter(func): 56 | """Register a Jinja2 filter function. The name of the function 57 | will become the name of the filter in the template environment. 58 | """ 59 | # by the time this is called (from within Python modules in the 60 | # application, the RenderEngine, and thus the Jinja Environment, 61 | # have already been created 62 | jinja_env.filters[func.__name__] = func 63 | return func 64 | 65 | class Template(object): 66 | """Holds a template body, viewfunc, mtime, and valid methods.""" 67 | 68 | def __init__(self, viewfunc, body, mtime=None, name=None): 69 | self.viewfunc = viewfunc 70 | self.body = body 71 | self.mtime = mtime 72 | self.name = name 73 | self.urlparams = {} 74 | 75 | def copy(self): 76 | return Template(self.viewfunc, self.body, self.mtime, self.name) 77 | 78 | jinja_env = None 79 | class RenderEngine(object): 80 | def __init__(self, app): 81 | self.app = app 82 | self.templates = {} 83 | 84 | global jinja_env 85 | jinja_env = jinja2.Environment( 86 | loader=jinja2.FunctionLoader(self.get_template_body)) 87 | 88 | def parse(self, fileobj): 89 | """Parse a .ks file into a view callable and a template 90 | string. If there is no separator ("----") then the first 91 | part is treated as the template, and the view callable is 92 | a no-op. 93 | """ 94 | first, second = [], [] 95 | active = first 96 | 97 | for lineno, line in enumerate(fileobj): 98 | if line.strip() == '----': 99 | if active is second: 100 | raise InvalidTemplate( 101 | 'Line %d: separator already seen on line %d' % (lineno, len(first))) 102 | active = second 103 | else: 104 | active.append(line) 105 | 106 | if active is first: 107 | return Template( 108 | viewfunc=lambda x: x, 109 | body=''.join(first)) 110 | 111 | viewcode, viewglobals = self.compile(''.join(first), fileobj.name) 112 | def viewfunc(viewlocals): 113 | exec viewcode in viewglobals, viewlocals 114 | return viewlocals 115 | 116 | return Template( 117 | viewfunc=viewfunc, 118 | body=''.join(second)) 119 | 120 | def compile(self, viewcode_str, filename): 121 | """Compile the view code and return a code object 122 | and dictionary of globals needed by the code object. 123 | """ 124 | viewcode = compile(viewcode_str, filename, 'exec') 125 | 126 | # scan top-level code only for "import foo" and 127 | # "from foo import *" and "from foo import bar, baz" 128 | viewglobals = {'__builtins__': __builtins__} 129 | for stmt in compiler.parse(viewcode_str).node: 130 | if isinstance(stmt, Import): 131 | modname, asname = stmt.names[0] 132 | if asname is None: 133 | asname = modname 134 | viewglobals[asname] = __import__(modname) 135 | elif isinstance(stmt, From): 136 | fromlist = [x[0] for x in stmt.names] 137 | module = __import__(stmt.modname, {}, {}, fromlist) 138 | for name, asname in stmt.names: 139 | if name == '*': 140 | for starname in getattr(module, '__all__', dir(module)): 141 | viewglobals[starname] = getattr(module, starname) 142 | else: 143 | if asname is None: 144 | asname = name 145 | viewglobals[asname] = getattr(module, name) 146 | 147 | return viewcode, viewglobals 148 | 149 | def refresh_if_needed(self, name): 150 | """Update the cached modification time, view func, 151 | and template body for the .ks template at the given 152 | path relative to the app_dir.""" 153 | filename = os.path.abspath(os.path.join(self.app.app_dir, name)) 154 | if not os.path.isfile(filename): 155 | raise TemplateNotFound('could not find template %s' % name) 156 | 157 | mtime = os.stat(filename).st_mtime 158 | template = self.templates.get(name) 159 | 160 | if template is None or template.mtime < mtime: 161 | template = self.parse(file(filename, 'rb')) 162 | template.mtime = mtime 163 | template.name = name 164 | self.templates[name] = template 165 | 166 | def render(self, template, viewlocals): 167 | """Template rendering entry point.""" 168 | jinja_template = jinja_env.get_template(template.name) 169 | viewlocals.update(template.urlparams) 170 | try: 171 | return jinja_template.generate(**template.viewfunc(viewlocals)) 172 | except StopViewFunc, stop: 173 | return stop.body 174 | 175 | def get_template(self, name): 176 | self.refresh_if_needed(name) 177 | return self.templates.get(name) 178 | 179 | def get_template_body(self, name): 180 | """Jinja2 template loader function.""" 181 | self.refresh_if_needed(name) 182 | template = self.templates[name] 183 | cached_mtime = template.mtime 184 | 185 | def uptodate(): 186 | self.refresh_if_needed(name) 187 | template = self.templates[name] 188 | return template and template.mtime and cached_mtime and template.mtime <= cached_mtime 189 | 190 | return template.body, name, uptodate 191 | 192 | -------------------------------------------------------------------------------- /keystone/scripts.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2011, Daniel Crosta 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # * Redistributions of source code must retain the above copyright notice, 8 | # this list of conditions and the following disclaimer. 9 | # 10 | # * Redistributions in binary form must reproduce the above copyright notice, 11 | # this list of conditions and the following disclaimer in the documentation 12 | # and/or other materials provided with the distribution. 13 | # 14 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 17 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 18 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 19 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 20 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 21 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 22 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 23 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 24 | # POSSIBILITY OF SUCH DAMAGE. 25 | 26 | from __future__ import with_statement 27 | 28 | import argparse 29 | import os 30 | import os.path 31 | 32 | def main(): 33 | parser = argparse.ArgumentParser(description='Run a Keystone application') 34 | parser.add_argument('app_dir', nargs='?', default=os.getcwd(), 35 | help='Path to Keystone application [current dir]') 36 | parser.add_argument('-p', '--port', dest='port', metavar='PORT', type=int, default=5000, 37 | help='Port to listen on [5000]') 38 | parser.add_argument('-H', '--host', dest='host', metavar='HOST', type=str, default='0.0.0.0', 39 | help='Hostname or IP address to listen on [0.0.0.0]') 40 | parser.add_argument('-t', '--threaded', dest='threaded', action='store_const', const=True, default=False, 41 | help='Use threads for concurrency; always False if -d/--debug is set [False]') 42 | parser.add_argument('-d', '--debug', dest='debug', action='store_const', const=False, default=True, 43 | help='Display Python tracebacks in the browser [False]') 44 | parser.add_argument('-e', '--static-expires', dest='static_expires', action='store', default=86400, type=int, 45 | help='Serve static files with expiry of STATIC_EXPIRES seconds [86400]') 46 | 47 | parser.add_argument('--configure', dest='paas', action='store', choices=['wsgi', 'heroku', 'dotcloud', 'epio'], 48 | help='Set up configuration files in app_dir for PaaS services') 49 | 50 | args = parser.parse_args() 51 | 52 | if args.paas: 53 | return configure(parser, args) 54 | 55 | return serve(parser, args) 56 | 57 | def serve(parser, args): 58 | from keystone.main import Keystone 59 | import werkzeug.serving 60 | 61 | if args.paas == 'heroku': 62 | args.host = '0.0.0.0' 63 | args.port = int(os.environ['PORT']) 64 | args.app_dir = os.getcwd() 65 | 66 | if args.debug: 67 | args.threaded = False 68 | 69 | extra = {} 70 | if args.static_expires: 71 | extra['static_expires'] = int(args.static_expires) 72 | 73 | app = Keystone(app_dir=args.app_dir, **extra) 74 | return werkzeug.serving.run_simple( 75 | hostname=args.host, 76 | port=args.port, 77 | application=app, 78 | use_reloader=args.debug, 79 | use_debugger=args.debug, 80 | use_evalex=args.debug, 81 | threaded=args.threaded, 82 | ) 83 | 84 | def configure(parser, args): 85 | import keystone 86 | 87 | def ensure_line(filename, line, mode='a'): 88 | filename = os.path.join(args.app_dir, filename) 89 | 90 | if os.path.exists(filename): 91 | with file(filename, 'r') as fp: 92 | for fpline in fp: 93 | if fpline.strip('\n') == line: 94 | return 95 | 96 | with file(filename, mode) as fp: 97 | fp.write(line) 98 | fp.write('\n') 99 | 100 | 101 | if args.paas in ('heroku', 'dotcloud', 'epio'): 102 | ensure_line('requirements.txt', 'keystone == %s' % keystone.__version__) 103 | 104 | if args.paas == 'wsgi': 105 | ensure_line('wsgi.py', 'from os.path import abspath, dirname') 106 | ensure_line('wsgi.py', 'here = abspath(dirname(__file__))') 107 | ensure_line('wsgi.py', 'from keystone.main import Keystone') 108 | ensure_line('wsgi.py', 'application = Keystone(here)') 109 | 110 | if args.paas == 'heroku': 111 | ensure_line('requirements.txt', 'gunicorn >= 0.13.4') 112 | 113 | ensure_line('wsgi.py', 'from keystone.main import Keystone') 114 | ensure_line('wsgi.py', 'application = Keystone("/app")') 115 | 116 | ensure_line('Procfile', 'web: gunicorn wsgi:application -w 4 -b 0.0.0.0:$PORT') 117 | 118 | if args.paas == 'dotcloud': 119 | ensure_line('dotcloud.yml', 'www:') 120 | ensure_line('dotcloud.yml', ' type: python') 121 | ensure_line('dotcloud.yml', ' approot: .') 122 | 123 | ensure_line('wsgi.py', 'from keystone.main import Keystone') 124 | ensure_line('wsgi.py', 'application = Keystone("/home/dotcloud/current")') 125 | 126 | if args.paas == 'epio': 127 | ensure_line('epio.ini', '[wsgi]') 128 | ensure_line('epio.ini', 'requirements = requirements.txt') 129 | ensure_line('epio.ini', 'entrypoint = wsgi:application') 130 | 131 | ensure_line('wsgi.py', 'from keystone.main import Keystone') 132 | ensure_line('wsgi.py', 'from os import getcwd') 133 | ensure_line('wsgi.py', 'application = Keystone(getcwd())') 134 | 135 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | verbosity = 2 3 | detailed-errors = 1 4 | with-coverage = 1 5 | cover-package = keystone 6 | cover-inclusive = 1 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distribute_setup import use_setuptools 2 | use_setuptools('0.6.15') 3 | 4 | from setuptools import setup, find_packages 5 | from sys import version_info 6 | 7 | REQUIRES = [ 8 | 'werkzeug', 9 | 'jinja2', 10 | ] 11 | 12 | if version_info < (2, 7): 13 | # no argparse in 2.6 standard 14 | REQUIRES.append('argparse') 15 | 16 | from keystone import __version__ 17 | 18 | setup( 19 | name='Keystone', 20 | description='A very simple web framework', 21 | version=__version__, 22 | author='Dan Crosta', 23 | author_email='dcrosta@late.am', 24 | license='BSD', 25 | url='https://github.com/dcrosta/keystone', 26 | classifiers=[ 27 | 'Programming Language :: Python', 28 | 'Programming Language :: Python :: 2.6', 29 | 'Programming Language :: Python :: 2.7', 30 | 'License :: OSI Approved :: BSD License', 31 | 'Development Status :: 3 - Alpha', 32 | 'Topic :: Internet :: WWW/HTTP', 33 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 34 | ], 35 | install_requires=REQUIRES, 36 | setup_requires=['nose'], 37 | tests_require=['coverage'], 38 | packages=['keystone'], 39 | entry_points={ 40 | 'console_scripts': [ 41 | 'keystone = keystone.scripts:main', 42 | ], 43 | } 44 | ) 45 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcrosta/keystone/5c76da08023ab058998653ce952fe45a9bc0924a/test/__init__.py -------------------------------------------------------------------------------- /test/test_main.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2011, Daniel Crosta 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # * Redistributions of source code must retain the above copyright notice, 8 | # this list of conditions and the following disclaimer. 9 | # 10 | # * Redistributions in binary form must reproduce the above copyright notice, 11 | # this list of conditions and the following disclaimer in the documentation 12 | # and/or other materials provided with the distribution. 13 | # 14 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 17 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 18 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 19 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 20 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 21 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 22 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 23 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 24 | # POSSIBILITY OF SUCH DAMAGE. 25 | 26 | from __future__ import with_statement 27 | 28 | import os 29 | import os.path 30 | import shutil 31 | import sys 32 | import unittest 33 | from inspect import getargspec 34 | from werkzeug.datastructures import Headers 35 | from werkzeug.wrappers import Request 36 | from werkzeug.wrappers import BaseResponse 37 | from werkzeug.test import Client 38 | from werkzeug.test import EnvironBuilder 39 | 40 | import util 41 | 42 | from keystone import http 43 | from keystone.main import Keystone 44 | from keystone.render import Template 45 | 46 | def wsgi_environ(method, url, data=None, content_type=None, headers={}): 47 | hdrs = Headers(headers) 48 | b = EnvironBuilder(method=method, path=url, headers=hdrs) 49 | return b.get_environ() 50 | 51 | class KeystoneTest(unittest.TestCase): 52 | 53 | def setUp(self): 54 | here = os.path.abspath(os.path.dirname(__file__)) 55 | self.app_dir = os.path.join(here, 'app_dir') 56 | 57 | shutil.rmtree(self.app_dir, ignore_errors=True) 58 | os.makedirs(self.app_dir) 59 | 60 | def tearDown(self): 61 | shutil.rmtree(self.app_dir, ignore_errors=True) 62 | if 'startup' in sys.modules: 63 | del sys.modules['startup'] 64 | 65 | def test_app_is_wsgi(self): 66 | app = Keystone(self.app_dir) 67 | 68 | self.assertTrue(callable(app), 'Keystone object should be callable') 69 | self.assertEqual(len(getargspec(app.__call__)[0]), 3, 'Keystone should take 2 arguments') 70 | self.assertTrue(getargspec(app.__call__)[1] is None, 'viewfunc should take no varargs') 71 | self.assertTrue(getargspec(app.__call__)[2] is None, 'viewfunc should take no kwargs') 72 | self.assertTrue(getargspec(app.__call__)[3] is None, 'viewfunc should have no defaults') 73 | 74 | # test that a simple call gets a valid response 75 | client = Client(app, BaseResponse) 76 | response = client.get('/') 77 | 78 | self.assertEqual(response.status_code, 404, 'WSGI call did not get 404 for request to empty app') 79 | 80 | def test_dispatch(self): 81 | index = os.path.join(self.app_dir, 'index.ks') 82 | static = os.path.join(self.app_dir, 'base.css') 83 | 84 | app = Keystone(self.app_dir) 85 | req = Request(wsgi_environ('GET', '/')) 86 | 87 | response = app.dispatch(req) 88 | self.assertTrue(isinstance(response, BaseResponse), 'dispatch on missing did not return a Response') 89 | self.assertEqual(response.status_code, 404, 'missing did not get a 404 HTTPException (got %d)' % response.status_code) 90 | 91 | changer = util.MtimeChanger() 92 | 93 | with changer.change_times(file(index, 'w')) as fp: 94 | fp.write('raise Exception("blah")\n----\nthis is HTML\n') 95 | 96 | response = app.dispatch(req) 97 | self.assertTrue(isinstance(response, BaseResponse), 'dispatch on exception did not return a Response') 98 | self.assertEqual(response.status_code, 500, 'error did not get a 500 HTTPException (got %d)' % response.status_code) 99 | 100 | with changer.change_times(file(index, 'w')) as fp: 101 | fp.write('raise http.SeeOther("/foo")\n----\nthis is HTML\n') 102 | 103 | response = app.dispatch(req) 104 | self.assertTrue(isinstance(response, BaseResponse), 'dispatch on HTTPException did not return a Response') 105 | self.assertEqual(response.status_code, 303, 'redirect (see other) did not get a 303 HTTPException (got %d)' % response.status_code) 106 | 107 | with changer.change_times(file(index, 'w')) as fp: 108 | fp.write('this is HTML\n') 109 | 110 | response = app.dispatch(req) 111 | self.assertTrue(isinstance(response, BaseResponse), 'dispatch on html-only template did not return a Response') 112 | self.assertEqual(response.status_code, 200, 'dispatch to template did not get a 200 status (got %d)' % response.status_code) 113 | 114 | with changer.change_times(file(index, 'w')) as fp: 115 | fp.write('# this is python\n----\nthis is HTML\n') 116 | 117 | response = app.dispatch(req) 118 | self.assertTrue(isinstance(response, BaseResponse), 'dispatch on mixed template did not return a Response') 119 | self.assertEqual(response.status_code, 200, 'dispatch to template did not get a 200 status (got %d)' % response.status_code) 120 | 121 | with changer.change_times(file(static, 'w')) as fp: 122 | fp.write('* { background-color: white }\n') 123 | 124 | req = Request(wsgi_environ('GET', '/base.css')) 125 | response = app.dispatch(req) 126 | self.assertTrue(isinstance(response, BaseResponse), 'dispatch on static did not return a Response') 127 | self.assertEqual(response.status_code, 200, 'dispatch to static did not get a 200 status (got %d)' % response.status_code) 128 | 129 | def test_render_static(self): 130 | static = os.path.join(self.app_dir, 'base.css') 131 | 132 | with file(static, 'w') as fp: 133 | fp.write('* { background-color: white }\n') 134 | 135 | app = Keystone(self.app_dir) 136 | req = Request(wsgi_environ('POST', '/base.css')) 137 | 138 | fileobj = file(static, 'rb') 139 | self.assertRaises(http.MethodNotAllowed, app.render_static, req, fileobj) 140 | 141 | req = Request(wsgi_environ('GET', '/base.css')) 142 | fileobj.seek(0, 0) 143 | response = app.render_static(req, fileobj) 144 | self.assertEqual(response.data, '* { background-color: white }\n') 145 | self.assertEqual(response.status_code, 200) 146 | self.assertEqual(response.content_length, 30) 147 | self.assertEqual(response.mimetype, 'text/css') 148 | self.assertTrue('ETag' in response.headers) 149 | self.assertTrue('Last-Modified' in response.headers) 150 | self.assertTrue('Expires' in response.headers) 151 | 152 | last_modified = response.headers['Last-Modified'] 153 | 154 | req = Request(wsgi_environ('GET', '/base.css', headers={'If-Modified-Since': last_modified})) 155 | response = app.render_static(req, fileobj) 156 | self.assertEqual(response.data, '') 157 | self.assertEqual(response.status_code, 304) 158 | 159 | def test_render_keystone(self): 160 | changer = util.MtimeChanger() 161 | index = os.path.join(self.app_dir, 'index.ks') 162 | 163 | with file(index, 'w') as fp: 164 | fp.write('# this is python\n----\nthis is HTML\n') 165 | 166 | app = Keystone(self.app_dir) 167 | template = app.engine.get_template(index) 168 | 169 | req = Request(wsgi_environ('GET', '/')) 170 | response = app.render_keystone(req, template) 171 | 172 | self.assertEqual(response.data, 'this is HTML') 173 | self.assertEqual(response.status_code, 200) 174 | self.assertEqual(response.mimetype, 'text/html') 175 | self.assertTrue('Content-Length' not in response.headers) 176 | self.assertTrue('ETag' not in response.headers) 177 | self.assertTrue('Last-Modified' not in response.headers) 178 | self.assertTrue('Expires' not in response.headers) 179 | self.assertTrue('Cache-Control' not in response.headers) 180 | self.assertTrue('Set-Cookie' not in response.headers) 181 | 182 | for method in ('GET', 'POST', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'): 183 | # none of these should raise 184 | req = Request(wsgi_environ(method, '/')) 185 | app.render_keystone(req, template) 186 | 187 | 188 | with changer.change_times(file(index, 'w')) as fp: 189 | fp.write('return_response("hello, world")\n----\nthis is HTML\n') 190 | 191 | req = Request(wsgi_environ('GET', '/')) 192 | template = app.engine.get_template(index) 193 | response = app.render_keystone(req, template) 194 | 195 | self.assertEqual(response.data, 'hello, world') 196 | self.assertEqual(response.status_code, 200) 197 | self.assertEqual(response.mimetype, 'text/html') 198 | self.assertTrue('Content-Length' not in response.headers) 199 | self.assertTrue('ETag' not in response.headers) 200 | self.assertTrue('Last-Modified' not in response.headers) 201 | self.assertTrue('Expires' not in response.headers) 202 | self.assertTrue('Cache-Control' not in response.headers) 203 | self.assertTrue('Set-Cookie' not in response.headers) 204 | 205 | def test_cookies(self): 206 | changer = util.MtimeChanger() 207 | index = os.path.join(self.app_dir, 'index.ks') 208 | app = Keystone(self.app_dir) 209 | 210 | with file(index, 'w') as fp: 211 | fp.write('set_cookie("name", "value")\n----\nthis is HTML\n') 212 | 213 | req = Request(wsgi_environ('GET', '/')) 214 | template = app.engine.get_template(index) 215 | response = app.render_keystone(req, template) 216 | self.assertEqual(response.headers['Set-Cookie'], 'name=value; Path=/') 217 | 218 | with changer.change_times(file(index, 'w')) as fp: 219 | fp.write('delete_cookie("name")\n----\nthis is HTML\n') 220 | 221 | req = Request(wsgi_environ('GET', '/')) 222 | template = app.engine.get_template(index) 223 | response = app.render_keystone(req, template) 224 | self.assertEqual(response.headers['Set-Cookie'], 'name=; expires=Thu, 01-Jan-1970 00:00:00 GMT; Max-Age=0; Path=/') 225 | 226 | def test_headers(self): 227 | index = os.path.join(self.app_dir, 'index.ks') 228 | app = Keystone(self.app_dir) 229 | 230 | with file(index, 'w') as fp: 231 | fp.write('headers.set("X-Key", "value")\n----\nthis is HTML\n') 232 | 233 | req = Request(wsgi_environ('GET', '/')) 234 | template = app.engine.get_template(index) 235 | response = app.render_keystone(req, template) 236 | self.assertTrue('X-Key' in response.headers) 237 | self.assertEqual(response.headers['X-Key'], 'value') 238 | 239 | def test_find(self): 240 | app_contents = { 241 | 'index.ks': 'index', 242 | 'pageA.ks': 'pageA', 243 | '%widlcard.ks': '----\n{{wildcard}}', 244 | 'file.txt': 'file 1', 245 | '_base.html': 'hello', 246 | 'something.py': '# blah', 247 | 'something.pyc': '# blah', 248 | 'something.pyo': '# blah', 249 | '.dotfile': 'dotfile', 250 | 'subdir': { 251 | 'index.ks': 'subdir index', 252 | 'pageA.ks': 'subdir pageA', 253 | 'file.txt': 'file 2', 254 | }, 255 | '%wildcard': { 256 | 'index.ks': '----\n{{wildcard}} index', 257 | 'pageA.ks': '----\n{{wildcard}} pageA', 258 | '%wildcard2.ks': '----\n{{wildcard}} {{wildcard2}}', 259 | 'file.txt': 'wildcard file', 260 | }, 261 | 'baddir': { 262 | '%wildcardA.ks': '----\n{{wildcardA}} A', 263 | '%wildcardB.ks': '----\n{{wildcardB}} B', 264 | }, 265 | 'static': { 266 | 'file.txt': 'file 3' 267 | } 268 | } 269 | 270 | def write_files(root_path, tree): 271 | for filename, contents in tree.iteritems(): 272 | if isinstance(contents, dict): 273 | subdir = os.path.join(root_path, filename) 274 | os.makedirs(subdir) 275 | write_files(subdir, contents) 276 | else: 277 | with file(os.path.join(root_path, filename), 'w') as fp: 278 | fp.write(contents) 279 | 280 | write_files(self.app_dir, app_contents) 281 | 282 | cases = [ 283 | {'path': '/', 'type': Template, 'body': 'index'}, 284 | {'path': '/index', 'type': Template, 'body': 'index'}, 285 | {'path': '/index.ks', 'type': type(None)}, 286 | 287 | {'path': '/something.py', 'type': type(None)}, 288 | {'path': '/something.pyc', 'type': type(None)}, 289 | {'path': '/something.pyo', 'type': type(None)}, 290 | {'path': '/_base.html', 'type': type(None)}, 291 | {'path': '/.dotfile', 'type': type(None)}, 292 | 293 | {'path': '/pageA', 'type': Template, 'body': 'pageA'}, 294 | 295 | {'path': '/somePage', 'type': Template, 'body': '{{wildcard}}'}, 296 | {'path': '/anotherPage', 'type': Template, 'body': '{{wildcard}}'}, 297 | 298 | {'path': '/file.txt', 'type': file, 'contents': 'file 1'}, 299 | 300 | # TODO: not sure that this is what we should be returning 301 | # here. options are: 302 | # 303 | # 1. return None (404) 304 | # 2. return the same as /subdir/ 305 | # 3. redirect to /subdir/ 306 | {'path': '/subdir', 'type': Template, 'body': '{{wildcard}}'}, 307 | 308 | {'path': '/subdir/', 'type': Template, 'body': 'subdir index'}, 309 | {'path': '/subdir/index', 'type': Template, 'body': 'subdir index'}, 310 | {'path': '/subdir/index/', 'type': type(None)}, 311 | 312 | {'path': '/subdir/pageA', 'type': Template, 'body': 'subdir pageA'}, 313 | {'path': '/subdir/file.txt', 'type': file, 'contents': 'file 2'}, 314 | 315 | {'path': '/anydir', 'type': Template, 'body': '{{wildcard}}'}, 316 | 317 | {'path': '/anydir/', 'type': Template, 'body': '{{wildcard}} index'}, 318 | {'path': '/anydir/index', 'type': Template, 'body': '{{wildcard}} index'}, 319 | {'path': '/anydir/pageA', 'type': Template, 'body': '{{wildcard}} pageA'}, 320 | 321 | {'path': '/anydir/pagename', 'type': Template, 'body': '{{wildcard}} {{wildcard2}}'}, 322 | {'path': '/anydir/pagename/', 'type': type(None)}, 323 | 324 | {'path': '/anydir/file.txt', 'type': file, 'contents': 'wildcard file'}, 325 | {'path': '/other/file.txt', 'type': file, 'contents': 'wildcard file'}, 326 | 327 | {'path': '/baddir/foo', 'type': Template, 'body': '{{wildcardA}} A', 'warns': True}, 328 | ] 329 | 330 | app = Keystone(self.app_dir) 331 | for testcase in cases: 332 | path = testcase['path'] 333 | with util.WarningCatcher(UserWarning) as wc: 334 | found = app._find(path) 335 | if 'warns' in testcase: 336 | self.assertTrue(wc.has_warning(UserWarning), '_find(%r) should have warned about multiple matches' % path) 337 | 338 | self.assertEqual(type(found), testcase['type'], '_find(%r) returned %s, expected %s' % (path, type(found), testcase['type'])) 339 | 340 | if isinstance(found, Template): 341 | self.assertEqual(found.body, testcase['body'], '_find(%r).body is %r, expected %r' % (path, found.body, testcase['body'])) 342 | elif isinstance(found, file): 343 | contents = found.read() 344 | self.assertEqual(contents, testcase['contents'], '_find(%r).read() is %r, expected %r' % (path, contents, testcase['contents'])) 345 | 346 | def test_score_candidates(self): 347 | cases = [ 348 | ('foo', ['%x.ks', 'foo.ks'], [0, 1]), 349 | ('bar', ['%x.ks', '%y.ks'], [0, 0]), 350 | ('baz', ['baz.ks'], [1]), 351 | ('foo/bar', ['%y/%x.ks', 'foo/%x.ks', '%y/bar.ks'], [0, 1, 1]), 352 | ('foo/', ['%y/%x.ks', 'foo/%x.ks', '%y/index.ks'], [0, 1, 2]), 353 | ('foo/baz', ['%x/%y.ks', 'foo/%y.ks', '%x/baz', '%x/baz.ks'], [0, 1, 1, 1]), 354 | ] 355 | 356 | app = Keystone() 357 | for path, candidates, expected in cases: 358 | scores = app._score_candidates(path, candidates) 359 | self.assertEqual(scores, expected, 'wrong scores for %r: %r, expected %r' % (path, scores, expected)) 360 | 361 | def test_startup_module(self): 362 | startup = os.path.join(self.app_dir, 'startup.py') 363 | with file(startup, 'w') as fp: 364 | fp.write('# do nothing\n') 365 | 366 | Keystone(self.app_dir) 367 | 368 | self.assertTrue('startup' in sys.modules, 'startup.py was not loaded') 369 | self.assertEqual(sys.modules['startup'].__file__, startup, 'wrong startup.py was loaded') 370 | 371 | def test_template_filters(self): 372 | with file(os.path.join(self.app_dir, 'startup.py'), 'w') as fp: 373 | fp.write('from keystone.render import template_filter\n') 374 | fp.write('@template_filter\n') 375 | fp.write('def silly(value):\n') 376 | fp.write(' return "silly"\n') 377 | 378 | with file(os.path.join(self.app_dir, 'index.ks'), 'w') as fp: 379 | fp.write('{{novar|silly}}') 380 | 381 | app = Keystone(self.app_dir) 382 | req = Request(wsgi_environ('GET', '/')) 383 | 384 | response = app.dispatch(req) 385 | output = ''.join(response.response) 386 | self.assertTrue(isinstance(response, BaseResponse)) 387 | self.assertEqual(output, 'silly') 388 | 389 | -------------------------------------------------------------------------------- /test/test_render.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2011, Daniel Crosta 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # * Redistributions of source code must retain the above copyright notice, 8 | # this list of conditions and the following disclaimer. 9 | # 10 | # * Redistributions in binary form must reproduce the above copyright notice, 11 | # this list of conditions and the following disclaimer in the documentation 12 | # and/or other materials provided with the distribution. 13 | # 14 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 17 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 18 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 19 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 20 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 21 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 22 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 23 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 24 | # POSSIBILITY OF SUCH DAMAGE. 25 | 26 | from __future__ import with_statement 27 | 28 | import os 29 | import os.path 30 | import shutil 31 | import re 32 | import unittest 33 | from inspect import iscode, isfunction, getargspec 34 | from StringIO import StringIO 35 | 36 | import util 37 | 38 | from keystone.render import Template 39 | from keystone.render import InvalidTemplate 40 | from keystone.render import TemplateNotFound 41 | from keystone.render import RenderEngine 42 | 43 | 44 | def dedent(string, joiner='\n'): 45 | """ 46 | De-indent a string. Used by ParserTest to let nicely-formatted 47 | triple-quoted strings be used for defining templates. Based on 48 | DocTestParser._min_indent. 49 | """ 50 | string = string.strip('\r\n') 51 | INDENT_RE = re.compile('^([ ]*)(?=\S)', re.MULTILINE) 52 | min_indent = min(len(indent) for indent in INDENT_RE.findall(string)) 53 | return joiner.join(line[min_indent:] for line in string.splitlines()) 54 | 55 | class MockApp(object): 56 | def __init__(self, app_dir=None): 57 | self.app_dir = app_dir 58 | 59 | def template_fileobj(string, name='madeup'): 60 | """ 61 | Create a file-like object with 'name' attribute (on real files 62 | this is the file name; for tests we can just use something 63 | made up) suitable for use with RenderEngine.parse() and friends. 64 | """ 65 | out = StringIO(dedent(string)) 66 | out.name = name 67 | return out 68 | 69 | 70 | class TemplateTest(unittest.TestCase): 71 | 72 | def test_copy(self): 73 | t = Template( 74 | viewfunc=lambda x: x, 75 | body='body', 76 | mtime=1234, 77 | name='name', 78 | ) 79 | copy = t.copy() 80 | 81 | # template copies should be identical except for urlparams 82 | self.assertTrue(t.viewfunc is copy.viewfunc, 'viewfunc changed in copy()') 83 | self.assertTrue(t.body is copy.body, 'body changed in copy()') 84 | self.assertTrue(t.mtime is copy.mtime, 'mtime changed in copy()') 85 | self.assertTrue(t.name is copy.name, 'name changed in copy()') 86 | self.assertTrue(t.urlparams is not copy.urlparams, 'urlparams did not change in copy()') 87 | self.assertTrue(copy.urlparams == {}, 'urlparams is not empty in copy()') 88 | 89 | # template copies should not have urlparams set 90 | t.urlparams = {'a': 1, 'b': 2} 91 | copy = t.copy() 92 | 93 | self.assertTrue(t.viewfunc is copy.viewfunc, 'viewfunc changed in copy()') 94 | self.assertTrue(t.body is copy.body, 'body changed in copy()') 95 | self.assertTrue(t.mtime is copy.mtime, 'mtime changed in copy()') 96 | self.assertTrue(t.name is copy.name, 'name changed in copy()') 97 | self.assertTrue(t.urlparams is not copy.urlparams, 'urlparams did not change in copy()') 98 | self.assertTrue(copy.urlparams == {}, 'urlparams is not empty in copy()') 99 | 100 | 101 | class ParserTest(unittest.TestCase): 102 | 103 | def test_split(self): 104 | templatefp = template_fileobj(""" 105 | # this is python 106 | ---- 107 | this is HTML 108 | """) 109 | 110 | engine = RenderEngine(MockApp()) 111 | template = engine.parse(templatefp) 112 | viewfunc, body = template.viewfunc, template.body 113 | 114 | self.assertTrue(isfunction(viewfunc), 'viewfunc is not a function') 115 | self.assertEquals(len(getargspec(viewfunc)[0]), 1, 'viewfunc should take only 1 argument') 116 | self.assertTrue(getargspec(viewfunc)[1] is None, 'viewfunc should take no varargs') 117 | self.assertTrue(getargspec(viewfunc)[2] is None, 'viewfunc should take no kwargs') 118 | self.assertTrue(getargspec(viewfunc)[3] is None, 'viewfunc should have no defaults') 119 | 120 | self.assertEquals(body, 'this is HTML\n', 'template body is incorrect') 121 | 122 | def test_no_split(self): 123 | templatefp = template_fileobj(""" 124 | this is HTML 125 | """) 126 | 127 | engine = RenderEngine(MockApp()) 128 | template = engine.parse(templatefp) 129 | viewfunc, body = template.viewfunc, template.body 130 | 131 | self.assertEquals(1, viewfunc(1), 'viewfunc should be an identity function') 132 | 133 | self.assertEquals(body, 'this is HTML\n', 'template body is incorrect') 134 | 135 | def test_two_splits(self): 136 | templatefp = template_fileobj(""" 137 | # this is python 138 | ---- 139 | this is HTML 140 | ---- 141 | # this is an error 142 | """) 143 | 144 | engine = RenderEngine(MockApp()) 145 | self.assertRaises(InvalidTemplate, engine.parse, templatefp) 146 | 147 | def test_viewfunc(self): 148 | # the viewfunc is essentially "do some stuff, then return locals()", 149 | # so we just want to ensure that things we expect in the output dict 150 | # are there 151 | templatefp = template_fileobj(""" 152 | x = 1 153 | y = 'abc' 154 | ---- 155 | this is HTML 156 | """) 157 | 158 | engine = RenderEngine(MockApp()) 159 | template = engine.parse(templatefp) 160 | 161 | returned_locals = template.viewfunc({}) 162 | self.assertEquals({'x': 1, 'y': 'abc'}, returned_locals) 163 | 164 | # also make sure that injected variables are returned 165 | returned_locals = template.viewfunc({'injected': 'anything'}) 166 | self.assertEquals({'x': 1, 'y': 'abc', 'injected': 'anything'}, returned_locals) 167 | 168 | # unless we delete things 169 | templatefp = template_fileobj(""" 170 | x = 1 171 | y = 'abc' 172 | del injected 173 | del y 174 | ---- 175 | this is HTML 176 | """) 177 | 178 | template = engine.parse(templatefp) 179 | 180 | returned_locals = template.viewfunc({'injected': 'anything'}) 181 | self.assertEquals({'x': 1}, returned_locals) 182 | 183 | 184 | class CompilerTest(unittest.TestCase): 185 | """ 186 | The goal is not to thoroughly test the compile() built-in 187 | method, but to ensure that certain aspects of its behavior 188 | which Keystone relies upon work as expected. 189 | """ 190 | 191 | def test_basic(self): 192 | viewcode_str = dedent(""" 193 | x = 1 194 | y = 2 195 | """) 196 | 197 | engine = RenderEngine(MockApp()) 198 | viewcode, viewglobals = engine.compile(viewcode_str, 'filename') 199 | 200 | self.assertTrue('__builtins__' in viewglobals, 'view globals did not contain builtins') 201 | self.assertTrue(iscode(viewcode), 'viewcode was not a code object') 202 | 203 | def test_invalid_syntax(self): 204 | viewcode_str = dedent(""" 205 | x = 1 206 | y = 207 | """) 208 | 209 | engine = RenderEngine(MockApp()) 210 | self.assertRaises(SyntaxError, engine.compile, viewcode_str, 'filename') 211 | 212 | def test_import_detection(self): 213 | import sys 214 | import keystone.http 215 | 216 | engine = RenderEngine(MockApp()) 217 | 218 | viewcode_str = dedent(""" 219 | import sys 220 | """) 221 | viewcode, viewglobals = engine.compile(viewcode_str, 'filename') 222 | self.assertTrue('sys' in viewglobals, 'view globals did not contain imported modules') 223 | self.assertTrue(viewglobals['sys'] is sys, 'view globals got a different version of sys') 224 | 225 | viewcode_str = dedent(""" 226 | from sys import version_info 227 | """) 228 | viewcode, viewglobals = engine.compile(viewcode_str, 'filename') 229 | self.assertTrue('version_info' in viewglobals, 'view globals did not contain from foo imported modules') 230 | self.assertTrue(viewglobals['version_info'] is sys.version_info, 'view globals got a different version of version_info') 231 | 232 | viewcode_str = dedent(""" 233 | from sys import version_info as vi 234 | """) 235 | viewcode, viewglobals = engine.compile(viewcode_str, 'filename') 236 | self.assertTrue('vi' in viewglobals, 'view globals did not contain from foo import as\'d modules') 237 | self.assertTrue(viewglobals['vi'] is sys.version_info, 'view globals got a different version of vi') 238 | 239 | viewcode_str = dedent(""" 240 | from keystone.http import * 241 | """) 242 | viewcode, viewglobals = engine.compile(viewcode_str, 'filename') 243 | for name in keystone.http.__all__: 244 | self.assertTrue(name in viewglobals, 'view globals did not contain from foo import * (%s)') 245 | 246 | 247 | def test_non_existent_import_fails_during_compile(self): 248 | viewcode_str = dedent(""" 249 | import froobulator 250 | """) 251 | 252 | engine = RenderEngine(MockApp()) 253 | self.assertRaises(ImportError, engine.compile, viewcode_str, 'filename') 254 | 255 | 256 | class TestRenderEngine(unittest.TestCase): 257 | """ 258 | This tests the rendering-related (as opposed to compilation or parsing) 259 | functions of RenderEngine. 260 | """ 261 | 262 | def setUp(self): 263 | here = os.path.abspath(os.path.dirname(__file__)) 264 | self.app_dir = os.path.join(here, 'app_dir') 265 | 266 | shutil.rmtree(self.app_dir, ignore_errors=True) 267 | os.makedirs(self.app_dir) 268 | 269 | def tearDown(self): 270 | shutil.rmtree(self.app_dir, ignore_errors=True) 271 | 272 | def test_get_template(self): 273 | with file(os.path.join(self.app_dir, 'tmpl.ks'), 'w') as fp: 274 | fp.write(dedent(""" 275 | # this is python 276 | ---- 277 | this is HTML 278 | """)) 279 | 280 | engine = RenderEngine(MockApp(self.app_dir)) 281 | template = engine.get_template('tmpl.ks') 282 | 283 | self.assertTrue(isinstance(template, Template), 'template is not a Template') 284 | self.assertEquals(template.name, 'tmpl.ks', 'template name is wrong') 285 | self.assertTrue(template is engine.get_template('tmpl.ks'), 'templates are not identical') 286 | 287 | self.assertRaises(TemplateNotFound, engine.get_template, 'missing.ks') 288 | 289 | def test_refresh_on_file_modification(self): 290 | engine = RenderEngine(MockApp(self.app_dir)) 291 | filename = os.path.join(self.app_dir, 'tmpl.ks') 292 | 293 | changer = util.MtimeChanger() 294 | 295 | with changer.change_times(file(filename, 'w')) as fp: 296 | fp.write(dedent(""" 297 | # this is python 298 | ---- 299 | this is HTML 300 | """)) 301 | 302 | template1 = engine.get_template('tmpl.ks') 303 | 304 | with changer.change_times(file(filename, 'w')) as fp: 305 | fp.write(dedent(""" 306 | # this is python 307 | ---- 308 | this is HTML 309 | """)) 310 | 311 | template2 = engine.get_template('tmpl.ks') 312 | 313 | self.assertTrue(template1 is not template2, 'template should not be the same after file changes') 314 | 315 | def test_full_render(self): 316 | engine = RenderEngine(MockApp(self.app_dir)) 317 | filename = os.path.join(self.app_dir, 'tmpl.ks') 318 | 319 | with file(filename, 'w') as fp: 320 | fp.write(dedent(""" 321 | this is {{name}} 322 | """)) 323 | 324 | t = engine.get_template('tmpl.ks') 325 | output = '\n'.join(engine.render(t, {'name': 'HTML'})) 326 | 327 | self.assertEquals('this is HTML', output) 328 | 329 | def test_render_with_template_hierarchy(self): 330 | with file(os.path.join(self.app_dir, 'base.html'), 'w') as fp: 331 | fp.write(dedent(""" 332 | {% block main %} 333 | this is the base 334 | {% endblock %} 335 | this is also the base 336 | """)) 337 | 338 | with file(os.path.join(self.app_dir, 'tmpl.ks'), 'w') as fp: 339 | fp.write(dedent(""" 340 | {% extends "base.html" %} 341 | {% block main %} 342 | this is the child 343 | {% endblock %} 344 | """)) 345 | 346 | engine = RenderEngine(MockApp(self.app_dir)) 347 | t = engine.get_template('tmpl.ks') 348 | output = '\n'.join(engine.render(t, {})) 349 | 350 | self.assertEquals('\nthis is the child\n\n\nthis is also the base', output) 351 | 352 | with file(os.path.join(self.app_dir, 'tmpl2.ks'), 'w') as fp: 353 | fp.write(dedent(""" 354 | {% extends "_base.html" %} 355 | {% block main %} 356 | this is the child 357 | {% endblock %} 358 | """)) 359 | 360 | t = engine.get_template('tmpl2.ks') 361 | generator = engine.render(t, {}) 362 | self.assertRaises(TemplateNotFound, generator.next) 363 | del generator 364 | 365 | with file(os.path.join(self.app_dir, '_base.html'), 'w') as fp: 366 | fp.write(dedent(""" 367 | {% block main %} 368 | this is the base 369 | {% endblock %} 370 | this is the new base 371 | """)) 372 | 373 | t = engine.get_template('tmpl2.ks') 374 | output = '\n'.join(engine.render(t, {})) 375 | 376 | self.assertEquals('\nthis is the child\n\n\nthis is the new base', output) 377 | 378 | -------------------------------------------------------------------------------- /test/util.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2011, Daniel Crosta 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # * Redistributions of source code must retain the above copyright notice, 8 | # this list of conditions and the following disclaimer. 9 | # 10 | # * Redistributions in binary form must reproduce the above copyright notice, 11 | # this list of conditions and the following disclaimer in the documentation 12 | # and/or other materials provided with the distribution. 13 | # 14 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 17 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 18 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 19 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 20 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 21 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 22 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 23 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 24 | # POSSIBILITY OF SUCH DAMAGE. 25 | 26 | import contextlib 27 | import os 28 | import os.path 29 | import time 30 | import warnings 31 | 32 | class MtimeChanger(object): 33 | def __init__(self): 34 | self.inc = 1 35 | 36 | @contextlib.contextmanager 37 | def change_times(self, fileobj): 38 | """ 39 | Ensure that if the file existed before the context manager 40 | was invoked, that the mtime of the file after __exit__ 41 | has increased. 42 | """ 43 | global count 44 | 45 | filename = fileobj.name 46 | if os.path.isfile(filename): 47 | mtime = os.stat(filename).st_mtime 48 | else: 49 | mtime = 0 50 | 51 | yield fileobj 52 | fileobj.close() 53 | 54 | if (not mtime and os.path.isfile(filename)) or (mtime and mtime >= os.stat(filename).st_mtime): 55 | if callable(getattr(os, 'utime')): 56 | newtime = max(time.time(), mtime + self.inc) 57 | self.inc += 1 58 | os.utime(filename, (newtime, newtime)) 59 | else: 60 | time.sleep(1) 61 | fp = open(filename, 'a') 62 | fp.write('') 63 | fp.close() 64 | 65 | class WarningCatcher(object): 66 | """ 67 | Context manager like warnings.catch_warnings, but with a simpler API 68 | for testing (and for Python 2.5 compatibility). 69 | """ 70 | 71 | def __init__(self, *warnings_to_catch): 72 | self.warnings_to_catch = warnings_to_catch 73 | self.log = [] 74 | self.showwarning = None 75 | 76 | def __enter__(self): 77 | self.showwarning = warnings.showwarning 78 | def showwarning(*args, **kwargs): 79 | # args[1] is the class of the warning 80 | self.log.append(args[1]) 81 | warnings.showwarning = showwarning 82 | return self 83 | 84 | def __exit__(self, *exc_info): 85 | warnings.showwarning = self.showwarning 86 | 87 | def has_warning(self, warning_cls, count=1): 88 | count_of_type = sum(1 for w in self.log if w == warning_cls) 89 | return count_of_type == count 90 | 91 | --------------------------------------------------------------------------------