├── .gitignore ├── .travis.yml ├── AUTHORS ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── cactus ├── __init__.py ├── bootstrap │ ├── __init__.py │ ├── archive.py │ └── package.py ├── browser.py ├── cli.py ├── compat │ ├── __init__.py │ ├── page.py │ └── paths.py ├── config │ ├── __init__.py │ ├── fallback.py │ ├── file.py │ └── router.py ├── contrib │ ├── __init__.py │ └── external │ │ ├── __init__.py │ │ ├── closure.py │ │ └── yui.py ├── deployment │ ├── __init__.py │ ├── auth.py │ ├── cloudfiles │ │ ├── __init__.py │ │ ├── auth.py │ │ ├── engine.py │ │ └── file.py │ ├── engine.py │ ├── file.py │ ├── gcs │ │ ├── __init__.py │ │ ├── auth.py │ │ ├── engine.py │ │ └── file.py │ └── s3 │ │ ├── __init__.py │ │ ├── auth.py │ │ ├── domain.py │ │ ├── engine.py │ │ └── file.py ├── exceptions.py ├── i18n │ ├── __init__.py │ └── commands.py ├── listener │ ├── __init__.py │ ├── mac.py │ └── polling.py ├── logger.py ├── mime.py ├── page.py ├── plugin │ ├── __init__.py │ ├── builtin │ │ ├── __init__.py │ │ ├── cache.py │ │ ├── context.py │ │ └── ignore.py │ ├── defaults.py │ ├── loader.py │ └── manager.py ├── server.py ├── site.py ├── skeleton │ ├── config.json │ ├── locale │ │ └── README.md │ ├── pages │ │ ├── about.html │ │ ├── contact.html │ │ ├── error.html │ │ ├── index.html │ │ ├── robots.txt │ │ └── sitemap.xml │ ├── plugins │ │ ├── __init__.py │ │ ├── blog.disabled.py │ │ ├── coffeescript.disabled.py │ │ ├── gitcommitid.disabled.py │ │ ├── haml.disabled.py │ │ ├── page_context.py │ │ ├── sass.disabled.py │ │ ├── scss.disabled.py │ │ ├── sprites.disabled.py │ │ └── static_optimizers.py │ ├── static │ │ ├── css │ │ │ ├── bootstrap-theme.css │ │ │ ├── bootstrap.css │ │ │ └── main.css │ │ ├── fonts │ │ │ ├── glyphicons-halflings-regular.eot │ │ │ ├── glyphicons-halflings-regular.svg │ │ │ ├── glyphicons-halflings-regular.ttf │ │ │ └── glyphicons-halflings-regular.woff │ │ ├── images │ │ │ └── favicon.ico │ │ └── js │ │ │ ├── main.js │ │ │ └── vendor │ │ │ ├── bootstrap.js │ │ │ ├── jquery-1.10.1.js │ │ │ └── modernizr-2.6.2.js │ └── templates │ │ └── base.html ├── static │ ├── __init__.py │ └── external │ │ ├── __init__.py │ │ ├── exceptions.py │ │ └── manager.py ├── template_tags.py ├── tests │ ├── __init__.py │ ├── compat.py │ ├── data │ │ ├── koenpage-in.html │ │ ├── koenpage-out.html │ │ ├── plugins │ │ │ ├── empty.py │ │ │ └── test.py │ │ ├── skeleton │ │ │ ├── locale │ │ │ │ └── README.md │ │ │ ├── pages │ │ │ │ ├── error.html │ │ │ │ ├── index.html │ │ │ │ ├── robots.txt │ │ │ │ └── sitemap.xml │ │ │ ├── plugins │ │ │ │ ├── __init__.py │ │ │ │ ├── blog.disabled.py │ │ │ │ ├── coffeescript.disabled.py │ │ │ │ ├── haml.disabled.py │ │ │ │ ├── page_context.py │ │ │ │ ├── sass.disabled.py │ │ │ │ ├── scss.disabled..py │ │ │ │ ├── sprites.disabled.py │ │ │ │ ├── static_optimizers.py │ │ │ │ └── version.py │ │ │ ├── static │ │ │ │ ├── css │ │ │ │ │ └── style.css │ │ │ │ ├── images │ │ │ │ │ └── favicon.ico │ │ │ │ └── js │ │ │ │ │ └── main.js │ │ │ └── templates │ │ │ │ └── base.html │ │ ├── test-in.html │ │ └── test-out.html │ ├── deployment │ │ ├── __init__.py │ │ ├── test_bucket_create.py │ │ ├── test_bucket_name.py │ │ ├── test_engine_api.py │ │ └── test_file.py │ ├── integration │ │ ├── __init__.py │ │ └── s3 │ │ │ ├── __init__.py │ │ │ ├── data │ │ │ ├── buckets.xml │ │ │ └── location.xml │ │ │ ├── test_bucket.py │ │ │ ├── test_deploy.py │ │ │ └── test_workflow.py │ ├── test_basic.py │ ├── test_bootstrap.py │ ├── test_build.py │ ├── test_cli.py │ ├── test_compat.py │ ├── test_config.py │ ├── test_context.py │ ├── test_credentials.py │ ├── test_deploy.py │ ├── test_external.py │ ├── test_fingerprint.py │ ├── test_ignore.py │ ├── test_legacy_context.py │ ├── test_listener.py │ ├── test_mime.py │ ├── test_plugins.py │ ├── test_template_tags.py │ ├── test_ui.py │ └── test_urls.py ├── ui.py └── utils │ ├── __init__.py │ ├── file.py │ ├── filesystem.py │ ├── helpers.py │ ├── internal.py │ ├── ipc.py │ ├── network.py │ ├── packaging.py │ ├── parallel.py │ ├── sync.py │ └── url.py ├── requirements.2.6.txt ├── requirements.2.7.txt ├── requirements.3.txt ├── requirements.txt ├── run.py ├── setup.cfg ├── setup.py ├── test_requirements.txt ├── todo.txt └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | dist 4 | build 5 | skeleton.tar.gz 6 | Cactus.egg-info 7 | examples/blog/config.json 8 | *.egg 9 | .tox 10 | TestProject 11 | .eggs 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - "2.6" 5 | - "2.7" 6 | - "3.4" 7 | - "3.5" 8 | - "3.6" 9 | - "3.6-dev" 10 | - "3.7-dev" 11 | - "nightly" 12 | install: 13 | - pip install . 14 | - pip install -r test_requirements.txt 15 | script: 16 | - nosetests 17 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Benjamin Estes 2 | Benjamin Petersen 3 | Emmanuel Ackaouy 4 | Gary Robertson 5 | Jason Bonta 6 | Jeffrey Paul 7 | Jochen Brissier 8 | Joe Germuska 9 | Jon Lønne 10 | Klaas Pieter Annema 11 | Koen Bok 12 | Ross McFarland 13 | Ryan Bagwell 14 | Thomas Bartels 15 | Thomas Orozco 16 | Todd Kennedy 17 | Todd Kennedy 18 | Tom Kruijsen 19 | Tung Dao 20 | jochen brissier 21 | pjv 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Koen Bok. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, 4 | are permitted provided that the following conditions are met: 5 | 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | 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 | * Neither the name of Koen Bok nor the names of any contributors may 15 | be used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 20 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 21 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 22 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 23 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 24 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 25 | TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 26 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 27 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 28 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 29 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE README.md requirements.txt test_requirements.txt 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | python setup.py install 3 | make clean 4 | 5 | uninstall: 6 | python setup.py uninstall 7 | 8 | reinstall: 9 | make uninstall 10 | make install 11 | 12 | clean: 13 | rm -Rf build Cactus.egg-info dist 14 | 15 | test: 16 | tox 17 | 18 | testw: 19 | watchmedo shell-command \ 20 | --patterns="*.py;*.txt" \ 21 | --recursive \ 22 | --command='nosetests -x -s --logging-level=CRITICAL --processes=4' \ 23 | . 24 | 25 | alltests: 26 | make clean 27 | make uninstall 28 | make install 29 | make test 30 | 31 | submit: 32 | python setup.py register 33 | 34 | .PHONY: test -------------------------------------------------------------------------------- /cactus/__init__.py: -------------------------------------------------------------------------------- 1 | # Python 3.5 Django compatibility fix: 2 | # + https://github.com/django/django/commit/b07aa52e8a8e4c7fdc7265f75ce2e7992e657ae9) 3 | # + https://code.djangoproject.com/ticket/23763 4 | import six 5 | 6 | if six.PY3: 7 | import html.parser as _html_parser 8 | 9 | try: 10 | HTMLParseError = _html_parser.HTMLParseError 11 | except AttributeError: 12 | # create a dummy class for Python 3.5+ where it's been removed 13 | class HTMLParseError(Exception): 14 | pass 15 | 16 | _html_parser.HTMLParseError = HTMLParseError 17 | 18 | -------------------------------------------------------------------------------- /cactus/bootstrap/__init__.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import logging 3 | 4 | from cactus.bootstrap.archive import bootstrap_from_archive 5 | from cactus.bootstrap.package import bootstrap_from_package 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def bootstrap(path, skeleton=None): 12 | """ 13 | Bootstrap a new project at a given path. 14 | 15 | :param path: The location where the new project should be created. 16 | :param skeleton: An optional path to an archive that should be used instead of the standard cactus skeleton. 17 | """ 18 | 19 | if skeleton is None: 20 | bootstrap_from_package(path) 21 | else: 22 | bootstrap_from_archive(path, skeleton) 23 | 24 | logger.info('New project generated at %s', path) 25 | -------------------------------------------------------------------------------- /cactus/bootstrap/archive.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | import shutil 4 | import tarfile 5 | import zipfile 6 | 7 | from six.moves import urllib 8 | 9 | 10 | class Folder(object): 11 | def __init__(self, from_path): 12 | self.from_path = from_path 13 | 14 | def extractall(self, path): 15 | os.rmdir(path) 16 | shutil.copytree(self.from_path, path) 17 | 18 | def close(self): 19 | pass 20 | 21 | 22 | def open_zipfile(archive): 23 | return zipfile.ZipFile(archive) 24 | 25 | 26 | def open_tarfile(archive): 27 | return tarfile.open(name=archive, mode='r') 28 | 29 | 30 | SUPPORTED_ARCHIVES = [ 31 | (open_tarfile, tarfile.is_tarfile), 32 | (open_zipfile, zipfile.is_zipfile), 33 | (Folder, os.path.isdir), 34 | ] 35 | 36 | 37 | def bootstrap_from_archive(path, skeleton): 38 | if os.path.isfile(skeleton) or os.path.isdir(skeleton): 39 | # Is is a local file? 40 | skeleton_file = skeleton 41 | else: 42 | # Assume it's an URL 43 | skeleton_file, headers = urllib.request.urlretrieve(skeleton) 44 | 45 | for opener, test in SUPPORTED_ARCHIVES: 46 | try: 47 | if test(skeleton_file): 48 | archive = opener(skeleton_file) 49 | break 50 | except IOError: 51 | pass 52 | else: 53 | raise Exception("Unsupported skeleton file type. Only .tar and .zip are supported at this time.") 54 | 55 | os.mkdir(path) 56 | archive.extractall(path=path) 57 | archive.close() 58 | -------------------------------------------------------------------------------- /cactus/bootstrap/package.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | import posixpath #TODO: Windows? 4 | import logging 5 | import pkg_resources 6 | 7 | from cactus.utils.packaging import pkg_walk 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def bootstrap_from_package(path): 14 | for dir_, sub_dirs, filenames in pkg_walk("cactus", "skeleton"): 15 | base_path = os.path.join(path, dir_.split('skeleton', 1)[1].lstrip('/')) 16 | 17 | for sub_dir in sub_dirs: 18 | dir_path = os.path.join(base_path, sub_dir) 19 | logger.debug("Creating {0}".format(dir_path)) 20 | os.makedirs(dir_path) 21 | 22 | for filename in filenames: 23 | resource_path = posixpath.join(dir_, filename) 24 | file_path = os.path.join(base_path, filename) 25 | 26 | logger.debug("Copying {0} to {1}".format(resource_path, file_path)) 27 | with open(file_path, 'wb') as f: 28 | f.write(pkg_resources.resource_stream("cactus", resource_path).read()) 29 | -------------------------------------------------------------------------------- /cactus/browser.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import platform 3 | 4 | s1 = """ 5 | set hostLists to %s 6 | tell application "Google Chrome" 7 | set windowsList to windows as list 8 | repeat with currWindow in windowsList 9 | set tabsList to currWindow's tabs as list 10 | repeat with currTab in tabsList 11 | repeat with currentHost in hostLists 12 | if currentHost is in currTab's URL then execute currTab javascript "%s" 13 | end repeat 14 | end repeat 15 | end repeat 16 | end tell 17 | """ 18 | 19 | s2 = """ 20 | set hostLists to %s 21 | tell application "Safari" 22 | if (count of windows) is greater than 0 then 23 | set windowsList to windows as list 24 | repeat with currWindow in windowsList 25 | set tabsList to currWindow's tabs as list 26 | repeat with currTab in tabsList 27 | repeat with currentHost in hostLists 28 | if currentHost is in currTab's URL then 29 | tell currTab to do JavaScript "%s" 30 | end if 31 | end repeat 32 | end repeat 33 | end repeat 34 | end if 35 | end tell 36 | """ 37 | 38 | s3 = """ 39 | window.location.reload() 40 | """ 41 | 42 | s4 = """ 43 | (function() { 44 | function updateQueryStringParameter(uri, key, value) { 45 | 46 | var re = new RegExp('([?|&])' + key + '=.*?(&|$)', 'i'); 47 | separator = uri.indexOf('?') !== -1 ? '&' : '?'; 48 | 49 | if (uri.match(re)) { 50 | return uri.replace(re, '$1' + separator + key + '=' + value + '$2'); 51 | } else { 52 | return uri + separator + key + '=' + value; 53 | } 54 | } 55 | 56 | var links = document.getElementsByTagName('link'); 57 | 58 | for (var i = 0; i < links.length;i++) { 59 | 60 | var link = links[i]; 61 | 62 | if (link.rel === 'stylesheet') { 63 | 64 | // Don't reload external urls, they likely did not change 65 | if ( 66 | link.href.indexOf('127.0.0.1') == -1 && 67 | link.href.indexOf('localhost') == -1 && 68 | link.href.indexOf('0.0.0.0') == -1) { 69 | continue; 70 | } 71 | 72 | var updatedLink = updateQueryStringParameter(link.href, 'cactus.reload', new Date().getTime()); 73 | 74 | // This is really hacky, but needed because the regex gets magically broken by piping it 75 | // through applescript. This replaces the first occurence of ? with & if there was no &. 76 | if (updatedLink.indexOf('?') == -1) { 77 | updatedLink = updatedLink.replace('&', '?'); 78 | } 79 | 80 | link.href = updatedLink; 81 | } 82 | } 83 | })() 84 | """ 85 | 86 | def applescript(input): 87 | 88 | # Bail if we're not on mac os for now 89 | if platform.system() != "Darwin": 90 | return 91 | 92 | command = "osascript< {"a", "b"} 111 | urlMatch = "{\"" + "\",\"".join(urlMatch) + "\"}" 112 | 113 | if apps['Google Chrome']: 114 | applescript(s1 % (urlMatch, js)) 115 | 116 | if apps['Safari']: 117 | applescript(s2 % (urlMatch, js)) 118 | 119 | def browserReload(url): 120 | _insertJavascript(url, s3) 121 | 122 | def browserReloadCSS(url): 123 | _insertJavascript(url, s4) 124 | 125 | def appsRunning(l): 126 | psdata = subprocess.check_output(['ps aux'], shell=True) 127 | retval = {} 128 | for app in l: retval[app] = app in psdata 129 | return retval 130 | -------------------------------------------------------------------------------- /cactus/compat/__init__.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | -------------------------------------------------------------------------------- /cactus/compat/page.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | import logging 4 | 5 | from cactus.utils.url import path_to_url 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class PageContextCompatibilityPlugin(object): 12 | """ 13 | This plugin ensures that the page context stays backwards compatible, but adds 14 | deprecation warnings. 15 | """ 16 | def preBuildPage(self, page, context, data): 17 | prefix = os.path.relpath(".", os.path.dirname(page.build_path)) 18 | 19 | def static_url(): 20 | logger.warn("%s:", page.path) 21 | logger.warn("{{ STATIC_URL }} is deprecated, use {% static '/static/path/to/file' %} instead.") 22 | return path_to_url(os.path.join(prefix, 'static')) 23 | 24 | def root_url(): 25 | logger.warn("%s:", page.path) 26 | logger.warn("{{ ROOT_URL }} is deprecated, use {% url '/page.html' %} instead.") 27 | return path_to_url(prefix) 28 | 29 | def page_url(): 30 | logger.warn("%s:", page.path) 31 | logger.warn("{{ PAGE_URL }} is deprecated, use {% current_page %} instead") 32 | return page.final_url[1:] # We don't want the leading slash (backwards compatibility) 33 | 34 | context.update({ 35 | "STATIC_URL": static_url, 36 | "ROOT_URL": root_url, 37 | "PAGE_URL": page_url, 38 | }) 39 | 40 | return context, data 41 | -------------------------------------------------------------------------------- /cactus/compat/paths.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | 3 | class VirtualPaths(object): 4 | def __init__(self, obj, mapping): 5 | self.obj = obj 6 | self.mapping = mapping 7 | 8 | def __getitem__(self, item): 9 | 10 | return getattr(self.obj, self.mapping[item]) 11 | 12 | 13 | class CompatibilityLayer(object): 14 | """ 15 | Ensure backwards compatibility with older versions of Cactus. 16 | """ 17 | mapping = {} 18 | 19 | @property 20 | def paths(self): 21 | return VirtualPaths(self, self.mapping) 22 | 23 | 24 | class SiteCompatibilityLayer(CompatibilityLayer): 25 | mapping = { 26 | 'build': 'build_path', 27 | 'pages': 'page_path', 28 | 'plugins': 'plugin_path', 29 | 'templates': 'template_path', 30 | 'static': 'static_path', 31 | 'script': 'script_path', 32 | } 33 | 34 | 35 | class PageCompatibilityLayer(CompatibilityLayer): 36 | mapping = { 37 | 'full': 'full_source_path', 38 | 'full-build': 'full_build_path', 39 | } 40 | 41 | @property 42 | def path(self): 43 | return self.source_path 44 | 45 | 46 | class StaticCompatibilityLayer(CompatibilityLayer): 47 | mapping = { 48 | 'full': 'full_source_path', 49 | 'full-build': 'full_build_path', 50 | } 51 | -------------------------------------------------------------------------------- /cactus/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eudicots/Cactus/b6dc9fb92248e1fd7fb6f44b57b8835802e9d880/cactus/config/__init__.py -------------------------------------------------------------------------------- /cactus/config/fallback.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import logging 3 | 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | class ConfigFallback(object): 9 | """ 10 | A transient fallback config. 11 | This config does not store anything to the filesystem. 12 | """ 13 | def __init__(self): 14 | self.cnf = {} 15 | 16 | @property 17 | def path(self): 18 | return "@fallback" 19 | 20 | def get(self, key, default=None): 21 | return self.cnf.get(key, default) 22 | 23 | def set(self, key, value): 24 | self.cnf[key] = value 25 | 26 | def has_key(self, key): 27 | return key in self.cnf 28 | 29 | def write(self): 30 | if self.cnf: 31 | logger.warning("Using config fallback, discarding config values: [%s]", ', '.join(self.cnf.keys())) 32 | -------------------------------------------------------------------------------- /cactus/config/file.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import json 3 | import logging 4 | 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class ConfigFile(object): 10 | """ 11 | A single config file, as present on the filesystem. 12 | """ 13 | _dirty = None 14 | 15 | def __init__(self, path): 16 | self.path = path 17 | self.load() 18 | 19 | def get(self, key, default=None): 20 | return self._data.get(key, default) 21 | 22 | def set(self, key, value): 23 | self._data[key] = value 24 | self._dirty = True 25 | 26 | def has_key(self, key): 27 | return key in self._data 28 | 29 | def load(self): 30 | self._data = {} 31 | 32 | try: 33 | self._data = json.load(open(self.path, "rU")) 34 | self._dirty = False 35 | except IOError: 36 | logger.warning("Unable to load configuration at '{0}'. No file found.".format(self.path)) 37 | except ValueError as e: 38 | logger.error("Unable to load configuration at '{0}'. Invalid JSON caused by: {1}".format(self.path, e)) 39 | except Exception as e: 40 | logger.exception("Unable to load configuration at '{0}'.".format(self.path)) 41 | 42 | def write(self): 43 | if self._dirty: 44 | json.dump(self._data, open(self.path, "w"), sort_keys=True, indent=4, separators=(",", ": ")) 45 | self._dirty = False 46 | logger.debug("Saved configuration at {0}".format(self.path)) 47 | -------------------------------------------------------------------------------- /cactus/config/router.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import logging 3 | 4 | from cactus.config.fallback import ConfigFallback 5 | from cactus.config.file import ConfigFile 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class ConfigRouter(object): 12 | """ 13 | A router to manage a series of config files. 14 | """ 15 | 16 | def __init__(self, paths): 17 | """ 18 | Load all the config files passed. 19 | Make sure not to load the same one twice 20 | """ 21 | self.configs = [] 22 | 23 | loaded_paths = set() 24 | for path in paths: 25 | if path not in loaded_paths: 26 | self.configs.append(ConfigFile(path)) 27 | loaded_paths.add(path) 28 | 29 | self.configs.append(ConfigFallback()) 30 | 31 | logger.debug("Loaded configs: %s", ', '.join([config.path for config in self.configs])) 32 | 33 | 34 | def _get_nested(self, key, default=None): 35 | assert isinstance(default, dict) # Don't shoot yourself in the foot. 36 | 37 | output = {} 38 | for config in reversed(self.configs): 39 | output.update(config.get(key, default)) 40 | logger.debug("Retrieving %s from %s", key, config.path) 41 | 42 | return output 43 | 44 | def _get_first(self, key, default=None): 45 | for config in self.configs: 46 | if config.has_key(key): 47 | logger.debug("Retrieved %s from %s", key, config.path) 48 | return config.get(key) 49 | 50 | return default 51 | 52 | def get(self, key, default=None, nested=False): #TODO: Mutable default.. copy? 53 | """ 54 | Retrieve a config key from the first config that has it. 55 | Return default if no config has it. 56 | """ 57 | logger.debug("Searching for %s (nested:%s)", key, nested) 58 | if nested: 59 | return self._get_nested(key, default) 60 | else: 61 | return self._get_first(key, default) 62 | 63 | 64 | def set(self, key, value): 65 | """ 66 | Write a config key from the first config that has it. 67 | 68 | If none do, write it to the first one. 69 | """ 70 | assert self.configs # There should always be at least a fallback config 71 | 72 | write_to = None 73 | 74 | for config in self.configs: 75 | if config.has_key(key): 76 | write_to = config 77 | if write_to is None: 78 | write_to = self.configs[0] 79 | 80 | write_to.set(key, value) 81 | logger.debug("Set %s in %s", key, write_to.path) 82 | 83 | def write(self): 84 | """ 85 | Write the config files to the filesystem. 86 | """ 87 | for config in self.configs: 88 | config.write() 89 | -------------------------------------------------------------------------------- /cactus/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eudicots/Cactus/b6dc9fb92248e1fd7fb6f44b57b8835802e9d880/cactus/contrib/__init__.py -------------------------------------------------------------------------------- /cactus/contrib/external/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eudicots/Cactus/b6dc9fb92248e1fd7fb6f44b57b8835802e9d880/cactus/contrib/external/__init__.py -------------------------------------------------------------------------------- /cactus/contrib/external/closure.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import subprocess 3 | from cactus.static.external import External 4 | 5 | 6 | class ClosureJSOptimizer(External): 7 | supported_extensions = ('js',) 8 | output_extension = 'js' 9 | 10 | def _run(self): 11 | subprocess.call([ 12 | 'closure-compiler', 13 | '--js', self.src, 14 | '--js_output_file', self.dst, 15 | '--compilation_level', 'SIMPLE_OPTIMIZATIONS' 16 | ]) 17 | -------------------------------------------------------------------------------- /cactus/contrib/external/yui.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import subprocess 3 | from cactus.static.external import External 4 | 5 | 6 | class YUIJSOptimizer(External): 7 | supported_extensions = ('js',) 8 | output_extension = 'js' 9 | 10 | def _run(self): 11 | subprocess.call([ 12 | 'yuicompressor', 13 | '--type', 'js', 14 | '-o', self.dst, 15 | self.src, 16 | ]) 17 | 18 | 19 | class YUICSSOptimizer(External): 20 | supported_extensions = ('css',) 21 | output_extension = 'css' 22 | 23 | def _run(self): 24 | subprocess.call([ 25 | 'yuicompressor', 26 | '--type', 'css', 27 | '-o', self.dst, 28 | self.src, 29 | ]) 30 | -------------------------------------------------------------------------------- /cactus/deployment/__init__.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import logging 3 | 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | PROVIDER_MAPPING = { 9 | "rackspace": "cactus.deployment.cloudfiles.engine.CloudFilesDeploymentEngine", 10 | "google": "cactus.deployment.gcs.engine.GCSDeploymentEngine", 11 | "aws": "cactus.deployment.s3.engine.S3DeploymentEngine", 12 | } 13 | 14 | 15 | def get_deployment_engine_class(provider): 16 | """ 17 | Import an engine by name 18 | :provider: The provider you want to deploy to 19 | :type provider: str 20 | :returns: The deployment Engine 21 | :rtype: cactus.deployment.engine.BaseDeploymentEngine 22 | """ 23 | engine_path = PROVIDER_MAPPING.get(provider, None) 24 | logger.debug("Loading Deployment Engine for %s: %s", provider, engine_path) 25 | 26 | if engine_path is None: 27 | return None 28 | 29 | module, engine = engine_path.rsplit(".", 1) 30 | 31 | try: 32 | _mod = __import__(module, fromlist=[engine]) 33 | except ImportError as e: 34 | logger.error("Unable to import requested engine (%s) for provider %s", engine, provider) 35 | logger.error("A required library was missing: %s", e.message) 36 | logger.error("Please install the library and try again") 37 | else: 38 | return getattr(_mod, engine) 39 | -------------------------------------------------------------------------------- /cactus/deployment/auth.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | import getpass 4 | import keyring 5 | 6 | is_desktop_app = os.environ.get("DESKTOPAPP", None) not in ["", None] 7 | 8 | def get_password(service, account): 9 | 10 | # Because we cannot use keychain from a sandboxed app environment we check if the password 11 | # was passed by the app into the env. 12 | if is_desktop_app: 13 | return os.environ.get("SECRET_KEY", None) 14 | 15 | return keyring.get_password(service, account) 16 | 17 | def set_password(service, account, password): 18 | 19 | if is_desktop_app: 20 | return 21 | 22 | keyring.set_password(service, account, password) 23 | 24 | 25 | class BaseKeyringCredentialsManager(object): 26 | _username_config_entry = "username" 27 | _keyring_service = "cactus" 28 | 29 | _username_display_name = "Username" 30 | _password_display_name = "Password" 31 | 32 | def __init__(self, engine): 33 | self.engine = engine #TODO: Don't we want only UI and config? 34 | self.username = None 35 | self.password = None 36 | 37 | def get_credentials(self): 38 | self.username = self.engine.site.config.get(self._username_config_entry) 39 | if self.username is None: 40 | self.username = self.engine.site.ui.prompt("Enter your {0}".format(self._username_display_name)) 41 | 42 | self.password = get_password(self._keyring_service, self.username) 43 | 44 | if self.password is None: 45 | self.password = self.engine.site.ui.prompt("Enter your {0} (will not be echoed)".format(self._password_display_name), 46 | prompt_fn=getpass.getpass) 47 | 48 | return self.username, self.password 49 | 50 | def save_credentials(self): 51 | assert self.username is not None, "You did not set {0}".format(self._username_display_name) 52 | assert self.password is not None, "You did not set {0}".format(self._password_display_name) 53 | 54 | self.engine.site.config.set(self._username_config_entry, self.username) 55 | self.engine.site.config.write() 56 | 57 | set_password(self._keyring_service, self.username, self.password) 58 | -------------------------------------------------------------------------------- /cactus/deployment/cloudfiles/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eudicots/Cactus/b6dc9fb92248e1fd7fb6f44b57b8835802e9d880/cactus/deployment/cloudfiles/__init__.py -------------------------------------------------------------------------------- /cactus/deployment/cloudfiles/auth.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | from cactus.deployment.auth import BaseKeyringCredentialsManager 3 | 4 | 5 | class CloudFilesCredentialsManager(BaseKeyringCredentialsManager): 6 | _username_config_entry = "cloudfiles-username" 7 | _password_display_name = "API Key" 8 | _keyring_service = "cactus/cloudfiles" 9 | -------------------------------------------------------------------------------- /cactus/deployment/cloudfiles/engine.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | 3 | import pyrax 4 | 5 | from cactus.deployment.engine import BaseDeploymentEngine 6 | from cactus.deployment.cloudfiles.auth import CloudFilesCredentialsManager 7 | from cactus.deployment.cloudfiles.file import CloudFilesFile 8 | 9 | 10 | class CloudFilesDeploymentEngine(BaseDeploymentEngine): 11 | CredentialsManagerClass = CloudFilesCredentialsManager 12 | FileClass = CloudFilesFile 13 | 14 | config_bucket_name = "cloudfiles-bucket-name" 15 | config_bucket_website = "cloudfiles-bucket-website" 16 | 17 | def _create_connection(self): 18 | username, api_key = self.credentials_manager.get_credentials() 19 | pyrax.set_setting("identity_type", "rackspace") 20 | pyrax.set_credentials(username, api_key) 21 | return pyrax.connect_to_cloudfiles() 22 | 23 | def get_bucket(self): 24 | try: 25 | return self.get_connection().get_container(self.bucket_name) 26 | except pyrax.exceptions.NoSuchContainer: 27 | return None 28 | 29 | def create_bucket(self): 30 | #TODO: Handle errors 31 | conn = self.get_connection() 32 | container = conn.create_container(self.bucket_name) 33 | container.set_web_index_page(self._index_page) 34 | container.set_web_error_page(self._error_page) 35 | container.make_public() 36 | return container 37 | 38 | def get_website_endpoint(self): 39 | return self.bucket.cdn_uri 40 | -------------------------------------------------------------------------------- /cactus/deployment/cloudfiles/file.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | 3 | from cactus.deployment.file import BaseFile 4 | from cactus.utils.helpers import CaseInsensitiveDict 5 | 6 | 7 | class CloudFilesFile(BaseFile): 8 | def remote_changed(self): 9 | obj = self.engine.bucket.get_object(self.url) 10 | #TODO: Headers 11 | return obj.etag != self.payload_checksum 12 | 13 | def get_headers(self): 14 | headers = CaseInsensitiveDict() 15 | for k in ("Cache-Control", "X-TTL"): 16 | headers[k] = 'max-age={0}'.format(self.cache_control) 17 | if self.content_encoding is not None: 18 | headers['Content-Encoding'] = self.content_encoding 19 | return headers 20 | 21 | def do_upload(self): 22 | obj = self.engine.bucket.store_object(self.url, self.payload(), content_type=self.content_type, 23 | etag=self.payload_checksum, content_encoding=self.content_encoding,) 24 | obj.set_metadata(self.get_headers()) 25 | -------------------------------------------------------------------------------- /cactus/deployment/engine.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | import logging 4 | 5 | from cactus.deployment.file import BaseFile 6 | from cactus.utils.filesystem import fileList 7 | from cactus.utils.helpers import get_or_prompt, memoize, map_apply 8 | from cactus.utils.parallel import multiMap, PARALLEL_DISABLED 9 | 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class BaseDeploymentEngine(object): 15 | FileClass = BaseFile 16 | CredentialsManagerClass = None #TODO: Define interface? 17 | 18 | config_bucket_name = None 19 | config_bucket_website = None 20 | 21 | _index_page = "index.html" 22 | _error_page = "error.html" 23 | 24 | _connection = None 25 | 26 | def __init__(self, site): 27 | """ 28 | :param site: An instance of cactus.site.Site 29 | """ 30 | self.site = site 31 | self.credentials_manager = self.CredentialsManagerClass(self) 32 | 33 | def deploy(self): 34 | self.configure() 35 | 36 | # Upload all files concurrently in a thread pool 37 | mapper = multiMap if self.site._parallel > PARALLEL_DISABLED else map_apply 38 | totalFiles = mapper(lambda p: p.upload(), self.files()) 39 | 40 | return totalFiles 41 | 42 | def _ignore_file(self, path): 43 | 44 | if os.path.basename(path).startswith("."): 45 | return True 46 | 47 | # Special case for Finder Icon files 48 | if "\r" in os.path.basename(path): 49 | return True 50 | 51 | return False 52 | 53 | 54 | @memoize 55 | def files(self): 56 | """ 57 | List of build files. 58 | """ 59 | return [self.FileClass(self, file_path) for file_path in fileList( 60 | self.site.build_path, relative=True) if self._ignore_file(file_path) is False] 61 | 62 | def total_bytes(self): 63 | """ 64 | Total size of files to be uploaded 65 | """ 66 | return sum([f.total_bytes for f in self.files()]) 67 | 68 | def total_bytes_uploaded(self): 69 | """ 70 | Total size of files to be uploaded 71 | """ 72 | return sum([f.total_bytes_uploaded for f in self.files()]) 73 | 74 | def progress(self): 75 | """ 76 | Progress of upload in percentage 77 | """ 78 | total_bytes = float(self.total_bytes()) 79 | total_bytes_uploaded = float(self.total_bytes_uploaded()) 80 | 81 | if total_bytes == 0 or total_bytes_uploaded == 0: 82 | return 0.0 83 | 84 | return total_bytes_uploaded / total_bytes 85 | 86 | 87 | def get_connection(self): 88 | if self._connection is None: 89 | self._connection = self._create_connection() 90 | return self._connection 91 | 92 | def _create_connection(self): 93 | """ 94 | Should return a Connection object 95 | """ 96 | raise NotImplementedError() 97 | 98 | def get_bucket(self): 99 | """ 100 | Should return a Bucket object, None if the bucket does not exist. 101 | """ 102 | raise NotImplementedError() 103 | 104 | def create_bucket(self): 105 | """ 106 | Should create and return a Bucket object. 107 | """ 108 | raise NotImplementedError() 109 | 110 | def get_website_endpoint(self): 111 | """ 112 | Should return the Website endpoint for the bucket. 113 | """ 114 | #TODO: Normalize -- rackspace gives an URL, but Amazon gives a domain name 115 | raise NotImplementedError() 116 | 117 | def configure(self): 118 | """ 119 | This is when the DeploymentEngine should configure itself to prepare for deployment 120 | 121 | :rtype: None 122 | """ 123 | self.bucket_name = get_or_prompt(self.site.config, self.config_bucket_name, self.site.ui.prompt_normalized, 124 | "Enter the bucket name (e.g.: www.example.com)") 125 | #TODO: Validate this is not empty 126 | 127 | self.bucket = self.get_bucket() #TODO: Catch auth errors 128 | 129 | #TODO: Make this all integrated and consistent! 130 | created = False 131 | if self.bucket is None: 132 | if self.site.ui.prompt_yes_no("Bucket does not exist. Create it?"): 133 | self.bucket = self.create_bucket() 134 | created = True 135 | else: 136 | return 137 | 138 | website_endpoint = self.get_website_endpoint() 139 | self.site.config.set(self.config_bucket_website, website_endpoint) 140 | 141 | self.site.config.write() 142 | self.credentials_manager.save_credentials() 143 | 144 | if created: 145 | logger.info('Bucket %s was created with website endpoint %s', self.bucket_name, website_endpoint) 146 | 147 | logger.info("Bucket Name: %s", self.bucket_name) 148 | logger.info("Bucket Web Endpoint: %s", website_endpoint) 149 | -------------------------------------------------------------------------------- /cactus/deployment/gcs/__init__.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | 3 | CACTUS_CLIENT_ID = "985227088845.apps.googleusercontent.com" 4 | CACTUS_CLIENT_SECRET = "6PzihpbiC33TW-GIagIRH0t_" 5 | CACTUS_REQUIRED_SCOPE = "https://www.googleapis.com/auth/devstorage.full_control" 6 | LOCAL_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob" 7 | -------------------------------------------------------------------------------- /cactus/deployment/gcs/auth.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import webbrowser 3 | 4 | from oauth2client.client import OAuth2WebServerFlow, FlowExchangeError 5 | from oauth2client.contrib.keyring_storage import Storage 6 | 7 | from cactus.deployment.gcs import CACTUS_CLIENT_ID, CACTUS_CLIENT_SECRET, CACTUS_REQUIRED_SCOPE, LOCAL_REDIRECT_URI 8 | from cactus.exceptions import InvalidCredentials 9 | 10 | 11 | class GCSCredentialsManager(object): 12 | def __init__(self, engine): 13 | self.engine = engine #TODO: Only pass those things that are needed? 14 | self.credentials = None 15 | 16 | def get_storage(self): 17 | return Storage("cactus/gcs", self.engine.bucket_name) #TODO: Not a great key, but do we want to ask for email? 18 | 19 | def get_credentials(self): 20 | 21 | if self.credentials is not None: 22 | return self.credentials 23 | 24 | self.credentials = self.get_storage().get() 25 | 26 | if self.credentials is None: 27 | flow = OAuth2WebServerFlow( 28 | client_id=CACTUS_CLIENT_ID, 29 | client_secret=CACTUS_CLIENT_SECRET, 30 | scope=CACTUS_REQUIRED_SCOPE, 31 | redirect_uri=LOCAL_REDIRECT_URI 32 | ) 33 | 34 | auth_uri = flow.step1_get_authorize_url() 35 | webbrowser.open(auth_uri) #TODO: Actually print the URL... 36 | code = self.engine.site.ui.prompt('Please enter the authorization code') 37 | 38 | try: 39 | self.credentials = flow.step2_exchange(code) #TODO: Catch invalid grant 40 | except FlowExchangeError: 41 | raise InvalidCredentials("The authorization did not match.") 42 | 43 | return self.credentials 44 | 45 | def save_credentials(self): 46 | assert self.credentials is not None, "You did not set credentials before saving them" #TODO: That's still bad 47 | self.get_storage().put(self.credentials) 48 | -------------------------------------------------------------------------------- /cactus/deployment/gcs/engine.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import logging 3 | import threading 4 | 5 | import httplib2 6 | import apiclient.discovery 7 | import apiclient.errors 8 | 9 | from cactus.deployment.engine import BaseDeploymentEngine 10 | from cactus.deployment.gcs.auth import GCSCredentialsManager 11 | from cactus.deployment.gcs.file import GCSFile 12 | from cactus.exceptions import InvalidCredentials 13 | 14 | 15 | class GCSDeploymentEngine(BaseDeploymentEngine): 16 | FileClass = GCSFile 17 | CredentialsManagerClass = GCSCredentialsManager 18 | 19 | config_bucket_name = "gcs-bucket-name" 20 | config_bucket_website = "gcs-bucket-website" 21 | 22 | _HTTPClass = httplib2.Http 23 | 24 | 25 | def __init__(self, *args, **kwargs): 26 | super(GCSDeploymentEngine, self).__init__(*args, **kwargs) 27 | self._service_pool = {} # We can't share services (they share SSL connections) across processes. 28 | 29 | def get_connection(self): 30 | """ 31 | Worker threads may not share the same connection 32 | """ 33 | thread = threading.current_thread() 34 | ident = thread.ident 35 | 36 | service = self._service_pool.get(ident) 37 | if service is None: 38 | credentials = self.credentials_manager.get_credentials() 39 | http_client = self._HTTPClass() 40 | credentials.authorize(http_client) 41 | service = apiclient.discovery.build('storage', 'v1', http=http_client) 42 | self._service_pool[ident] = service 43 | 44 | return service 45 | 46 | def get_bucket(self): 47 | req = self.get_connection().buckets().get(bucket=self.bucket_name) 48 | try: 49 | return req.execute() 50 | except apiclient.errors.HttpError as e: 51 | if e.resp['status'] == '404': 52 | return None 53 | raise 54 | 55 | def create_bucket(self): 56 | project_id = self.site.ui.prompt_normalized('API project identifier?') 57 | 58 | public_acl = { 59 | "entity": "allUsers", 60 | "role": "READER", 61 | } 62 | 63 | body = { 64 | "name": self.bucket_name, 65 | "website": { 66 | "mainPageSuffix": self._index_page, 67 | "notFoundPage": self._error_page, 68 | }, 69 | "defaultObjectAcl": [public_acl], #TODO: Not required actually 70 | } 71 | 72 | self.get_connection().buckets().insert(project=project_id, body=body).execute() 73 | return self.get_bucket() 74 | 75 | def get_website_endpoint(self): 76 | return "Unavailable for GCS" 77 | -------------------------------------------------------------------------------- /cactus/deployment/gcs/file.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import io 3 | import base64 4 | import socket 5 | 6 | from apiclient.http import MediaIoBaseUpload 7 | from apiclient.errors import HttpError 8 | from cactus.deployment.file import BaseFile 9 | from cactus.utils.network import retry 10 | 11 | 12 | class GCSFile(BaseFile): 13 | def get_metadata(self): 14 | """ 15 | Generate metadata for the upload. 16 | Note: we don't set the etag, since the GCS API does not accept what we set 17 | """ 18 | metadata = { 19 | "acl": [{"entity": "allUsers", "role": "READER"},], 20 | "md5Hash": base64.b64encode(self.payload_checksum.decode('hex')), 21 | "contentType": self.content_type, # Given twice... 22 | "cacheControl": unicode(self.cache_control) # That's what GCS will return 23 | } 24 | 25 | if self.content_encoding is not None: 26 | metadata['contentEncoding'] = self.content_encoding 27 | 28 | return metadata 29 | 30 | def remote_changed(self): 31 | """ 32 | Compare each piece of metadata that we're setting with the one that's stored remotely 33 | If one's different, upload again. 34 | 35 | :rtype: bool 36 | """ 37 | resource = self.engine.get_connection().objects() 38 | req = resource.get(bucket=self.engine.bucket_name, object=self.url) 39 | 40 | try: 41 | remote_metadata = req.execute() 42 | except HttpError as e: 43 | if e.resp.status == 404: 44 | return True 45 | raise 46 | 47 | ignore_metadata = ["acl"] # We can't control what we'll retrieve TODO: do the best we can do! 48 | 49 | for k, v in self.get_metadata().items(): 50 | if k not in ignore_metadata and remote_metadata.get(k) != v: 51 | return True 52 | return False 53 | 54 | @retry((socket.error,), tries=5, delay=3, backoff=2) 55 | def do_upload(self): 56 | resource = self.engine.get_connection().objects() 57 | 58 | stream = io.BytesIO(self.payload()) 59 | upload = MediaIoBaseUpload(stream, mimetype=self.content_type) 60 | 61 | req = resource.insert( 62 | bucket=self.engine.bucket_name, 63 | name=self.url, 64 | body=self.get_metadata(), 65 | media_body=upload, 66 | ) 67 | 68 | req.execute() 69 | -------------------------------------------------------------------------------- /cactus/deployment/s3/__init__.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | -------------------------------------------------------------------------------- /cactus/deployment/s3/auth.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | from cactus.deployment.auth import BaseKeyringCredentialsManager 3 | 4 | 5 | class AWSCredentialsManager(BaseKeyringCredentialsManager): 6 | _username_config_entry = "aws-access-key" 7 | _username_display_name = "Amazon Access Key ID" 8 | _password_display_name = "Amazon Secret Access Key" 9 | _keyring_service = "aws" #Would break backwards compatibility 10 | -------------------------------------------------------------------------------- /cactus/deployment/s3/file.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import logging 3 | import socket 4 | 5 | from boto.exception import S3ResponseError 6 | 7 | from cactus.deployment.file import BaseFile 8 | from cactus.utils.helpers import CaseInsensitiveDict 9 | from cactus.utils.network import retry 10 | from cactus.utils.url import getURLHeaders 11 | from cactus.utils import ipc 12 | 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class S3File(BaseFile): 18 | def __init__(self, engine, path): 19 | super(S3File, self).__init__(engine, path) 20 | self.extra_headers = CaseInsensitiveDict() 21 | 22 | def get_headers(self): 23 | headers = CaseInsensitiveDict() 24 | headers['Cache-Control'] = 'max-age={0}'.format(self.cache_control) 25 | if self.content_encoding is not None: 26 | headers['Content-Encoding'] = self.content_encoding 27 | headers.update(self.extra_headers) 28 | return headers 29 | 30 | def remote_url(self): 31 | return 'http://%s/%s' % (self.engine.site.config.get('aws-bucket-website'), self.url) 32 | 33 | @retry((S3ResponseError, socket.error, socket.timeout), tries=5, delay=1, backoff=2) 34 | def remote_changed(self): 35 | remote_headers = dict((k, v.strip('"')) for k, v in getURLHeaders(self.remote_url()).items()) 36 | local_headers = self.get_headers() 37 | local_headers['etag'] = self.payload_checksum 38 | for k, v in local_headers.items(): # Don't check AWS' own headers. 39 | if remote_headers.get(k) != v: 40 | return True 41 | return False 42 | 43 | @retry((S3ResponseError, socket.error, socket.timeout), tries=5, delay=1, backoff=2) 44 | def do_upload(self): 45 | 46 | kbConstant = (1024 * 100) 47 | 48 | progressCallbackCount = len(self.payload()) / kbConstant 49 | 50 | def progressCallback(current, total): 51 | 52 | if current == 0 or total == 0: 53 | return 54 | 55 | self.total_bytes_uploaded = current 56 | 57 | logger.info('+ %s upload progress %.1f%%', 58 | self.url, float(current) / float(total) * 100) 59 | 60 | # logger.warning("deploy.progress %s", self.engine.progress()) 61 | 62 | ipc.signal("deploy.progress", { 63 | "progress": self.engine.progress(), 64 | "fileName": self.path 65 | }) 66 | 67 | if len(self.payload()) < kbConstant: 68 | progressCallback = None 69 | progressCallbackCount = None 70 | 71 | key = self.engine.bucket.new_key(self.url) 72 | 73 | if self.content_type: 74 | key.content_type = self.content_type # We don't it need before (local headers only) 75 | key.md5 = self.payload_checksum # In case of a flaky network 76 | key.set_contents_from_string(self.payload(), 77 | headers=self.get_headers(), 78 | policy='public-read', 79 | cb=progressCallback, 80 | num_cb=progressCallbackCount) 81 | -------------------------------------------------------------------------------- /cactus/exceptions.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | 3 | class InvalidCredentials(Exception): 4 | """ 5 | Raised when invalid credentials are used to connect. 6 | """ 7 | pass 8 | -------------------------------------------------------------------------------- /cactus/i18n/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eudicots/Cactus/b6dc9fb92248e1fd7fb6f44b57b8835802e9d880/cactus/i18n/__init__.py -------------------------------------------------------------------------------- /cactus/i18n/commands.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | from django.core.management.commands.makemessages import Command as MakeMessagesCommand 3 | from django.core.management.commands.compilemessages import Command as CompileMessagesCommand 4 | 5 | from cactus.utils.filesystem import chdir 6 | 7 | 8 | DEFAULT_COMMAND_KWARGS = { 9 | # Command Options 10 | "verbosity": 3, 11 | "settings": None, 12 | "pythonpath": None, 13 | "traceback": True, 14 | "all": False, 15 | } 16 | 17 | DEFAULT_MAKEMESSAGES_KWARGS = { 18 | # MakeMessages Options: Default 19 | "domain": "django", 20 | "extensions": [], 21 | "ignore_patterns": [], 22 | "symlinks": False, 23 | "use_default_ignore_patterns": True, 24 | "no_wrap": False, 25 | "no_location": False, 26 | "no_obsolete": False, 27 | "keep_pot": False 28 | } 29 | 30 | def WrappedCommandFactory(wrapped, default_kwargs=None): 31 | # Compose a list of kwargs for future runs 32 | base_kwargs = {} 33 | base_kwargs.update(DEFAULT_COMMAND_KWARGS) 34 | if default_kwargs is not None: 35 | base_kwargs.update(default_kwargs) 36 | 37 | class WrappedCommand(object): 38 | """ 39 | Wraps a Django management command 40 | """ 41 | def __init__(self, site): 42 | self.site = site 43 | 44 | def execute(self): 45 | kwargs = {"locale": [self.site.locale]} 46 | kwargs.update(base_kwargs) 47 | 48 | cmd = wrapped() 49 | with chdir(self.site.path): 50 | cmd.execute(**kwargs) # May raise an exception depending on gettext install. 51 | 52 | 53 | return WrappedCommand 54 | 55 | 56 | MessageMaker = WrappedCommandFactory(MakeMessagesCommand, DEFAULT_MAKEMESSAGES_KWARGS) 57 | MessageCompiler = WrappedCommandFactory(CompileMessagesCommand) 58 | -------------------------------------------------------------------------------- /cactus/listener/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | from cactus.listener.polling import PollingListener 5 | 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | try: 11 | from cactus.listener.mac import FSEventsListener as Listener 12 | except (ImportError, OSError): 13 | logger.debug("Failed to load FSEventsListener, falling back to PollingListener", exc_info=True) 14 | Listener = PollingListener 15 | -------------------------------------------------------------------------------- /cactus/listener/mac.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | import time 4 | import threading 5 | import logging 6 | 7 | from ctypes import * 8 | 9 | from cactus.utils.filesystem import fileList 10 | from cactus.utils.network import retry 11 | 12 | from fsevents import Observer, Stream 13 | 14 | class struct_timespec(Structure): 15 | _fields_ = [('tv_sec', c_long), ('tv_nsec', c_long)] 16 | 17 | class struct_stat64(Structure): 18 | _fields_ = [ 19 | ('st_dev', c_int32), 20 | ('st_mode', c_uint16), 21 | ('st_nlink', c_uint16), 22 | ('st_ino', c_uint64), 23 | ('st_uid', c_uint32), 24 | ('st_gid', c_uint32), 25 | ('st_rdev', c_int32), 26 | ('st_atimespec', struct_timespec), 27 | ('st_mtimespec', struct_timespec), 28 | ('st_ctimespec', struct_timespec), 29 | ('st_birthtimespec', struct_timespec), 30 | ('dont_care', c_uint64 * 8) 31 | ] 32 | 33 | libc = CDLL('/usr/lib/libc.dylib') 34 | stat64 = libc.stat64 35 | stat64.argtypes = [c_char_p, POINTER(struct_stat64)] 36 | 37 | # OS-X only function to get actual creation date 38 | def get_creation_time(path): 39 | buf = struct_stat64() 40 | rv = stat64(path, pointer(buf)) 41 | if rv != 0: 42 | raise OSError("Couldn't stat file %r" % path) 43 | return buf.st_birthtimespec.tv_sec 44 | 45 | 46 | def createStream(real_path, link_path, callback): 47 | 48 | def cb(event): 49 | 50 | if not os.path.isdir(link_path): 51 | event.name = link_path 52 | else: 53 | translated_path = os.path.join(link_path, os.path.relpath(event.name, real_path)) 54 | event.name = translated_path 55 | 56 | callback(event) 57 | 58 | return Stream(cb, real_path, file_events=True) 59 | 60 | 61 | class FSEventsListener(object): 62 | def __init__(self, path, f, ignore = None): 63 | 64 | logging.info("Using FSEvents") 65 | 66 | self.path = path 67 | self.f = f 68 | self.ignore = ignore 69 | 70 | self.observer = Observer() 71 | self.observer.daemon = True 72 | 73 | self._streams = [] 74 | self._streams.append(createStream(self.path, path, self._update)) 75 | 76 | self._streamed_folders = [self.path] 77 | 78 | def add_stream(p): 79 | if p in self._streamed_folders: 80 | return 81 | self._streams.append( 82 | createStream(p, file_path, self._update)) 83 | self._streamed_folders.append(p) 84 | 85 | # Start an extra listener for all symlinks 86 | for file_path in fileList(self.path, folders=True): 87 | if os.path.islink(file_path): 88 | if os.path.isdir(file_path): 89 | add_stream(os.path.realpath(file_path)) 90 | else: 91 | add_stream(os.path.dirname(os.path.realpath(file_path))) 92 | 93 | def run(self): 94 | self.resume() 95 | self.observer.start() 96 | 97 | def pause(self): 98 | logging.debug("MacListener.PAUSE") 99 | 100 | for stream in self._streams: 101 | self.observer.unschedule(stream) 102 | 103 | def resume(self): 104 | logging.debug("MacListener.RESUME") 105 | 106 | for stream in self._streams: 107 | self.observer.schedule(stream) 108 | 109 | def stop(): 110 | self.observer.stop() 111 | 112 | def _update(self, event): 113 | 114 | path = event.name 115 | 116 | if self.ignore and self.ignore(path): 117 | return 118 | 119 | logging.debug("MacListener.update %s", event) 120 | 121 | result = { 122 | 'added': [], 123 | 'deleted': [], 124 | 'changed': [], 125 | } 126 | 127 | if os.path.exists(path): 128 | 129 | seconds_since_created = int(time.time()) - get_creation_time(os.path.realpath(path)) 130 | 131 | if seconds_since_created < 1.0: 132 | result["added"].append(path) 133 | else: 134 | result["changed"].append(path) 135 | else: 136 | result["deleted"].append(path) 137 | 138 | self.f(result) 139 | -------------------------------------------------------------------------------- /cactus/listener/polling.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | import time 4 | import threading 5 | import logging 6 | 7 | import six 8 | 9 | from cactus.utils.filesystem import fileList 10 | from cactus.utils.network import retry 11 | 12 | class PollingListener(object): 13 | def __init__(self, path, f, delay = .5, ignore = None): 14 | self.path = path 15 | self.f = f 16 | self.delay = delay 17 | self.ignore = ignore 18 | self._pause = False 19 | self._checksums = {} 20 | 21 | def checksums(self): 22 | checksumMap = {} 23 | 24 | for f in fileList(self.path): 25 | 26 | if f.startswith('.'): 27 | continue 28 | 29 | if self.ignore and self.ignore(f): 30 | continue 31 | 32 | try: 33 | checksumMap[f] = int(os.stat(f).st_mtime) 34 | except OSError: 35 | continue 36 | 37 | return checksumMap 38 | 39 | def run(self): 40 | # self._loop() 41 | t = threading.Thread(target=self._loop) 42 | t.daemon = True 43 | t.start() 44 | 45 | def pause(self): 46 | self._pause = True 47 | 48 | def resume(self): 49 | self._checksums = self.checksums() 50 | self._pause = False 51 | 52 | def _loop(self): 53 | self._checksums = self.checksums() 54 | 55 | while True: 56 | self._run() 57 | 58 | @retry((Exception,), tries = 5, delay = 0.5) 59 | def _run(self): 60 | if not self._pause: 61 | oldChecksums = self._checksums 62 | newChecksums = self.checksums() 63 | 64 | result = { 65 | 'added': [], 66 | 'deleted': [], 67 | 'changed': [], 68 | } 69 | 70 | for k, v in six.iteritems(oldChecksums): 71 | if k not in newChecksums: 72 | result['deleted'].append(k) 73 | elif v != newChecksums[k]: 74 | result['changed'].append(k) 75 | 76 | for k, v in six.iteritems(newChecksums): 77 | if k not in oldChecksums: 78 | result['added'].append(k) 79 | 80 | result['any'] = result['added'] + result['deleted'] + result['changed'] 81 | 82 | if result['any']: 83 | self._checksums = newChecksums 84 | self.f(result) 85 | 86 | time.sleep(self.delay) 87 | -------------------------------------------------------------------------------- /cactus/logger.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import types 4 | import json 5 | 6 | import six 7 | 8 | 9 | class JsonFormatter(logging.Formatter): 10 | 11 | def format(self, record): 12 | 13 | data = { 14 | "level": record.levelno, 15 | "levelName": record.levelname, 16 | "msg": logging.Formatter.format(self, record) 17 | } 18 | 19 | if type(record.args) is types.DictType: 20 | for k, v in six.iteritems(record.args): 21 | data[k] = v 22 | 23 | return json.dumps(data) 24 | 25 | 26 | def setup_logging(verbose, quiet): 27 | 28 | logger = logging.getLogger() 29 | handler = logging.StreamHandler() 30 | 31 | if os.environ.get('DESKTOPAPP'): 32 | log_level = logging.INFO 33 | handler.setFormatter(JsonFormatter()) 34 | 35 | else: 36 | from colorlog import ColoredFormatter 37 | 38 | formatter = ColoredFormatter( 39 | "%(log_color)s%(message)s", 40 | datefmt=None, 41 | reset=True, 42 | log_colors={ 43 | 'DEBUG': 'cyan', 44 | 'INFO': 'green', 45 | 'WARNING': 'yellow', 46 | 'ERROR': 'red', 47 | 'CRITICAL': 'bold_red', 48 | } 49 | ) 50 | 51 | if quiet: 52 | log_level = logging.WARNING 53 | elif verbose: 54 | log_level = logging.DEBUG 55 | else: 56 | log_level = logging.INFO 57 | 58 | handler.setFormatter(formatter) 59 | 60 | logger.setLevel(log_level) 61 | 62 | for h in logger.handlers: 63 | logger.removeHandler(h) 64 | 65 | logger.addHandler(handler) 66 | -------------------------------------------------------------------------------- /cactus/mime.py: -------------------------------------------------------------------------------- 1 | import os 2 | import mimetypes 3 | 4 | MIMETYPE_MAP = { 5 | '.js': 'text/javascript', 6 | '.mov': 'video/quicktime', 7 | '.mp4': 'video/mp4', 8 | '.m4v': 'video/x-m4v', 9 | '.3gp': 'video/3gpp', 10 | '.woff': 'application/font-woff', 11 | '.eot': 'application/vnd.ms-fontobject', 12 | '.ttf': 'application/x-font-truetype', 13 | '.otf': 'application/x-font-opentype', 14 | '.svg': 'image/svg+xml', 15 | } 16 | 17 | MIMETYPE_DEFAULT = 'application/octet-stream' 18 | 19 | def guess(path): 20 | 21 | if not path: 22 | return MIMETYPE_DEFAULT 23 | 24 | base, ext = os.path.splitext(path) 25 | 26 | if ext.lower() in MIMETYPE_MAP: 27 | return MIMETYPE_MAP[ext.lower()] 28 | 29 | mime_type, encoding = mimetypes.guess_type(path) 30 | 31 | if mime_type: 32 | return mime_type 33 | 34 | return MIMETYPE_DEFAULT 35 | -------------------------------------------------------------------------------- /cactus/plugin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eudicots/Cactus/b6dc9fb92248e1fd7fb6f44b57b8835802e9d880/cactus/plugin/__init__.py -------------------------------------------------------------------------------- /cactus/plugin/builtin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eudicots/Cactus/b6dc9fb92248e1fd7fb6f44b57b8835802e9d880/cactus/plugin/builtin/__init__.py -------------------------------------------------------------------------------- /cactus/plugin/builtin/cache.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | 3 | class CacheDurationPlugin(object): 4 | """ 5 | A plugin to make the default cache expiry configurable via a "cache-duration" configuration setting 6 | """ 7 | 8 | def preDeploy(self, site): 9 | """ 10 | Load the cache duration from the config 11 | """ 12 | self.cache_duration = site.config.get("cache-duration") 13 | 14 | def preDeployFile(self, file): 15 | """ 16 | Set the cache duration expiry on the file 17 | """ 18 | if self.cache_duration is not None and not file.is_fingerprinted: 19 | file.cache_control = self.cache_duration 20 | -------------------------------------------------------------------------------- /cactus/plugin/builtin/context.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | 3 | class ContextPlugin(object): 4 | """ 5 | A plugin to manage custom context via config files. 6 | 7 | The context can be made available via a "context" key in config files. 8 | """ 9 | 10 | def preBuild(self, site): 11 | """ 12 | Load the context from the config 13 | """ 14 | self.context = site.config.get("context", {}, nested=True) 15 | 16 | def preBuildPage(self, page, context, data): 17 | """ 18 | Update the page context with the config context 19 | """ 20 | context.update(self.context) 21 | 22 | return context, data 23 | 24 | 25 | plugin = ContextPlugin() 26 | -------------------------------------------------------------------------------- /cactus/plugin/builtin/ignore.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import fnmatch 3 | 4 | class IgnorePatternsPlugin(object): 5 | """ 6 | Define configurable ignore patterns for static files and pages 7 | """ 8 | def preBuild(self, site): 9 | """ 10 | Load the ignore patterns from the site config 11 | """ 12 | self.ignore_patterns = site.config.get('ignore', []) 13 | 14 | def preBuildPage(self, page, context, data): 15 | if not self.accept_path(page.source_path): 16 | page.discarded = True 17 | return context, data 18 | 19 | 20 | def preBuildStatic(self, static): 21 | if not self.accept_path(static.path): 22 | static.discard() 23 | 24 | def accept_path(self, path): 25 | """ 26 | :param path: A path to be tested 27 | :returns: Whether this path can be includes in the build 28 | """ 29 | for pattern in self.ignore_patterns: 30 | if fnmatch.fnmatch(path, pattern): 31 | return False 32 | return True 33 | -------------------------------------------------------------------------------- /cactus/plugin/defaults.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | 3 | # Define no-op plugin methods 4 | def preBuildPage(page, context, data): 5 | """ 6 | Called prior to building a page. 7 | 8 | :param page: The page about to be built 9 | :param context: The context for this page (you can modify this, but you must return it) 10 | :param data: The raw body for this page (you can modify this). 11 | :returns: Modified (or not) context and data. 12 | """ 13 | return context, data 14 | 15 | 16 | def postBuildPage(page): 17 | """ 18 | Called after building a page. 19 | 20 | :param page: The page that was just built. 21 | :returns: None 22 | """ 23 | pass 24 | 25 | 26 | def preBuildStatic(static): 27 | """ 28 | Called before building (copying to the build folder) a static file. 29 | 30 | :param static: The static file about to be built. 31 | :returns: None 32 | """ 33 | pass 34 | 35 | 36 | def postBuildStatic(static): 37 | """ 38 | Called after building (copying to the build folder) a static file. 39 | 40 | :param static: The static file that was just built. 41 | :returns: None 42 | """ 43 | pass 44 | 45 | 46 | def preBuild(site): 47 | """ 48 | Called prior to building the site, after loading configuration and plugins. 49 | 50 | A good time to register your externals. 51 | 52 | :param site: The site about to be built. 53 | :returns: None 54 | """ 55 | pass 56 | 57 | def postBuild(site): 58 | """ 59 | Called after building the site. 60 | 61 | :param site: The site that was just built. 62 | :returns: None 63 | """ 64 | pass 65 | 66 | 67 | def preDeploy(site): 68 | """ 69 | Called prior to deploying the site (built files) 70 | 71 | A good time to configure custom headers 72 | 73 | :param site: The site about to be deployed. 74 | :returns: None 75 | """ 76 | pass 77 | 78 | 79 | def postDeploy(site): 80 | """ 81 | Called after deploying the site (built files) 82 | 83 | :param site: The site that was just built. 84 | :returns: None 85 | """ 86 | pass 87 | 88 | 89 | def preDeployFile(file): 90 | """ 91 | Called prior to deploying a single built file 92 | 93 | :param file: The file about to be deployed. 94 | :returns: None 95 | """ 96 | pass 97 | 98 | 99 | ORDER = -1 100 | 101 | 102 | DEFAULTS = [ 103 | 'preBuildPage', 104 | 'postBuildPage', 105 | 'preBuildStatic', 106 | 'postBuildStatic', 107 | 'preBuild', 108 | 'postBuild', 109 | 'preDeploy', 110 | 'postDeploy', 111 | 'preDeployFile', 112 | ] 113 | -------------------------------------------------------------------------------- /cactus/plugin/loader.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | import sys 4 | import imp 5 | import logging 6 | 7 | from cactus.plugin import defaults 8 | from cactus.utils.filesystem import fileList 9 | 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class BasePluginsLoader(object): 15 | def load(self): 16 | raise NotImplementedError("Subclasses must implement load") 17 | 18 | def _initialize_plugin(self, plugin): 19 | """ 20 | :param plugin: A plugin to initialize. 21 | :returns: An initialized plugin with all default methods set. 22 | """ 23 | # Load default attributes 24 | for attr in defaults.DEFAULTS + ['ORDER']: 25 | if not hasattr(plugin, attr): 26 | setattr(plugin, attr, getattr(defaults, attr)) 27 | 28 | # Name the plugin 29 | if not hasattr(plugin, "plugin_name"): 30 | if hasattr(plugin, "__name__"): 31 | plugin.plugin_name = plugin.__name__ 32 | elif hasattr(plugin, "__class__"): 33 | plugin.plugin_name = plugin.__class__.__name__ 34 | else: 35 | plugin.plugin_name = "anonymous" 36 | 37 | 38 | class ObjectsPluginLoader(BasePluginsLoader): 39 | """ 40 | Loads the plugins objects passed to this loader. 41 | """ 42 | def __init__(self, plugins): 43 | """ 44 | :param plugins: The list of plugins this loader should load. 45 | """ 46 | self.plugins = plugins 47 | 48 | def load(self): 49 | """ 50 | :returns: The list of plugins loaded by this loader. 51 | """ 52 | plugins = [] 53 | 54 | # Load cactus internal plugins 55 | for builtin_plugin in self.plugins: 56 | self._initialize_plugin(builtin_plugin) 57 | plugins.append(builtin_plugin) 58 | 59 | return plugins 60 | 61 | 62 | class CustomPluginsLoader(BasePluginsLoader): 63 | """ 64 | Loads all the plugins found at the path passed. 65 | """ 66 | 67 | def __init__(self, plugin_path): 68 | """ 69 | :param plugin_path: The path where the plugins should be loaded from. 70 | """ 71 | self.plugin_path = plugin_path 72 | 73 | def load(self): 74 | """ 75 | :returns: The list of plugins loaded by this loader. 76 | """ 77 | plugins = [] 78 | 79 | # Load user plugins 80 | for plugin_path in fileList(self.plugin_path): 81 | if self._is_plugin_path(plugin_path): 82 | custom_plugin = self._load_plugin_path(plugin_path) 83 | if custom_plugin: 84 | self._initialize_plugin(custom_plugin) 85 | plugins.append(custom_plugin) 86 | 87 | 88 | return plugins 89 | 90 | def _is_plugin_path(self, plugin_path): 91 | """ 92 | :param plugin_path: A path where to look for a plugin. 93 | :returns: Whether this path looks like an enabled plugin. 94 | """ 95 | if not plugin_path.endswith('.py'): 96 | return False 97 | 98 | if 'disabled' in plugin_path: 99 | return False 100 | 101 | return True 102 | 103 | def _load_plugin_path(self, plugin_path): 104 | """ 105 | :param plugin_path: A path to load as a plugin. 106 | :returns: A plugin module. 107 | """ 108 | module_name = "plugin_{0}".format(os.path.splitext(os.path.basename(plugin_path))[0]) 109 | 110 | try: 111 | return imp.load_source(module_name, plugin_path) 112 | except Exception as e: 113 | logger.warning('Could not load plugin at path %s: %s' % (plugin_path, e)) 114 | return None 115 | 116 | # sys.exit() 117 | -------------------------------------------------------------------------------- /cactus/plugin/manager.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import functools 3 | 4 | from cactus.utils.internal import getargspec 5 | from cactus.plugin import defaults 6 | 7 | 8 | class PluginManager(object): 9 | def __init__(self, site, loaders): 10 | self.site = site 11 | self.loaders = loaders 12 | self.reload() 13 | 14 | for plugin_method in defaults.DEFAULTS: 15 | if not hasattr(self, plugin_method): 16 | setattr(self, plugin_method, functools.partial(self.call, plugin_method)) 17 | 18 | def reload(self): 19 | plugins = [] 20 | for loader in self.loaders: 21 | plugins.extend(loader.load()) 22 | 23 | self.plugins = sorted(plugins, key=lambda plugin: plugin.ORDER) 24 | 25 | def call(self, method, *args, **kwargs): 26 | """ 27 | Call each plugin 28 | """ 29 | for plugin in self.plugins: 30 | _meth = getattr(plugin, method) 31 | _meth(*args, **kwargs) 32 | 33 | def preBuildPage(self, site, page, context, data): 34 | """ 35 | Special call as we have changed the API for this. 36 | 37 | We have two calling conventions: 38 | - The new one, which passes page, context, data 39 | - The deprecated one, which also passes the site (Now accessible via the page) 40 | """ 41 | for plugin in self.plugins: 42 | # Find the correct calling convention 43 | new = [page, context, data] 44 | deprecated = [site, page, context, data] 45 | arg_lists = dict((len(l), l) for l in [deprecated, new]) 46 | 47 | try: 48 | # Try to find the best calling convention 49 | n_args = len(getargspec(plugin.preBuildPage).args) 50 | # Just use the new calling convention if there's fancy usage of 51 | # *args, **kwargs that we can't control. 52 | arg_list = arg_lists.get(n_args, new) 53 | except NotImplementedError: 54 | # If we can't get the number of args, use the new one. 55 | arg_list = new 56 | 57 | # Call with the best calling convention we have. 58 | # If that doesn't work, then we'll let the error escalate. 59 | context, data = plugin.preBuildPage(*arg_list) 60 | 61 | return context, data 62 | -------------------------------------------------------------------------------- /cactus/skeleton/config.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /cactus/skeleton/locale/README.md: -------------------------------------------------------------------------------- 1 | Internationalization 2 | ==================== 3 | 4 | Using internationalization with Cactus 5 | -------------------------------------- 6 | 7 | To enable internationalization for your project: 8 | 9 | 1. Add a `locale` key to (one of your) configuration file(s) 10 | 2. Mark strings for translation in your site (using `{% trans %}`) 11 | 3. Run `cactus messages:make` 12 | 4. Edit the .po file that was created with translations. 13 | 14 | 15 | Multiple languages with Cactus 16 | ------------------------------ 17 | 18 | To make the best of translations, you'll need multiple configuration files: one per language you'd like to support. 19 | 20 | This lets you transparently deploy multiple versions of your website to multiple buckets (one per language). 21 | -------------------------------------------------------------------------------- /cactus/skeleton/pages/about.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block body %} 4 |
5 |
6 |

Make this your about page!

7 |

Use this page to tell your visitors about you. Or scrape it, and make a new one!

8 |
9 |
10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /cactus/skeleton/pages/contact.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block body %} 4 |
5 |
6 |

Make this a contact form!

7 |

Give your contact info, or use a SaaS solution to add dynamic contact forms to your static website.

8 |
9 |
10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /cactus/skeleton/pages/error.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block body %} 4 |
5 |
6 |

We're sorry!

7 |

The page could not be found on this server.

8 |
9 |
10 | {% endblock body %} 11 | 12 | {% block scripts %} 13 | 26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /cactus/skeleton/pages/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block body %} 4 |
5 |
6 |

Welcome to Cactus!

7 |

Get started with this template based on HTML 5 Boilerplate. Go ahead, explore!

8 |
9 |
10 | 11 |
12 | 13 |
14 | 15 |
16 |

Full featured template engine

17 |

Cactus uses and extends Django's templating engine. If you've built sites using Django before, you'll feel right at home.

18 |
19 | 20 |
21 |

Pretty URLs

22 |

Cactus can prettify URLs, so you can turn /about.html into a fancier /about/.

23 |
24 | 25 |
26 |

Minification & Fingerprinting

27 |

For maximum performance, Cactus can minify and fingerprint your static assets. As for your pages, Cactus will automatically compress them.

28 |
29 | 30 |
31 |

And much more

32 |

There's of course a lot more you can do with Cactus. Plus, Cactus's plugin framework lets you easily extend its functionality.

33 |
34 | 35 | 36 |
37 |
38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /cactus/skeleton/pages/robots.txt: -------------------------------------------------------------------------------- 1 | {% verbatim %} 2 | User-agent: * 3 | Disallow: 4 | 5 | Sitemap: sitemap.xml 6 | {% endverbatim %} 7 | -------------------------------------------------------------------------------- /cactus/skeleton/pages/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | {% for page in CACTUS.pages %}{% if page.path != 'error.html' %} 3 | 4 | {{ page.absolute_final_url }} 5 | daily 6 | 1.0 7 | {% endif %}{% endfor %} 8 | 9 | -------------------------------------------------------------------------------- /cactus/skeleton/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eudicots/Cactus/b6dc9fb92248e1fd7fb6f44b57b8835802e9d880/cactus/skeleton/plugins/__init__.py -------------------------------------------------------------------------------- /cactus/skeleton/plugins/blog.disabled.py: -------------------------------------------------------------------------------- 1 | """ 2 | Modify `config.json` to set a custom blog path, default author name, or date pattern used to parse metadata. The defaults are: 3 | "blog": { 4 | "path": "blog", 5 | "author": "Unknown", 6 | "date-format": "%d-%m-%Y" 7 | } 8 | """ 9 | 10 | import os 11 | import datetime 12 | import logging 13 | 14 | ORDER = 999 15 | POSTS = [] 16 | 17 | from django.template import Context 18 | from django.template.loader import get_template 19 | from django.template.loader_tags import BlockNode, ExtendsNode 20 | 21 | def getNode(template, context=Context(), name='subject'): 22 | """ 23 | Get django block contents from a template. 24 | http://stackoverflow.com/questions/2687173/ 25 | django-how-can-i-get-a-block-from-a-template 26 | """ 27 | for node in template: 28 | if isinstance(node, BlockNode) and node.name == name: 29 | return node.render(context) 30 | elif isinstance(node, ExtendsNode): 31 | return getNode(node.nodelist, context, name) 32 | raise Exception("Node '%s' could not be found in template." % name) 33 | 34 | 35 | def preBuild(site): 36 | 37 | global POSTS 38 | siteContext = site.context() 39 | 40 | blog = site.config.get('blog', {}) 41 | blogPath = os.path.join(blog.get('path', 'blog'), '') 42 | dateFormat = blog.get('date-format', '%d-%m-%Y') 43 | defaultAuthor = blog.get('author', 'Unknown') 44 | 45 | # Build all the posts 46 | for page in site.pages(): 47 | if page.path.startswith(blogPath): 48 | 49 | # Skip non html posts for obious reasons 50 | if not page.path.endswith('.html'): 51 | continue 52 | 53 | # Find a specific defined variable in the page context, 54 | # and throw a warning if we're missing it. 55 | def find(name): 56 | c = page.context() 57 | if not name in c: 58 | logging.info("Missing info '%s' for post %s" % (name, page.path)) 59 | return '' 60 | return c.get(name, '') 61 | 62 | # Build a context for each post 63 | context = {'__CACTUS_CURRENT_PAGE__': page,} 64 | context.update(siteContext) 65 | 66 | postContext = {} 67 | postContext['title'] = find('title') 68 | postContext['author'] = find('author') or defaultAuthor 69 | postContext['date'] = find('date') 70 | postContext['path'] = page.final_url 71 | postContext['body'] = getNode(get_template(page.path), context=Context(context), name="body") 72 | 73 | # Parse the date into a date object 74 | try: 75 | postContext['date'] = datetime.datetime.strptime(postContext['date'], dateFormat) 76 | except Exception as e: 77 | logging.warning("Date format not correct for page %s, should be %s\n%s" % (page.path, dateFormat, e)) 78 | continue 79 | 80 | POSTS.append(postContext) 81 | 82 | # Sort the posts by date 83 | POSTS = sorted(POSTS, key=lambda x: x['date']) 84 | POSTS.reverse() 85 | 86 | indexes = xrange(0, len(POSTS)) 87 | 88 | for i in indexes: 89 | if i+1 in indexes: POSTS[i]['prevPost'] = POSTS[i+1] 90 | if i-1 in indexes: POSTS[i]['nextPost'] = POSTS[i-1] 91 | 92 | 93 | def preBuildPage(site, page, context, data): 94 | """ 95 | Add the list of posts to every page context so we can 96 | access them from wherever on the site. 97 | """ 98 | context['posts'] = POSTS 99 | 100 | for post in POSTS: 101 | if post['path'] == page.final_url: 102 | context.update(post) 103 | 104 | return context, data 105 | -------------------------------------------------------------------------------- /cactus/skeleton/plugins/coffeescript.disabled.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pipes 3 | import subprocess 4 | import logging 5 | 6 | def run(command): 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | logger.debug(command) 11 | 12 | os.environ['PATH'] = '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/usr/local/share/npm/bin:' 13 | 14 | process = subprocess.Popen([command], 15 | shell=True, 16 | stdin=subprocess.PIPE, 17 | stdout=subprocess.PIPE, 18 | stderr=subprocess.PIPE) 19 | 20 | stdout = process.stdout.readline() 21 | stderr = process.stderr.readline() 22 | 23 | if stdout: logger.info(stdout) 24 | if stderr: logger.warning(stderr) 25 | 26 | 27 | def preBuild(site): 28 | run('coffee -c %s/js/*.coffee' % pipes.quote(site.static_path)) 29 | -------------------------------------------------------------------------------- /cactus/skeleton/plugins/gitcommitid.disabled.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | 4 | 5 | KEY = 'GIT_COMMITID' 6 | 7 | def commitid(): 8 | command = 'git log -1 --format="%H"' 9 | print("run command [%s]" % command) 10 | result = subprocess.check_output(command, shell=True) 11 | result = result.rstrip('\n') 12 | print("result command [%s]" % result) 13 | return result 14 | 15 | 16 | mycommitid = "undefined" 17 | 18 | def preBuildPage(page, context, data): 19 | global mycommitid 20 | if (mycommitid == "undefined"): 21 | mycommitid = commitid() 22 | 23 | context[KEY] = mycommitid 24 | return context, data -------------------------------------------------------------------------------- /cactus/skeleton/plugins/haml.disabled.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import codecs 4 | 5 | # How to: 6 | # * Install hamlpy (https://github.com/jessemiller/HamlPy) 7 | # * .haml files will compiled to .html files 8 | 9 | 10 | from hamlpy.hamlpy import Compiler 11 | from cactus.utils.filesystem import fileList 12 | 13 | CLEANUP = [] 14 | 15 | def preBuild(site): 16 | for path in fileList(site.paths['pages']): 17 | 18 | #only file ends with haml 19 | if not path.endswith('.haml'): 20 | continue 21 | 22 | #read the lines 23 | haml_lines = codecs.open(path, 'r', encoding='utf-8').read().splitlines() 24 | 25 | #compile haml to html 26 | compiler = Compiler() 27 | output = compiler.process_lines(haml_lines) 28 | 29 | #replace path 30 | outPath = path.replace('.haml', '.html') 31 | 32 | #write the html file 33 | with open(outPath,'w') as f: 34 | f.write(output) 35 | 36 | CLEANUP.append(outPath) 37 | 38 | 39 | def postBuild(site): 40 | global CLEANUP 41 | for path in CLEANUP: 42 | print path 43 | os.remove(path) 44 | CLEANUP = [] 45 | -------------------------------------------------------------------------------- /cactus/skeleton/plugins/page_context.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | 3 | def preBuildPage(page, context, data): 4 | """ 5 | Updates the context of the page to include: the page itself as {{ CURRENT_PAGE }} 6 | """ 7 | 8 | # This will run for each page that Cactus renders. 9 | # Any changes you make to context will be passed to the template renderer for this page. 10 | 11 | extra = { 12 | "CURRENT_PAGE": page 13 | # Add your own dynamic context elements here! 14 | } 15 | 16 | context.update(extra) 17 | return context, data 18 | -------------------------------------------------------------------------------- /cactus/skeleton/plugins/sass.disabled.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pipes 3 | 4 | def postBuild(site): 5 | os.system( 6 | 'sass -t compressed --update %s/static/css/*.sass' % 7 | pipes.quote(site.paths['build'] 8 | )) 9 | -------------------------------------------------------------------------------- /cactus/skeleton/plugins/scss.disabled.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import pipes 4 | import shutil 5 | import subprocess 6 | 7 | from cactus.utils.filesystem import fileList 8 | 9 | """ 10 | This plugin uses pyScss to translate sass files to css 11 | 12 | Install: 13 | 14 | sudo easy_install pyScss 15 | 16 | """ 17 | 18 | try: 19 | from scss import Scss 20 | except: 21 | sys.exit("Could not find pyScss, please install: sudo easy_install pyScss") 22 | 23 | 24 | CSS_PATH = 'static/css' 25 | 26 | for path in fileList(CSS_PATH): 27 | 28 | if not path.endswith('.scss'): 29 | continue 30 | 31 | with open(path, 'r') as f: 32 | data = f.read() 33 | 34 | css = Scss().compile(data) 35 | 36 | with open(path.replace('.scss', '.css'), 'w') as f: 37 | f.write(css) 38 | -------------------------------------------------------------------------------- /cactus/skeleton/plugins/sprites.disabled.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import pipes 4 | import shutil 5 | import subprocess 6 | 7 | """ 8 | This plugin uses glue to sprite images: 9 | http://glue.readthedocs.org/en/latest/quickstart.html 10 | 11 | Install: 12 | 13 | (Only if you want to sprite jpg too) 14 | brew install libjpeg 15 | 16 | sudo easy_install pip 17 | sudo pip uninstall pil 18 | sudo pip install pil 19 | sudo pip install glue 20 | """ 21 | 22 | try: 23 | import glue 24 | except Exception as e: 25 | sys.exit('Could not use glue: %s\nMaybe install: sudo easy_install glue' % e) 26 | 27 | 28 | IMG_PATH = 'static/img/sprites' 29 | CSS_PATH = 'static/css/sprites' 30 | 31 | KEY = '_PREV_CHECKSUM' 32 | 33 | def checksum(path): 34 | command = 'md5 `find %s -type f`' % pipes.quote(IMG_PATH) 35 | return subprocess.check_output(command, shell=True) 36 | 37 | def preBuild(site): 38 | if not os.path.isdir(IMG_PATH): 39 | return 40 | 41 | currChecksum = checksum(IMG_PATH) 42 | prevChecksum = getattr(site, KEY, None) 43 | 44 | # Don't run if none of the images has changed 45 | if currChecksum == prevChecksum: 46 | return 47 | 48 | if os.path.isdir(CSS_PATH): 49 | shutil.rmtree(CSS_PATH) 50 | 51 | os.mkdir(CSS_PATH) 52 | os.system('glue --cachebuster --crop --optipng "%s" "%s" --project' % (IMG_PATH, CSS_PATH)) 53 | 54 | setattr(site, KEY, currChecksum) 55 | -------------------------------------------------------------------------------- /cactus/skeleton/plugins/static_optimizers.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | from cactus.contrib.external.closure import ClosureJSOptimizer 3 | from cactus.contrib.external.yui import YUICSSOptimizer 4 | 5 | 6 | def preBuild(site): 7 | """ 8 | Registers optimizers as requested by the configuration. 9 | Be sure to read the plugin to understand and use it. 10 | """ 11 | 12 | # Inspect the site configuration, and retrieve an `optimize` list. 13 | # This lets you configure optimization selectively. 14 | # You may want to use one configuration for staging with no optimizations, and one 15 | # configuration for production, with all optimizations. 16 | optimize = site.config.get("optimize", []) 17 | 18 | if "js" in optimize: 19 | # If `js` was found in the `optimize` key, then register our JS optimizer. 20 | # This uses closure, but you could use cactus.contrib.external.yui.YUIJSOptimizer! 21 | site.external_manager.register_optimizer(ClosureJSOptimizer) 22 | 23 | if "css" in optimize: 24 | # Same thing for CSS. 25 | site.external_manager.register_optimizer(YUICSSOptimizer) 26 | 27 | # Add your own types here! 28 | -------------------------------------------------------------------------------- /cactus/skeleton/static/css/main.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | /* ========================================================================== 4 | Author's custom styles 5 | ========================================================================== */ 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /cactus/skeleton/static/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eudicots/Cactus/b6dc9fb92248e1fd7fb6f44b57b8835802e9d880/cactus/skeleton/static/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /cactus/skeleton/static/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eudicots/Cactus/b6dc9fb92248e1fd7fb6f44b57b8835802e9d880/cactus/skeleton/static/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /cactus/skeleton/static/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eudicots/Cactus/b6dc9fb92248e1fd7fb6f44b57b8835802e9d880/cactus/skeleton/static/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /cactus/skeleton/static/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eudicots/Cactus/b6dc9fb92248e1fd7fb6f44b57b8835802e9d880/cactus/skeleton/static/images/favicon.ico -------------------------------------------------------------------------------- /cactus/skeleton/static/js/main.js: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /cactus/skeleton/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 63 | 64 | {% block body %} 65 | {% endblock %} 66 | 67 | 68 | 69 | 70 | 71 | 72 | {% block scripts %} 73 | {% endblock %} 74 | 75 | -------------------------------------------------------------------------------- /cactus/static/external/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from cactus.static.external.exceptions import ExternalFailure 3 | 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | # Helpers to build the Externals 9 | 10 | ACCEPTED = 1 11 | REFUSED = 0 12 | DISCARDED = -1 13 | 14 | 15 | def status_getter(status): 16 | def has_status(external): 17 | return external.status == status 18 | return has_status 19 | 20 | 21 | def status_setter(status): 22 | def set_status(external): 23 | logger.debug('External {0} ({1}) status > {2}'.format( 24 | external.__class__.__name__, external.src, status)) 25 | external.status = status 26 | return set_status 27 | 28 | 29 | class External(object): 30 | supported_extensions = () # The extensions supported by this output 31 | output_extension = 'css' # The extension of this processor's output 32 | critical = False # Whether this External failure is critical 33 | 34 | def __init__(self, extension, src, dst): 35 | self.extension = extension 36 | self.src = src 37 | self.dst = dst 38 | 39 | accept = status_setter(ACCEPTED) 40 | accepted = status_getter(ACCEPTED) 41 | 42 | refuse = status_setter(REFUSED) 43 | refused = status_getter(REFUSED) 44 | 45 | discard = status_setter(DISCARDED) 46 | discarded = status_getter(DISCARDED) 47 | 48 | 49 | def run(self): 50 | """ 51 | Return True in the case we succeed in running, False otherwise. 52 | This means we can use several processors and have one or the other work. 53 | """ 54 | if not self.extension in self.supported_extensions: 55 | return self.refuse() 56 | 57 | self.accept() # We accept now so the run method can discard 58 | 59 | try: 60 | self._run() 61 | except OSError as e: 62 | msg = 'Could not call external processor {0}: {1}'.format(self.__class__.__name__, e) 63 | 64 | if self.critical: 65 | logger.critical(msg) 66 | raise ExternalFailure(self.__class__.__name__, e) 67 | else: 68 | logger.info(msg) 69 | self.refuse() 70 | 71 | def _run(self): 72 | raise NotImplementedError() 73 | -------------------------------------------------------------------------------- /cactus/static/external/exceptions.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | 3 | class InvalidExternal(Exception): 4 | """ 5 | Raised when an External caused an illegal operation. 6 | """ 7 | #TODO: Tests 8 | 9 | 10 | class ExternalFailure(Exception): 11 | """ 12 | Raised when an External failed to run 13 | """ 14 | def __init__(self, external, error): 15 | self.external = external 16 | self.error = error 17 | 18 | def __str__(self): 19 | return '{0} failed: {1}'.format(self.external, self.error) 20 | -------------------------------------------------------------------------------- /cactus/static/external/manager.py: -------------------------------------------------------------------------------- 1 | class ExternalManager(object): 2 | """ 3 | Manager the active externals 4 | """ 5 | def __init__(self, site, processors=None, optimizers=None): 6 | self.site = site 7 | self.processors = processors if processors is not None else [] 8 | self.optimizers = optimizers if optimizers is not None else [] 9 | 10 | def _register(self, external, externals): 11 | externals.insert(0, external) 12 | 13 | def _deregister(self, external, externals): 14 | externals.remove(external) 15 | 16 | def clear(self): 17 | """ 18 | Clear this manager 19 | """ 20 | self.processors = [] 21 | self.optimizers = [] 22 | 23 | def register_processor(self, processor): 24 | """ 25 | Add a new processor to the list of processors 26 | This processor will be added with maximum priority 27 | """ 28 | self._register(processor, self.processors) 29 | 30 | def deregister_processor(self, processor): 31 | """ 32 | Remove an existing processor from the list 33 | Will raise a ValueError if the processor is not present 34 | """ 35 | self._deregister(processor, self.processors) 36 | 37 | def register_optimizer(self, optimizer): 38 | """ 39 | Add a new optimizer to the list of optimizer 40 | This optimizer will be added with maximum priority 41 | """ 42 | self._register(optimizer, self.optimizers) 43 | 44 | def deregister_optimizer(self, processor): 45 | """ 46 | Remove an existing optimizer from the list 47 | Will raise a ValueError if the optimizer is not present 48 | """ 49 | self._deregister(processor, self.optimizers) 50 | -------------------------------------------------------------------------------- /cactus/template_tags.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | import logging 4 | 5 | from django.template.base import Library 6 | from django.conf import settings 7 | from django.utils.encoding import force_text 8 | from django.utils.safestring import mark_safe 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | register = Library() 13 | 14 | 15 | def static(context, link_url): 16 | """ 17 | Get the path for a static file in the Cactus build. 18 | We'll need this because paths can be rewritten with fingerprinting. 19 | """ 20 | #TODO: Support URLS that don't start with `/static/` 21 | site = context['__CACTUS_SITE__'] 22 | page = context['__CACTUS_CURRENT_PAGE__'] 23 | 24 | url = site.get_url_for_static(link_url) 25 | 26 | if url is None: 27 | 28 | # For the static method we check if we need to add a prefix 29 | helper_keys = [ 30 | "/static/" + link_url, 31 | "/static" + link_url, 32 | "static/" + link_url 33 | ] 34 | 35 | for helper_key in helper_keys: 36 | 37 | url_helper_key = site.get_url_for_static(helper_key) 38 | 39 | if url_helper_key is not None: 40 | return url_helper_key 41 | 42 | logger.warning('%s: static resource does not exist: %s', page.link_url, link_url) 43 | 44 | url = link_url 45 | 46 | return url 47 | 48 | 49 | def url(context, link_url): 50 | """ 51 | Get the path for a page in the Cactus build. 52 | We'll need this because paths can be rewritten with prettifying. 53 | """ 54 | site = context['__CACTUS_SITE__'] 55 | page = context['__CACTUS_CURRENT_PAGE__'] 56 | 57 | url = site.get_url_for_page(link_url) 58 | 59 | if url is None: 60 | 61 | # See if we're trying to link to an /subdir/index.html with /subdir 62 | link_url_index = os.path.join(link_url, "index.html") 63 | url_link_url_index = site.get_url_for_page(link_url_index) 64 | 65 | if url_link_url_index is None: 66 | logger.warning('%s: page resource does not exist: %s', page.link_url, link_url) 67 | 68 | url = link_url 69 | 70 | locale = site.config.get("locale") 71 | if locale is not None and site.verb == site.VERB_BUILD: 72 | # prepend links with language directory 73 | url = u"/%s%s" % (site.config.get("locale"), url) 74 | 75 | if site.prettify_urls: 76 | return url.rsplit('index.html', 1)[0] 77 | 78 | return url 79 | 80 | 81 | def config(context, key): 82 | """ 83 | Get a value from the config by key 84 | """ 85 | site = context['__CACTUS_SITE__'] 86 | result = site.config.get(key) 87 | 88 | if result: 89 | return result 90 | 91 | return "" 92 | 93 | 94 | def current_page(context): 95 | """ 96 | Returns the current URL 97 | """ 98 | page = context['__CACTUS_CURRENT_PAGE__'] 99 | 100 | return page.final_url 101 | 102 | 103 | def if_current_page(context, link_url, positive=True, negative=False): 104 | """ 105 | Return one of the passed parameters if the URL passed is the current one. 106 | For consistency reasons, we use the link_url of the page. 107 | """ 108 | page = context['__CACTUS_CURRENT_PAGE__'] 109 | 110 | return positive if page.link_url == link_url else negative 111 | 112 | @register.filter(is_safe=True) 113 | def markdown(value, arg=''): 114 | """ 115 | Runs Markdown over a given value, optionally using various 116 | extensions python-markdown supports. 117 | 118 | Syntax:: 119 | 120 | {{ value|markdown2:"extension1_name,extension2_name..." }} 121 | 122 | To enable safe mode, which strips raw HTML and only returns HTML 123 | generated by actual Markdown syntax, pass "safe" as the first 124 | extension in the list. 125 | 126 | If the version of Markdown in use does not support extensions, 127 | they will be silently ignored. 128 | 129 | """ 130 | try: 131 | import markdown2 132 | except ImportError: 133 | logging.warning("Markdown package not installed.") 134 | return force_text(value) 135 | else: 136 | def parse_extra(extra): 137 | if ':' not in extra: 138 | return (extra, {}) 139 | name, values = extra.split(':', 1) 140 | values = dict((str(val.strip()), True) for val in values.split('|')) 141 | return (name.strip(), values) 142 | 143 | extras = (e.strip() for e in arg.split(',')) 144 | extras = dict(parse_extra(e) for e in extras if e) 145 | 146 | if 'safe' in extras: 147 | del extras['safe'] 148 | safe_mode = True 149 | else: 150 | safe_mode = False 151 | 152 | return mark_safe(markdown2.markdown(force_text(value), extras=extras, safe_mode=safe_mode)) 153 | 154 | register.simple_tag(takes_context=True)(static) 155 | register.simple_tag(takes_context=True)(url) 156 | register.simple_tag(takes_context=True)(config) 157 | register.simple_tag(takes_context=True)(current_page) 158 | register.simple_tag(takes_context=True)(if_current_page) 159 | -------------------------------------------------------------------------------- /cactus/tests/__init__.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | import tempfile 4 | import shutil 5 | import unittest2 as unittest 6 | 7 | import django.conf 8 | 9 | from cactus.site import Site 10 | from cactus.bootstrap import bootstrap 11 | from cactus.config.router import ConfigRouter 12 | from cactus.utils.parallel import PARALLEL_DISABLED 13 | 14 | 15 | class BaseTestCase(unittest.TestCase): 16 | def setUp(self): 17 | self.test_dir = tempfile.mkdtemp() 18 | self.path = os.path.join(self.test_dir, 'test') 19 | self.clear_django_settings() 20 | 21 | def tearDown(self): 22 | shutil.rmtree(self.test_dir) 23 | 24 | def clear_django_settings(self): 25 | django.conf.settings._wrapped = django.conf.empty 26 | 27 | def assertFileExists(self, path): 28 | """ 29 | Check that a file at path exists. 30 | """ 31 | try: 32 | open(path) 33 | except IOError: 34 | path_dir = os.path.dirname(path) 35 | msg = [ 36 | "File does not exist: {0}".format(path), 37 | "The following files *did* exist in {0}: {1}".format(path_dir, os.listdir(path_dir)) 38 | ] 39 | self.fail("\n".join(msg)) 40 | 41 | def assertFileDoesNotExist(self, path): 42 | """ 43 | Check that the file at path does not exist. 44 | """ 45 | try: 46 | open(path) 47 | except IOError: 48 | pass 49 | else: 50 | self.fail("File exists: {0}".format(path)) 51 | 52 | 53 | class BaseBootstrappedTestCase(BaseTestCase): 54 | def setUp(self): 55 | super(BaseBootstrappedTestCase, self).setUp() 56 | bootstrap(self.path, os.path.join("cactus", "tests", "data", "skeleton")) 57 | 58 | 59 | class SiteTestCase(BaseBootstrappedTestCase): 60 | def setUp(self): 61 | super(SiteTestCase, self).setUp() 62 | self.config_path = os.path.join(self.path, 'config.json') 63 | self.conf = ConfigRouter([self.config_path]) 64 | self.conf.set('site-url', 'http://example.com/') 65 | for k, v in self.get_config_for_test().items(): 66 | self.conf.set(k, v) 67 | self.conf.write() 68 | 69 | self.site = Site(self.path, [self.config_path]) 70 | self.site._parallel = PARALLEL_DISABLED 71 | 72 | def get_config_for_test(self): 73 | """ 74 | Hook to set config keys in other tests. 75 | """ 76 | return {} 77 | -------------------------------------------------------------------------------- /cactus/tests/compat.py: -------------------------------------------------------------------------------- 1 | import os 2 | import os.path 3 | import shutil 4 | import tempfile 5 | 6 | 7 | has_symlink = False 8 | 9 | 10 | compat_test_dir = tempfile.mkdtemp() 11 | 12 | # Check for symlink support (available and usable) 13 | 14 | src = os.path.join(compat_test_dir, "src") 15 | dst = os.path.join(compat_test_dir, "dst") 16 | 17 | with open(src, "w"): 18 | pass 19 | 20 | try: 21 | os.symlink(src, dst) 22 | except (AttributeError, OSError): 23 | # AttributeError if symlink is not available (Python <= 3.2 on Windows) 24 | # OSError if we don't have the symlink privilege (on Windows) 25 | pass # Leave has_symlink false 26 | else: 27 | has_symlink = True 28 | 29 | 30 | shutil.rmtree(compat_test_dir) 31 | -------------------------------------------------------------------------------- /cactus/tests/data/koenpage-in.html: -------------------------------------------------------------------------------- 1 | name: Koen Bok 2 | age: 29 3 | {% extends "base.html" %} 4 | {% block content %} 5 | I am {{ name }} and {{ age }} years old. 6 | {% endblock %} -------------------------------------------------------------------------------- /cactus/tests/data/koenpage-out.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Welcome 13 | 14 | 15 | 16 | 17 | 18 | I am Koen Bok and 29 years old. 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /cactus/tests/data/plugins/empty.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eudicots/Cactus/b6dc9fb92248e1fd7fb6f44b57b8835802e9d880/cactus/tests/data/plugins/empty.py -------------------------------------------------------------------------------- /cactus/tests/data/plugins/test.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | 3 | class TestPluginMethod(object): 4 | def __init__(self, fn=None): 5 | self.calls = [] 6 | self.fn = fn 7 | 8 | def __call__(self, *args, **kwargs): 9 | self.calls.append({'args': args, 'kwargs': kwargs}) 10 | if self.fn is not None: 11 | return self.fn(*args, **kwargs) 12 | 13 | 14 | preBuildPage = TestPluginMethod(lambda page, context, data: (context, data,)) # site, page, context, data 15 | postBuildPage = TestPluginMethod() # page / site, page, context, data 16 | preBuild = TestPluginMethod() # site 17 | postBuild = TestPluginMethod() # site 18 | preDeploy = TestPluginMethod() # site 19 | postDeploy = TestPluginMethod() # site 20 | preDeployFile = TestPluginMethod() # file 21 | 22 | ORDER = 2 23 | -------------------------------------------------------------------------------- /cactus/tests/data/skeleton/locale/README.md: -------------------------------------------------------------------------------- 1 | Internalization 2 | =============== 3 | 4 | Using internalization with Cactus 5 | --------------------------------- 6 | 7 | To enable internalization for your project: 8 | 9 | 1. Add a `locale` key to (one of your) configuration file(s) 10 | 2. Mark strings for translation in your site (using `{% trans %}`) 11 | 3. Run `cactus makemessages` 12 | 4. Edit the .po file that was created with translations. 13 | 14 | 15 | Multiple languages with Cactus 16 | ------------------------------ 17 | 18 | To make the best of translations, you'll need multiple configuration files: one per language you'd like to support. 19 | 20 | This lets you transparently deploy multiple versions of your website to multiple buckets (one per language). -------------------------------------------------------------------------------- /cactus/tests/data/skeleton/pages/error.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 | 4 | 5 | 19 | 20 |

21 | Sorry the page could not be found on this server. 22 |

23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /cactus/tests/data/skeleton/pages/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 | Welcome to Cactus! 4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /cactus/tests/data/skeleton/pages/robots.txt: -------------------------------------------------------------------------------- 1 | {% verbatim %} 2 | User-agent: * 3 | Disallow: 4 | 5 | Sitemap: sitemap.xml 6 | {% endverbatim %} 7 | -------------------------------------------------------------------------------- /cactus/tests/data/skeleton/pages/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | {% for page in CACTUS.pages %}{% if page.path != 'error.html' %} 3 | 4 | {{ page.absolute_final_url }} 5 | daily 6 | 1.0 7 | {% endif %}{% endfor %} 8 | 9 | -------------------------------------------------------------------------------- /cactus/tests/data/skeleton/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eudicots/Cactus/b6dc9fb92248e1fd7fb6f44b57b8835802e9d880/cactus/tests/data/skeleton/plugins/__init__.py -------------------------------------------------------------------------------- /cactus/tests/data/skeleton/plugins/blog.disabled.py: -------------------------------------------------------------------------------- 1 | """ 2 | Modify `config.json` to set a custom blog path, default author name, or date pattern used to parse metadata. The defaults are: 3 | "blog": { 4 | "path": "blog", 5 | "author": "Unknown", 6 | "date-format": "%d-%m-%Y" 7 | } 8 | """ 9 | 10 | import os 11 | import datetime 12 | import logging 13 | 14 | ORDER = 999 15 | POSTS = [] 16 | 17 | from django.template import Context 18 | from django.template.loader import get_template 19 | from django.template.loader_tags import BlockNode, ExtendsNode 20 | 21 | def getNode(template, context=Context(), name='subject'): 22 | """ 23 | Get django block contents from a template. 24 | http://stackoverflow.com/questions/2687173/ 25 | django-how-can-i-get-a-block-from-a-template 26 | """ 27 | for node in template: 28 | if isinstance(node, BlockNode) and node.name == name: 29 | return node.render(context) 30 | elif isinstance(node, ExtendsNode): 31 | return getNode(node.nodelist, context, name) 32 | raise Exception("Node '%s' could not be found in template." % name) 33 | 34 | 35 | def preBuild(site): 36 | 37 | global POSTS 38 | siteContext = site.context() 39 | 40 | blog = site.config.get('blog', {}) 41 | blogPath = os.path.join(blog.get('path', 'blog'), '') 42 | dateFormat = blog.get('date-format', '%d-%m-%Y') 43 | defaultAuthor = blog.get('author', 'Unknown') 44 | 45 | # Build all the posts 46 | for page in site.pages(): 47 | if page.path.startswith(blogPath): 48 | 49 | # Skip non html posts for obious reasons 50 | if not page.path.endswith('.html'): 51 | continue 52 | 53 | # Find a specific defined variable in the page context, 54 | # and throw a warning if we're missing it. 55 | def find(name): 56 | c = page.context() 57 | if not name in c: 58 | logging.info("Missing info '%s' for post %s" % (name, page.path)) 59 | return '' 60 | return c.get(name, '') 61 | 62 | # Build a context for each post 63 | context = {'__CACTUS_CURRENT_PAGE__': page,} 64 | context.update(siteContext) 65 | 66 | postContext = {} 67 | postContext['title'] = find('title') 68 | postContext['author'] = find('author') or defaultAuthor 69 | postContext['date'] = find('date') 70 | postContext['path'] = page.final_url 71 | postContext['body'] = getNode(get_template(page.path), context=Context(context), name="body") 72 | 73 | # Parse the date into a date object 74 | try: 75 | postContext['date'] = datetime.datetime.strptime(postContext['date'], dateFormat) 76 | except Exception as e: 77 | logging.warning("Date format not correct for page %s, should be %s\n%s" % (page.path, dateFormat, e)) 78 | continue 79 | 80 | POSTS.append(postContext) 81 | 82 | # Sort the posts by date 83 | POSTS = sorted(POSTS, key=lambda x: x['date']) 84 | POSTS.reverse() 85 | 86 | indexes = xrange(0, len(POSTS)) 87 | 88 | for i in indexes: 89 | if i+1 in indexes: POSTS[i]['prevPost'] = POSTS[i+1] 90 | if i-1 in indexes: POSTS[i]['nextPost'] = POSTS[i-1] 91 | 92 | 93 | def preBuildPage(site, page, context, data): 94 | """ 95 | Add the list of posts to every page context so we can 96 | access them from wherever on the site. 97 | """ 98 | context['posts'] = POSTS 99 | 100 | for post in POSTS: 101 | if post['path'] == page.final_url: 102 | context.update(post) 103 | 104 | return context, data 105 | -------------------------------------------------------------------------------- /cactus/tests/data/skeleton/plugins/coffeescript.disabled.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pipes 3 | 4 | os.environ['PATH'] = '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/usr/local/share/npm/bin:' 5 | 6 | def postBuild(site): 7 | command = 'coffee -c %s/static/js/*.coffee' % pipes.quote(site.paths['build']) 8 | os.system(command) 9 | -------------------------------------------------------------------------------- /cactus/tests/data/skeleton/plugins/haml.disabled.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import codecs 4 | 5 | # How to: 6 | # * Install hamlpy (https://github.com/jessemiller/HamlPy) 7 | # * .haml files will compiled to .html files 8 | 9 | 10 | from hamlpy.hamlpy import Compiler 11 | from cactus.utils.filesystem import fileList 12 | 13 | CLEANUP = [] 14 | 15 | def preBuild(site): 16 | for path in fileList(site.paths['pages']): 17 | 18 | #only file ends with haml 19 | if not path.endswith('.haml'): 20 | continue 21 | 22 | #read the lines 23 | haml_lines = codecs.open(path, 'r', encoding='utf-8').read().splitlines() 24 | 25 | #compile haml to html 26 | compiler = Compiler() 27 | output = compiler.process_lines(haml_lines) 28 | 29 | #replace path 30 | outPath = path.replace('.haml', '.html') 31 | 32 | #write the html file 33 | with open(outPath,'w') as f: 34 | f.write(output) 35 | 36 | CLEANUP.append(outPath) 37 | 38 | 39 | def postBuild(site): 40 | global CLEANUP 41 | for path in CLEANUP: 42 | print path 43 | os.remove(path) 44 | CLEANUP = [] 45 | -------------------------------------------------------------------------------- /cactus/tests/data/skeleton/plugins/page_context.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | 3 | def preBuildPage(page, context, data): 4 | """ 5 | Updates the context of the page to include: the page itself as {{ CURRENT_PAGE }} 6 | """ 7 | 8 | # This will run for each page that Cactus renders. 9 | # Any changes you make to context will be passed to the template renderer for this page. 10 | 11 | extra = { 12 | "CURRENT_PAGE": page 13 | # Add your own dynamic context elements here! 14 | } 15 | 16 | context.update(extra) 17 | return context, data 18 | -------------------------------------------------------------------------------- /cactus/tests/data/skeleton/plugins/sass.disabled.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pipes 3 | 4 | def postBuild(site): 5 | os.system( 6 | 'sass -t compressed --update %s/static/css/*.sass' % 7 | pipes.quote(site.paths['build'] 8 | )) 9 | -------------------------------------------------------------------------------- /cactus/tests/data/skeleton/plugins/scss.disabled..py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import pipes 4 | import shutil 5 | import subprocess 6 | 7 | from cactus.utils.filesystem import fileList 8 | 9 | """ 10 | This plugin uses pyScss to translate sass files to css 11 | 12 | Install: 13 | 14 | sudo easy_install pyScss 15 | 16 | """ 17 | 18 | try: 19 | from scss import Scss 20 | except: 21 | sys.exit("Could not find pyScss, please install: sudo easy_install pyScss") 22 | 23 | 24 | CSS_PATH = 'static/css' 25 | 26 | for path in fileList(CSS_PATH): 27 | 28 | if not path.endswith('.scss'): 29 | continue 30 | 31 | with open(path, 'r') as f: 32 | data = f.read() 33 | 34 | css = Scss().compile(data) 35 | 36 | with open(path.replace('.scss', '.css'), 'w') as f: 37 | f.write(css) 38 | -------------------------------------------------------------------------------- /cactus/tests/data/skeleton/plugins/sprites.disabled.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import pipes 4 | import shutil 5 | import subprocess 6 | 7 | """ 8 | This plugin uses glue to sprite images: 9 | http://glue.readthedocs.org/en/latest/quickstart.html 10 | 11 | Install: 12 | 13 | (Only if you want to sprite jpg too) 14 | brew install libjpeg 15 | 16 | (Only if you want to optimize pngs with optipng) 17 | brew install optipng 18 | 19 | sudo easy_install pip 20 | sudo pip uninstall pil 21 | sudo pip install pil 22 | sudo pip install glue 23 | """ 24 | 25 | try: 26 | import glue 27 | except Exception as e: 28 | sys.exit('Could not use glue: %s\nMaybe install: sudo easy_install glue' % e) 29 | 30 | 31 | IMG_PATH = 'static/img/sprites' 32 | CSS_PATH = 'static/css/sprites' 33 | 34 | KEY = '_PREV_CHECKSUM' 35 | 36 | def checksum(path): 37 | command = 'md5 `find %s -type f`' % pipes.quote(IMG_PATH) 38 | return subprocess.check_output(command, shell=True) 39 | 40 | def preBuild(site): 41 | 42 | currChecksum = checksum(IMG_PATH) 43 | prevChecksum = getattr(site, KEY, None) 44 | 45 | # Don't run if none of the images has changed 46 | if currChecksum == prevChecksum: 47 | return 48 | 49 | if os.path.isdir(CSS_PATH): 50 | shutil.rmtree(CSS_PATH) 51 | 52 | os.mkdir(CSS_PATH) 53 | os.system('glue --cachebuster --crop --optipng "%s" "%s" --project' % (IMG_PATH, CSS_PATH)) 54 | 55 | setattr(site, KEY, currChecksum) 56 | -------------------------------------------------------------------------------- /cactus/tests/data/skeleton/plugins/static_optimizers.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | from cactus.contrib.external.closure import ClosureJSOptimizer 3 | from cactus.contrib.external.yui import YUICSSOptimizer 4 | 5 | 6 | def preBuild(site): 7 | """ 8 | Registers optimizers as requested by the configuration. 9 | Be sure to read the plugin to understand and use it. 10 | """ 11 | 12 | # Inspect the site configuration, and retrieve an `optimize` list. 13 | # This lets you configure optimization selectively. 14 | # You may want to use one configuration for staging with no optimizations, and one 15 | # configuration for production, with all optimizations. 16 | optimize = site.config.get("optimize", []) 17 | 18 | if "js" in optimize: 19 | # If `js` was found in the `optimize` key, then register our JS optimizer. 20 | # This uses closure, but you could use cactus.contrib.external.yui.YUIJSOptimizer! 21 | site.external_manager.register_optimizer(ClosureJSOptimizer) 22 | 23 | if "css" in optimize: 24 | # Same thing for CSS. 25 | site.external_manager.register_optimizer(YUICSSOptimizer) 26 | 27 | # Ass your own types here! 28 | -------------------------------------------------------------------------------- /cactus/tests/data/skeleton/plugins/version.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import os 3 | 4 | INFO = { 5 | 'name': 'Version Updater', 6 | 'description': 'Add a version to /versions.txt after each deploy' 7 | } 8 | 9 | # Set up extra django template tags 10 | 11 | def templateTags(): 12 | pass 13 | 14 | 15 | # Build actions 16 | 17 | # def preBuild(site): 18 | # print 'preBuild' 19 | # 20 | # def postBuild(site): 21 | # print 'postBuild' 22 | 23 | # Build page actions 24 | 25 | # def preBuildPage(site, path, context, data): 26 | # print 'preBuildPage', path 27 | # return context, data 28 | # 29 | # def postBuildPage(site, path): 30 | # print 'postBuildPage', path 31 | # pass 32 | 33 | 34 | # Deploy actions 35 | 36 | def preDeploy(site): 37 | 38 | # Add a deploy log at /versions.txt 39 | 40 | import urllib2 41 | import datetime 42 | import platform 43 | import codecs 44 | import getpass 45 | 46 | url = site.config.get('aws-bucket-website') 47 | data = u'' 48 | 49 | # If this is the first deploy we don't have to fetch the old file 50 | if url: 51 | try: 52 | data = urllib2.urlopen('http://%s/versions.txt' % url, timeout=8.0).read() + u'\n' 53 | except: 54 | print("Could not fetch the previous versions.txt, skipping...") 55 | return 56 | 57 | data += u'\t'.join([datetime.datetime.now().isoformat(), platform.node(), getpass.getuser()]) 58 | codecs.open(os.path.join(site.paths['build'], 'versions.txt'), 'w', 'utf8').write(data) 59 | 60 | def postDeploy(site): 61 | pass 62 | -------------------------------------------------------------------------------- /cactus/tests/data/skeleton/static/css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font: 14px/1.35em Arial, Helvetica, sans-serif; 3 | } -------------------------------------------------------------------------------- /cactus/tests/data/skeleton/static/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eudicots/Cactus/b6dc9fb92248e1fd7fb6f44b57b8835802e9d880/cactus/tests/data/skeleton/static/images/favicon.ico -------------------------------------------------------------------------------- /cactus/tests/data/skeleton/static/js/main.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | console.log("Hello!"); 3 | }); 4 | -------------------------------------------------------------------------------- /cactus/tests/data/skeleton/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {% block header %} 12 | Welcome 13 | {% endblock %} 14 | 15 | 16 | 17 | {% block content %} 18 | Main content 19 | {% endblock %} 20 | 21 | {% block scripts %} 22 | 23 | 24 | {% endblock %} 25 | 26 | 27 | -------------------------------------------------------------------------------- /cactus/tests/data/test-in.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 | This is a testpage! 4 | {% endblock %} -------------------------------------------------------------------------------- /cactus/tests/data/test-out.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Welcome 13 | 14 | 15 | 16 | 17 | 18 | This is a testpage! 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /cactus/tests/deployment/__init__.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import shutil 3 | import tempfile 4 | import unittest2 as unittest 5 | 6 | from cactus.plugin.manager import PluginManager 7 | from cactus.utils.parallel import PARALLEL_DISABLED 8 | from cactus.config.fallback import ConfigFallback 9 | from cactus.deployment.engine import BaseDeploymentEngine 10 | from cactus.deployment.file import BaseFile 11 | 12 | 13 | class DummyUI(object): 14 | """ 15 | A class to mock the site's UI and have it return what we need 16 | """ 17 | def __init__(self, bucket_name="test-bucket", create_bucket=True): 18 | self.bucket_name = bucket_name 19 | self.create_bucket = create_bucket 20 | 21 | self.asked_name = 0 22 | self.asked_create = 0 23 | 24 | def prompt_normalized(self, q): 25 | self.asked_name +=1 26 | return self.bucket_name 27 | 28 | def prompt_yes_no(self, q): 29 | self.asked_create += 1 30 | return self.create_bucket 31 | 32 | 33 | class DummyConfig(ConfigFallback): 34 | """ 35 | A ConfigFallback that remembers whether we saved 36 | """ 37 | def write(self): 38 | super(DummyConfig, self).write() 39 | self.saved = True 40 | 41 | 42 | class DummyPluginManager(PluginManager): 43 | """ 44 | Doesn't do anything 45 | """ 46 | def call(self, method, *args, **kwargs): 47 | """ 48 | Trap the call 49 | """ 50 | pass 51 | 52 | 53 | class DummySite(object): 54 | """ 55 | Something that pretends to be a site, as far as the deployment engine knows 56 | """ 57 | _parallel = PARALLEL_DISABLED 58 | 59 | def __init__(self, path, ui): 60 | self.build_path = path 61 | self.config = ConfigFallback() 62 | self.ui = ui 63 | self.plugin_manager = DummyPluginManager(self, []) 64 | self.compress_extensions = [] 65 | 66 | 67 | class DummyCredentialsManager(object): 68 | """ 69 | Something that looks like a CredentialsManager, as far as we know. 70 | """ 71 | _username = "123" 72 | _password = "456" 73 | 74 | def __init__(self, engine): 75 | self.engine = engine 76 | self.saved = False 77 | 78 | def get_credentials(self): 79 | return self._username, self ._password 80 | 81 | def save_credentials(self): 82 | self.saved = True 83 | 84 | 85 | class DummyFile(BaseFile): 86 | """ 87 | A fake file class we can extend to test things 88 | """ 89 | def __init__(self, engine, path): 90 | super(DummyFile, self).__init__(engine, path) 91 | self.engine.created_files.append(self) 92 | self.remote_changed_calls = 0 93 | self.do_upload_calls = 0 94 | 95 | def remote_changed(self): 96 | self.remote_changed_calls += 1 97 | return True 98 | 99 | def do_upload(self): 100 | self.do_upload_calls += 1 101 | pass 102 | 103 | 104 | class DummyDeploymentEngine(BaseDeploymentEngine): 105 | """ 106 | A deployment engine we can extend 107 | """ 108 | FileClass = DummyFile 109 | CredentialsManagerClass = DummyCredentialsManager 110 | 111 | config_bucket_name = "test-conf-entry" 112 | config_bucket_website = "test-conf-entry-website" 113 | 114 | def __init__(self, site): 115 | super(DummyDeploymentEngine, self).__init__(site) 116 | self.get_bucket_calls = 0 117 | self.create_bucket_calls = 0 118 | self.get_website_calls = 0 119 | self.created_files = [] 120 | 121 | def _create_connection(self): 122 | pass 123 | 124 | def get_bucket(self): 125 | self.get_bucket_calls += 1 126 | if self.create_bucket_calls: 127 | return "test-bucket-obj" 128 | return None 129 | 130 | def create_bucket(self): 131 | self.create_bucket_calls += 1 132 | return "test-bucket-obj" 133 | 134 | def get_website_endpoint(self): 135 | self.get_website_calls += 1 136 | return "http://test-bucket.com" 137 | 138 | 139 | class BaseDeploymentTestCase(unittest.TestCase): 140 | def setUp(self): 141 | self.test_dir = tempfile.mkdtemp() 142 | 143 | def tearDown(self): 144 | shutil.rmtree(self.test_dir) 145 | -------------------------------------------------------------------------------- /cactus/tests/deployment/test_bucket_create.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | from cactus.tests.deployment import DummyUI, DummySite, DummyDeploymentEngine, BaseDeploymentTestCase 3 | 4 | 5 | class BucketCreateTestCase(BaseDeploymentTestCase): 6 | def setUp(self): 7 | super(BucketCreateTestCase, self).setUp() 8 | self.ui = DummyUI() 9 | self.site = DummySite(self.test_dir, self.ui) 10 | self.engine = DummyDeploymentEngine(self.site) 11 | 12 | def test_bucket_does_not_exist(self): 13 | """ 14 | Test that we create buckets that don't exist 15 | """ 16 | self.assertEqual(0, self.engine.create_bucket_calls) 17 | self.assertEqual(0, self.ui.asked_create) 18 | 19 | self.engine.configure() 20 | 21 | self.assertEqual(1, self.engine.get_bucket_calls) 22 | self.assertEqual(1, self.engine.create_bucket_calls) 23 | self.assertEqual(1, self.ui.asked_create) 24 | 25 | def test_bucket_exists(self): 26 | """ 27 | Test that we don't attempt to re-create buckets that exist 28 | """ 29 | self.engine.create_bucket_calls = 1 30 | 31 | self.engine.configure() 32 | 33 | self.assertEqual(1, self.engine.create_bucket_calls) 34 | self.assertEqual(1, self.engine.get_bucket_calls) 35 | self.assertEqual(0, self.ui.asked_create) 36 | -------------------------------------------------------------------------------- /cactus/tests/deployment/test_bucket_name.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | from cactus.tests.deployment import DummyUI, DummySite, DummyDeploymentEngine, BaseDeploymentTestCase 3 | 4 | 5 | class BucketNameTestCase(BaseDeploymentTestCase): 6 | def setUp(self): 7 | super(BucketNameTestCase, self).setUp() 8 | self.ui = DummyUI(create_bucket=False) 9 | self.site = DummySite(self.test_dir, self.ui) 10 | self.engine = DummyDeploymentEngine(self.site) 11 | 12 | def test_not_configured(self): 13 | """ 14 | Test that we prompt the bucket name in case it's not configured 15 | """ 16 | self.assertEqual(0, self.ui.asked_name) 17 | 18 | self.engine.configure() 19 | 20 | self.assertEqual(1, self.ui.asked_name) 21 | self.assertEqual("test-bucket", self.engine.bucket_name) 22 | 23 | def test_configured(self): 24 | """ 25 | Test that we don't prompt the bucket name in case it's configured 26 | """ 27 | self.site.config.set("test-conf-entry", "test-bucket") 28 | 29 | self.assertEqual(0, self.ui.asked_name) 30 | 31 | self.engine.configure() 32 | 33 | self.assertEqual(0, self.ui.asked_name) 34 | self.assertEqual("test-bucket", self.engine.bucket_name) 35 | -------------------------------------------------------------------------------- /cactus/tests/deployment/test_engine_api.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | from cactus.tests.deployment import DummyUI, DummySite, DummyDeploymentEngine, BaseDeploymentTestCase 3 | 4 | 5 | class BucketCreateTestCase(BaseDeploymentTestCase): 6 | def setUp(self): 7 | super(BucketCreateTestCase, self).setUp() 8 | self.ui = DummyUI() 9 | self.site = DummySite(self.test_dir, self.ui) 10 | self.engine = DummyDeploymentEngine(self.site) 11 | self.engine.configure() 12 | 13 | def test_bucket_attrs(self): 14 | """ 15 | Test that the bucket name is provided 16 | """ 17 | self.assertEqual("test-bucket", self.engine.bucket_name) 18 | self.assertEqual("test-bucket-obj", self.engine.bucket) 19 | 20 | def test_config_saved(self): 21 | """ 22 | Test that the configuration is saved 23 | """ 24 | self.assertEqual("test-bucket", self.site.config.get("test-conf-entry")) 25 | self.assertEqual("http://test-bucket.com", self.site.config.get("test-conf-entry-website")) 26 | 27 | def test_credentials_saved(self): 28 | """ 29 | Test that the credentials are saved 30 | """ 31 | self.assertTrue(self.engine.credentials_manager.saved) 32 | -------------------------------------------------------------------------------- /cactus/tests/deployment/test_file.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | 4 | from cactus.tests.deployment import BaseDeploymentTestCase, DummyDeploymentEngine, DummySite, DummyUI, DummyFile 5 | 6 | 7 | class FileChangedTestCase(BaseDeploymentTestCase): 8 | def setUp(self): 9 | super(FileChangedTestCase, self).setUp() 10 | self.ui = DummyUI() 11 | self.site = DummySite(self.test_dir, self.ui) 12 | self.engine = DummyDeploymentEngine(self.site) 13 | with open(os.path.join(self.test_dir, "123.html"), "w") as f: 14 | f.write("Hello!") 15 | 16 | def test_file_unchanged(self): 17 | """ 18 | Test that we don't attempt to deploy unchanged files 19 | """ 20 | class TestFile(DummyFile): 21 | def remote_changed(self): 22 | super(TestFile, self).remote_changed() 23 | return False 24 | 25 | self.engine.FileClass = TestFile 26 | self.engine.deploy() 27 | files = self.engine.created_files 28 | self.assertEqual(1, len(files)) 29 | 30 | f = files[0] 31 | 32 | self.assertEqual(1, f.remote_changed_calls) 33 | self.assertEqual(0, f.do_upload_calls) 34 | 35 | 36 | def test_file_changed(self): 37 | """ 38 | Test that we deploy files that changed 39 | """ 40 | class TestFile(DummyFile): 41 | def remote_changed(self): 42 | super(TestFile, self).remote_changed() 43 | return True 44 | 45 | self.engine.FileClass = TestFile 46 | self.engine.deploy() 47 | files = self.engine.created_files 48 | self.assertEqual(1, len(files)) 49 | 50 | f = files[0] 51 | 52 | self.assertEqual(1, f.remote_changed_calls) 53 | self.assertEqual(1, f.do_upload_calls) 54 | -------------------------------------------------------------------------------- /cactus/tests/integration/s3/__init__.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | import re 4 | 5 | from cactus.deployment.s3.engine import S3DeploymentEngine 6 | from cactus.utils.helpers import checksum 7 | 8 | from cactus.tests.integration import IntegrationTestCase, DebugHTTPSConnectionFactory, BaseTestHTTPConnection, \ 9 | TestHTTPResponse 10 | 11 | 12 | class DummyAWSCredentialsManager(object): 13 | def __init__(self, site): 14 | self.site = site 15 | 16 | def get_credentials(self): 17 | return "123", "abc" 18 | 19 | def save_credentials(self): 20 | pass 21 | 22 | 23 | class S3TestHTTPConnection(BaseTestHTTPConnection): 24 | 25 | def handle_request(self, request): 26 | if request.method == "GET": 27 | # List buckets 28 | if request.path == "/": 29 | if request.params == {}: 30 | return self.list_buckets() 31 | # Request for just one bucket (like /bucket/ - regex is not perfect but should do for here) 32 | if re.match(r"/[a-z1-9\-.]+/", request.path): 33 | if "location" in request.params: 34 | return self.location() 35 | 36 | if request.method == "PUT": 37 | if request.path == "/": 38 | return TestHTTPResponse(200) 39 | return self.put_object(request) 40 | 41 | raise Exception("Unsupported request {0} {1}".format(request.method, request.url)) 42 | 43 | def _serve_data(self, name): 44 | with open(os.path.join("cactus/tests/integration/s3/data", name)) as f: 45 | return TestHTTPResponse(200, body=f.read()) 46 | 47 | def list_buckets(self): 48 | return self._serve_data("buckets.xml") 49 | 50 | def location(self): 51 | return self._serve_data("location.xml") 52 | 53 | def put_object(self, req): 54 | return TestHTTPResponse(200, headers={"ETag":'"{0}"'.format(checksum(req.body))}) 55 | 56 | 57 | class S3IntegrationTestCase(IntegrationTestCase): 58 | def get_deployment_engine_class(self): 59 | # Create a connection factory 60 | self.connection_factory = DebugHTTPSConnectionFactory(S3TestHTTPConnection) 61 | 62 | class TestS3DeploymentEngine(S3DeploymentEngine): 63 | _s3_https_connection_factory = (self.connection_factory, ()) 64 | CredentialsManagerClass = DummyAWSCredentialsManager 65 | 66 | return TestS3DeploymentEngine 67 | 68 | def get_credentials_manager_class(self): 69 | return DummyAWSCredentialsManager 70 | -------------------------------------------------------------------------------- /cactus/tests/integration/s3/data/buckets.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | bcaf1ffd86f461ca5fb16fd081034f 5 | webfile 6 | 7 | 8 | 9 | website 10 | 2006-02-03T16:45:09.000Z 11 | 12 | 13 | other 14 | 2006-02-03T16:41:58.000Z 15 | 16 | 17 | -------------------------------------------------------------------------------- /cactus/tests/integration/s3/data/location.xml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cactus/tests/integration/s3/test_bucket.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | from cactus.tests.integration.s3 import S3IntegrationTestCase 3 | 4 | 5 | class BucketTestCase(S3IntegrationTestCase): 6 | def test_create_bucket(self): 7 | """ 8 | Test that we properly create a bucket in AWS 9 | """ 10 | self.site.deployment_engine.bucket_name = "new" 11 | bucket = self.site.deployment_engine.create_bucket() 12 | 13 | self.assertEqual("new", bucket.name) 14 | 15 | self.assertEqual(2, len(self.connection_factory.requests)) 16 | new, configure = self.connection_factory.requests 17 | 18 | self.assertEqual("/new/", new.path) 19 | self.assertEqual("PUT", new.method) 20 | 21 | self.assertEqual("/new/?website", configure.url) 22 | self.assertEqual("PUT", configure.method) 23 | 24 | for req in (new, configure): 25 | self.assertEqual("s3.amazonaws.com", req.connection.host) 26 | 27 | def test_list_buckets(self): 28 | """ 29 | Test that we retrieve the correct list of buckets from AWS 30 | """ 31 | buckets = self.site.deployment_engine._get_buckets() 32 | 33 | # Check that the right buckets are returned 34 | bucket_names = [bucket.name for bucket in buckets] 35 | self.assertEqual(sorted(bucket_names), sorted(["website", "other"])) 36 | 37 | # Check that we only made one connection to list buckets 38 | self.assertEqual(1, len(self.connection_factory.requests)) 39 | req = self.connection_factory.requests[0] 40 | self.assertEqual("GET", req.method) 41 | self.assertEqual("/", req.path) 42 | 43 | def test_get_bucket(self): 44 | """ 45 | Test that we access the correct bucket in AWS 46 | """ 47 | self.site.deployment_engine.bucket_name = "other" 48 | bucket = self.site.deployment_engine.get_bucket() 49 | 50 | # Check that just one bucket was retried 51 | self.assertEqual("other", bucket.name) 52 | 53 | # Check that we only made one connection to list buckets 54 | self.assertEqual(1, len(self.connection_factory.requests)) 55 | req = self.connection_factory.requests[0] 56 | self.assertEqual("GET", req.method) 57 | self.assertEqual("/", req.path) 58 | -------------------------------------------------------------------------------- /cactus/tests/integration/s3/test_deploy.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | from __future__ import unicode_literals 3 | 4 | import os 5 | import io 6 | import gzip 7 | 8 | from six import BytesIO 9 | 10 | from cactus.deployment.file import BaseFile 11 | 12 | from cactus.tests.integration.s3 import S3IntegrationTestCase 13 | 14 | 15 | class DeployTestCase(S3IntegrationTestCase): 16 | def setUp(self): 17 | super(DeployTestCase, self).setUp() 18 | self.site.config.set('aws-bucket-name', 'website') 19 | 20 | def test_simple_deploy(self): 21 | """ 22 | Test our file deployment 23 | 24 | - Uploaded 25 | - Have the correct name 26 | - Compressed (if useful) -- #TODO 27 | - Publicly readable 28 | """ 29 | bucket_name = self.site.config.get("aws-bucket-name") 30 | 31 | payload = "\x01" * 1000 + "\x02" * 1000 # Will compress very well 32 | payload = payload.encode('utf-8') 33 | 34 | with io.FileIO(os.path.join(self.site.static_path, "static.css"), 'w') as f: 35 | f.write(payload) 36 | 37 | self.site.upload() 38 | 39 | puts = [req for req in self.connection_factory.requests if req.method == "PUT"] 40 | 41 | # How many files did we upload? 42 | self.assertEqual(1, len(puts)) 43 | put = puts[0] 44 | 45 | # What file did we upload? 46 | self.assertEqual("/{0}/static/static.css".format(bucket_name), put.url) 47 | 48 | # Where the AWS standard headers correct? 49 | self.assertEqual("public-read", put.headers["x-amz-acl"]) 50 | self.assertEqual("gzip", put.headers["content-encoding"]) 51 | self.assertEqual("max-age={0}".format(BaseFile.DEFAULT_CACHE_EXPIRATION), put.headers["cache-control"]) 52 | # We just have to check that the max age is set. Another test (test_deploy) checks that this value can be 53 | # changed using plugins 54 | 55 | # Did we use the correct access key? 56 | self.assertEqual("AWS 123", put.headers["authorization"].split(':')[0]) 57 | 58 | # Did we talk to the right host? 59 | self.assertEqual("s3.amazonaws.com", put.connection.host) 60 | 61 | # Are the file contents correct? 62 | compressed = gzip.GzipFile(fileobj=BytesIO(put.body), mode="r") 63 | self.assertEqual(payload, compressed.read()) 64 | 65 | def test_compression(self): 66 | compress_extensions = ["yes", "html"] 67 | payload = "\x01" * 1000 + "\x02" * 1000 # Will compress very well 68 | payload = payload.encode('utf-8') 69 | 70 | self.site.compress_extensions = compress_extensions 71 | 72 | with io.FileIO(os.path.join(self.site.static_path, "static.yes"), 'wb') as f: 73 | f.write(payload) 74 | 75 | with io.FileIO(os.path.join(self.site.static_path, "static.no"), 'wb') as f: 76 | f.write(payload) 77 | 78 | self.site.upload() 79 | 80 | puts = [req for req in self.connection_factory.requests if req.method == "PUT"] 81 | 82 | self.assertEqual(2, len(puts)) 83 | compressed = 0 84 | for put in puts: 85 | if put.url.rsplit(".", 1)[1] in compress_extensions: 86 | self.assertEqual("gzip", put.headers["content-encoding"]) 87 | compressed += 1 88 | else: 89 | self.assertIsNone(put.headers.get("content-encoding")) 90 | 91 | self.assertEqual(1, compressed) 92 | -------------------------------------------------------------------------------- /cactus/tests/integration/s3/test_workflow.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | from cactus.tests.integration.s3 import S3TestHTTPConnection, S3IntegrationTestCase 3 | 4 | 5 | class WorkflowTestCase(S3IntegrationTestCase): 6 | HTTPConnectionClass = S3TestHTTPConnection 7 | 8 | def test_bucket_name_unset(self): 9 | """ 10 | Test that we prompt the user for the name of the bucket 11 | """ 12 | self.assertEqual(None, self.site.config.get('aws-bucket-name')) 13 | self.assertEqual(None, self.site.config.get('aws-bucket-website')) 14 | 15 | bucket_name = "website" # Need to pick a name that is in our XML list! 16 | 17 | self.site.ui.prompt_normalized = lambda q: bucket_name 18 | self.site.upload() 19 | 20 | self.assertEqual(bucket_name, self.site.config.get('aws-bucket-name')) 21 | self.assertEqual("{0}.s3-website-us-east-1.amazonaws.com".format(bucket_name), 22 | self.site.config.get('aws-bucket-website')) # See the response we send (US standard). 23 | 24 | def test_no_bucket_create(self): 25 | """ 26 | Test that we prompt for bucket creation if the bucket does not exist 27 | """ 28 | bucket_name = "does-not-exist" 29 | 30 | self.site.config.set('aws-bucket-name', bucket_name) 31 | self.assertEqual(None, self.site.config.get('aws-bucket-website')) 32 | 33 | self.site.ui.prompt_yes_no = lambda q: True 34 | self.site.upload() 35 | 36 | # Check that we retrieved the list and created a bucket 37 | self.assertEqual(4, len(self.connection_factory.requests)) 38 | list_buckets, create_bucket, enable_website, retrieve_location = self.connection_factory.requests 39 | 40 | self.assertEqual("/", list_buckets.url) 41 | self.assertEqual("GET", list_buckets.method) 42 | 43 | self.assertEqual("/{0}/".format(bucket_name), create_bucket.url) 44 | self.assertEqual("s3.amazonaws.com".format(bucket_name), create_bucket.connection.host) 45 | self.assertEqual("PUT", create_bucket.method) 46 | 47 | self.assertEqual("/{0}/?website".format(bucket_name), enable_website.url) 48 | self.assertEqual("PUT", enable_website.method) 49 | 50 | self.assertEqual("/{0}/?location".format(bucket_name), retrieve_location.url) 51 | self.assertEqual("GET", retrieve_location.method) 52 | 53 | # Check that we updated our config 54 | self.assertEqual("{0}.s3-website-us-east-1.amazonaws.com".format(bucket_name), 55 | self.site.config.get('aws-bucket-website')) # See the response we send (US standard). 56 | 57 | def test_credentials_manager(self): 58 | """ 59 | Test that credentials are saved in the manager 60 | """ 61 | class DummyCredentialsManager(object): 62 | saved = False 63 | 64 | def get_credentials(self): 65 | return {"access_key": '123', "secret_key": '456'} 66 | 67 | def save_credentials(self): 68 | self.saved = True 69 | 70 | self.site.deployment_engine.credentials_manager = DummyCredentialsManager() 71 | self.site.config.set('aws-bucket-name', 'website') 72 | self.site.upload() 73 | 74 | self.assertTrue(self.site.deployment_engine.credentials_manager.saved) 75 | -------------------------------------------------------------------------------- /cactus/tests/test_basic.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | import shutil 4 | 5 | from cactus.tests import SiteTestCase 6 | from cactus.utils.filesystem import fileList 7 | from cactus.utils.url import path_to_url 8 | 9 | 10 | class TestSite(SiteTestCase): 11 | def testBuild(self): 12 | """ 13 | Test that we build the proper files. 14 | """ 15 | self.site.build() 16 | 17 | # Make sure we build to .build and not build 18 | self.assertEqual(os.path.exists(os.path.join(self.path, 'build')), False) 19 | 20 | self.assertEqual( 21 | sorted([path_to_url(path) for path in fileList(os.path.join(self.path, '.build'), relative=True)]), 22 | sorted([ 23 | 'error.html', 24 | 'index.html', 25 | 'robots.txt', 26 | 'sitemap.xml', 27 | self.site.get_url_for_static('/static/css/style.css')[1:], # Strip the initial / 28 | self.site.get_url_for_static('/static/images/favicon.ico')[1:], # Strip the initial / 29 | self.site.get_url_for_static('/static/js/main.js')[1:], # Strip the initial / 30 | ])) 31 | 32 | def testRenderPage(self): 33 | """ 34 | Test that pages get rendered. 35 | """ 36 | 37 | shutil.copy( 38 | os.path.join('cactus', 'tests', 'data', "test-in.html"), 39 | os.path.join(self.path, 'pages', 'test.html') 40 | ) 41 | 42 | 43 | self.site.build() 44 | 45 | with open(os.path.join('cactus', 'tests', 'data', 'test-out.html'), "rU") as expected: 46 | with open(os.path.join(self.path, '.build', 'test.html'), "rU") as obtained: 47 | self.assertEqual(expected.read(), obtained.read()) 48 | 49 | def testPageContext(self): 50 | """ 51 | Test that page context is parsed and uses in the pages. 52 | """ 53 | 54 | shutil.copy( 55 | os.path.join('cactus', 'tests', 'data', "koenpage-in.html"), 56 | os.path.join(self.path, 'pages', 'koenpage.html') 57 | ) 58 | 59 | self.site.build() 60 | 61 | with open(os.path.join('cactus', 'tests', 'data', 'koenpage-out.html'), "rU") as expected: 62 | with open(os.path.join(self.path, '.build', 'koenpage.html'), "rU") as obtained: 63 | self.assertEqual(expected.read(), obtained.read()) 64 | 65 | def test_html_only_context(self): 66 | """ 67 | Test that we only parse context on HTML pages. 68 | """ 69 | robots_txt = 'Disallow:/page1\nDisallow:/page2' 70 | 71 | with open(os.path.join(self.site.path, 'pages', 'robots.txt'), 'w') as f: 72 | f.write(robots_txt) 73 | 74 | self.site.build() 75 | 76 | with open(os.path.join(self.site.build_path, 'robots.txt'), 'rU') as f: 77 | self.assertEquals(robots_txt, f.read()) 78 | 79 | def testStaticLoader(self): 80 | """ 81 | Test that the static URL builde and template tag work. 82 | """ 83 | static = '/static/css/style.css' 84 | page = "{%% static '%s' %%}" % static 85 | 86 | 87 | with open(os.path.join(self.path, 'pages', 'staticpage.html'), "w") as dst: 88 | dst.write(page) 89 | 90 | self.site.build() 91 | 92 | with open(os.path.join(self.path, '.build', 'staticpage.html'), "rU") as obtained: 93 | self.assertEqual(self.site.get_url_for_static(static), obtained.read()) 94 | 95 | def test_current_page(self): 96 | """ 97 | Test that the if_current_page tag works. 98 | """ 99 | page = 'page1.html' 100 | content = "{%% if_current_page '/%s' %%}" % page 101 | 102 | other = 'page2.html' 103 | 104 | with open(os.path.join(self.path, 'pages', page), 'w') as f: 105 | f.write(content) 106 | 107 | with open(os.path.join(self.path, 'pages', other), 'w') as f: 108 | f.write(content) 109 | 110 | self.site.build() 111 | 112 | with open(os.path.join(self.path, '.build', page), 'rU') as f: 113 | self.assertEqual('True', f.read()) 114 | 115 | with open(os.path.join(self.path, '.build', other), 'rU') as f: 116 | self.assertEqual('False', f.read()) 117 | -------------------------------------------------------------------------------- /cactus/tests/test_bootstrap.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | import shutil 4 | import tarfile 5 | import tempfile 6 | import unittest 7 | import zipfile 8 | import threading 9 | import random 10 | 11 | from six.moves import BaseHTTPServer, SimpleHTTPServer, xrange 12 | 13 | from cactus.bootstrap import bootstrap 14 | from cactus.tests import BaseBootstrappedTestCase 15 | from cactus.utils.filesystem import fileList 16 | 17 | 18 | def ArchiveServerHandlerFactory(archive_path): 19 | class ArchiveHandler(SimpleHTTPServer.SimpleHTTPRequestHandler): 20 | def do_GET(self): 21 | """ 22 | Reply with the archive. 23 | """ 24 | self.send_response(200) 25 | self.end_headers() 26 | with open(archive_path, 'rb') as f: 27 | self.copyfile(f, self.wfile) 28 | 29 | def log_request(self, code='-', size='-'): 30 | """ 31 | Discard log requests to clear up test output. 32 | """ 33 | return 34 | 35 | return ArchiveHandler 36 | 37 | 38 | class TestFolderBootstrap(BaseBootstrappedTestCase): 39 | def test_bootstrap(self): 40 | self.assertEqual( 41 | sorted(fileList(self.path, relative=True)), 42 | sorted(fileList("cactus/tests/data/skeleton", relative=True)), 43 | ) 44 | 45 | 46 | class TestCactusPackageBootstrap(BaseBootstrappedTestCase): 47 | def setUp(self): 48 | self.test_dir = tempfile.mkdtemp() 49 | self.path = os.path.join(self.test_dir, 'test') 50 | self.clear_django_settings() 51 | bootstrap(self.path) 52 | 53 | def test_bootstrap(self): 54 | self.assertEqual( 55 | sorted(fileList(self.path, relative=True)), 56 | sorted(fileList("cactus/skeleton", relative=True)), 57 | ) 58 | 59 | 60 | class BaseTestArchiveBootstrap(object): 61 | def setUp(self): 62 | self.test_dir = tempfile.mkdtemp() 63 | self.path = os.path.join(self.test_dir, 'test') 64 | self.skeleton_path = "cactus/skeleton" 65 | self.archive_path = os.path.join(self.test_dir, "archive") 66 | 67 | with open(self.archive_path, "wb") as f: 68 | self.make_archive(f) 69 | 70 | def make_archive(self, f): 71 | raise NotImplementedError() 72 | 73 | def tearDown(self): 74 | shutil.rmtree(self.test_dir) 75 | 76 | def test_file(self): 77 | # Test 78 | bootstrap(self.path, self.archive_path) 79 | 80 | self.assertEqual( 81 | sorted(fileList(self.path, relative=True)), 82 | sorted(fileList(self.skeleton_path, relative=True)), 83 | ) 84 | 85 | def test_url(self): 86 | archive_path = self.archive_path 87 | 88 | port = random.choice(xrange(7000, 10000)) 89 | 90 | server_address = ("127.0.0.1", port) 91 | httpd = BaseHTTPServer.HTTPServer(server_address, ArchiveServerHandlerFactory(archive_path)) 92 | t = threading.Thread(target=httpd.serve_forever) 93 | t.start() 94 | 95 | bootstrap(self.path, "http://127.0.0.1:%s" % port) 96 | httpd.shutdown() 97 | 98 | self.assertEqual( 99 | sorted(fileList(self.path, relative=True)), 100 | sorted(fileList(self.skeleton_path, relative=True)), 101 | ) 102 | 103 | 104 | class ZIPTestArchiveBootstrap(BaseTestArchiveBootstrap, unittest.TestCase): 105 | """ 106 | Test ZIP archive support 107 | """ 108 | def make_archive(self, f): 109 | archive = zipfile.ZipFile(f, mode="w") 110 | for resource in fileList(self.skeleton_path, relative=True): 111 | archive.write(os.path.join(self.skeleton_path, resource), resource) 112 | archive.close() 113 | 114 | 115 | class TARTestArchiveBootstrap(BaseTestArchiveBootstrap, unittest.TestCase): 116 | """ 117 | Test TAR archive support 118 | """ 119 | def make_archive(self, f): 120 | archive = tarfile.open(f.name, fileobj=f, mode="w") 121 | for resource in fileList(self.skeleton_path, relative=True): 122 | archive.add(os.path.join(self.skeleton_path, resource), resource) 123 | archive.close() 124 | -------------------------------------------------------------------------------- /cactus/tests/test_build.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | import tempfile 4 | import unittest2 as unittest 5 | 6 | from cactus.tests import SiteTestCase 7 | from cactus.tests.compat import has_symlink 8 | 9 | 10 | class TestBuild(SiteTestCase): 11 | 12 | @unittest.skipUnless(has_symlink, "No symlink support") 13 | def test_existing_symlink(self): 14 | 15 | with open(os.path.join(self.site.static_path, 'file.js'), "w") as f: 16 | f.write("hello") 17 | 18 | os.symlink( 19 | os.path.join(self.site.static_path, 'file.js'), 20 | os.path.join(self.site.static_path, 'file-link.js')) 21 | 22 | self.site.build() 23 | 24 | self.assertFileExists(os.path.join(self.site.build_path, 'static', 'file.js')) 25 | self.assertFileExists(os.path.join(self.site.build_path, 'static', 'file-link.js')) 26 | 27 | with open(os.path.join(self.site.build_path, 'static', 'file-link.js')) as f: 28 | self.assertEqual(f.read(), "hello") 29 | 30 | self.assertEqual( 31 | os.path.islink(os.path.join(self.site.build_path, 'static', 'file-link.js')), False) 32 | 33 | @unittest.skipUnless(has_symlink, "No symlink support") 34 | def test_nonexisting_symlink(self): 35 | 36 | os.symlink( 37 | os.path.join(self.site.static_path, 'file.js'), 38 | os.path.join(self.site.static_path, 'file-link.js')) 39 | 40 | self.site.build() 41 | 42 | self.assertFileDoesNotExist(os.path.join(self.site.build_path, 'static', 'file.js')) 43 | self.assertFileDoesNotExist(os.path.join(self.site.build_path, 'static', 'file-link.js')) 44 | 45 | def test_pages_binary_file(self): 46 | 47 | with open(os.path.join(self.site.page_path, 'file.zip'), "wb") as f: 48 | f.write(os.urandom(1024)) 49 | 50 | self.site.build() 51 | 52 | def test_listener_ignores(self): 53 | 54 | # Some complete;y random files 55 | self.assertEqual(True, self.site._rebuild_should_ignore("/Users/test/a.html")) 56 | self.assertEqual(True, self.site._rebuild_should_ignore("a.html")) 57 | self.assertEqual(True, self.site._rebuild_should_ignore("/a.html")) 58 | 59 | # Some plausible files 60 | self.assertEqual(True, self.site._rebuild_should_ignore( 61 | os.path.join(self.site.path, "config.json"))) 62 | self.assertEqual(True, self.site._rebuild_should_ignore( 63 | os.path.join(self.site.path, "readme.txt"))) 64 | self.assertEqual(True, self.site._rebuild_should_ignore( 65 | os.path.join(self.site.path, ".git", "config"))) 66 | self.assertEqual(True, self.site._rebuild_should_ignore( 67 | os.path.join(self.site.path, ".build", "index.html"))) 68 | self.assertEqual(True, self.site._rebuild_should_ignore( 69 | os.path.join(self.site.path, ".build", "static", "main.js"))) 70 | 71 | # Files we do want 72 | self.assertEqual(False, self.site._rebuild_should_ignore( 73 | os.path.join(self.site.path, "pages", "index.html"))) 74 | self.assertEqual(False, self.site._rebuild_should_ignore( 75 | os.path.join(self.site.path, "pages", "staging", "index.html"))) 76 | self.assertEqual(False, self.site._rebuild_should_ignore( 77 | os.path.join(self.site.path, "templates", "base.html"))) 78 | self.assertEqual(False, self.site._rebuild_should_ignore( 79 | os.path.join(self.site.path, "static", "main.js"))) 80 | self.assertEqual(False, self.site._rebuild_should_ignore( 81 | os.path.join(self.site.path, "plugins", "test.py"))) 82 | 83 | # Dotfile stuff 84 | self.assertEqual(True, self.site._rebuild_should_ignore( 85 | os.path.join(self.site.path, "pages", ".DS_Store"))) 86 | self.assertEqual(True, self.site._rebuild_should_ignore( 87 | os.path.join(self.site.path, "pages", ".hidden", "a.html"))) 88 | 89 | @unittest.skipUnless(has_symlink, "No symlink support") 90 | def test_symlink_ignore(self): 91 | 92 | file_path = os.path.join(tempfile.mkdtemp(), 'file.js') 93 | link_path = os.path.join(self.site.static_path, 'file-link.js') 94 | 95 | with open(file_path, "w") as f: 96 | f.write("hello") 97 | 98 | os.symlink(file_path, link_path) 99 | 100 | # Test for ignore function to work with symlinks 101 | self.assertEqual(self.site._rebuild_should_ignore(link_path), False) 102 | -------------------------------------------------------------------------------- /cactus/tests/test_cli.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import sys 3 | import os 4 | import subprocess 5 | import time 6 | 7 | import requests 8 | from requests.adapters import HTTPAdapter 9 | from requests.packages.urllib3 import Retry 10 | 11 | from cactus.tests import BaseTestCase 12 | from cactus.utils.filesystem import fileList 13 | 14 | 15 | # Python < 3.3 compatibility 16 | if not hasattr(subprocess, "DEVNULL"): 17 | subprocess.DEVNULL = open(os.devnull, 'w') 18 | 19 | 20 | class CliTestCase(BaseTestCase): 21 | def find_cactus(self): 22 | """ 23 | Locate a Cactus executable and ensure that it'll run using the right interpreter. 24 | This is meant to work well in a Tox environment. That's how we run our tests, so that's all that really 25 | matters here. 26 | """ 27 | bin_path = os.path.abspath(os.path.dirname(sys.executable)) 28 | 29 | try: 30 | path = os.path.join(bin_path, "cactus") 31 | with open(path) as f: 32 | self.assertEqual("#!{0}".format(sys.executable), f.readline().strip()) 33 | except IOError: 34 | pass 35 | else: 36 | return path 37 | 38 | try: 39 | path = os.path.join(bin_path, "cactus.exe") 40 | with open(path): 41 | pass 42 | except IOError: 43 | pass 44 | else: 45 | return path 46 | 47 | self.fail("Unable to find Cactus") 48 | 49 | def run_cli(self, args, stdin="", cwd=None): 50 | real_args = [self.find_cactus()] 51 | real_args.extend(args) 52 | 53 | kwargs = { 54 | "args": real_args, 55 | "stdin": subprocess.PIPE, 56 | "stdout": subprocess.PIPE, 57 | "stderr": subprocess.PIPE, 58 | } 59 | 60 | if cwd is not None: 61 | kwargs["cwd"] = cwd 62 | 63 | p = subprocess.Popen(**kwargs) 64 | out, err = p.communicate(stdin.encode("utf-8")) 65 | return p.returncode, out.decode("utf-8"), err.decode("utf-8") 66 | 67 | def test_create_and_build(self): 68 | self.assertEqual(0, len(os.listdir(self.test_dir))) 69 | 70 | # Test regular create 71 | ret, out, err = self.run_cli(["create", self.path, "--skeleton", os.path.join("cactus", "tests", "data", "skeleton")]) 72 | self.assertEqual(0, ret) 73 | 74 | self.assertEqual( 75 | sorted(fileList(self.path, relative=True)), 76 | sorted(fileList("cactus/tests/data/skeleton", relative=True)), 77 | ) 78 | 79 | # Test that we prompt to move stuff out of the way 80 | _, _, _ = self.run_cli(["create", "-v", self.path], "n\n") 81 | self.assertEqual(1, len(os.listdir(self.test_dir))) 82 | 83 | _, _, _ = self.run_cli(["create", "-q", self.path], "y\n") 84 | self.assertEqual(2, len(os.listdir(self.test_dir))) 85 | 86 | # Test that we can build the resulting site 87 | ret, _, _ = self.run_cli(["build"], cwd=self.path) 88 | self.assertEqual(0, ret) 89 | 90 | def test_custom_path(self): 91 | """ 92 | Test that we can build a site in a custom folder. 93 | """ 94 | ret, out, err = self.run_cli(["create", self.path, "--skeleton", os.path.join("cactus", "tests", "data", "skeleton")]) 95 | self.assertEqual(0, ret) 96 | 97 | ret, out, err = self.run_cli(["build", "--path", self.path], cwd=self.test_dir) 98 | self.assertEqual(0, ret) 99 | self.assertIn(os.path.join(self.path, "config.json"), err) # Check that we tried to find the config file in the right folder (and failed) 100 | 101 | ret, _, _ = self.run_cli(["build", "-d", self.path], cwd=self.test_dir) 102 | self.assertEqual(0, ret) 103 | 104 | ret, _, _ = self.run_cli(["build"], cwd=self.test_dir) 105 | self.assertNotEqual(0, ret) 106 | 107 | def test_help(self): 108 | ret, out, err = self.run_cli([]) 109 | self.assertNotEqual(0, ret) 110 | self.assertIn("usage: cactus", err) 111 | 112 | def test_serve(self): 113 | cactus = self.find_cactus() 114 | 115 | ret, _, _ = self.run_cli(["create", self.path]) 116 | self.assertEqual(0, ret) 117 | 118 | port = 12345 119 | 120 | p = subprocess.Popen([cactus, "serve", "-p", str(port)], cwd=self.path, stdout=subprocess.DEVNULL, 121 | stderr=subprocess.DEVNULL) 122 | 123 | srv = "http://127.0.0.1:{0}".format(port) 124 | s = requests.Session() 125 | s.mount(srv, HTTPAdapter(max_retries=Retry(backoff_factor=0.2))) 126 | 127 | r = s.post("{0}/_cactus/shutdown".format(srv)) 128 | r.raise_for_status() 129 | 130 | # We'd love to use p.wait(n) here, but that doesn't work on 131 | # some of the versions of Python we support. 132 | for _ in range(5): 133 | if p.poll() != None: 134 | break 135 | time.sleep(1) 136 | else: 137 | self.fail("Server did not exit!") 138 | 139 | self.assertEqual(0, p.returncode) 140 | -------------------------------------------------------------------------------- /cactus/tests/test_compat.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | from cactus.tests import SiteTestCase 3 | 4 | 5 | class TestCompatibility(SiteTestCase): 6 | def _paths_key_exists(self, obj, old_key): 7 | try: 8 | obj.paths[old_key] 9 | except KeyError: 10 | self.fail('Old key does not exist anymore: {0}'.format(old_key)) 11 | 12 | def test_compatibility(self): 13 | """ 14 | Test that we can access the path elements the "old" way 15 | Just try a few of them, we're not locking this down - just testing 16 | """ 17 | for old_key, new_field in ( 18 | ('build', 'build_path'), 19 | ('pages', 'page_path'), 20 | ): 21 | self.assertEqual(self.site.paths[old_key], getattr(self.site, new_field)) 22 | 23 | def test_site_paths_keys_exist(self): 24 | for old_key in ('build', 'pages', 'plugins', 'templates', 'static', 'script'): 25 | self._paths_key_exists(self.site, old_key) 26 | 27 | def test_page_paths_keys_exist(self): 28 | page = self.site.pages()[0] 29 | 30 | for old_key in ('full', 'full-build'): 31 | self._paths_key_exists(page, old_key) 32 | 33 | def test_page_path_attr(self): 34 | page = self.site.pages()[0] 35 | self.assertEqual(page.source_path, page.path) 36 | 37 | def test_page_paths_keys_exist_in_static(self): 38 | static = self.site.static()[0] 39 | 40 | for old_key in ('full', 'full-build'): 41 | self._paths_key_exists(static, old_key) 42 | -------------------------------------------------------------------------------- /cactus/tests/test_config.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | import shutil 4 | import tempfile 5 | import unittest2 as unittest 6 | 7 | from cactus.config.file import ConfigFile 8 | from cactus.config.router import ConfigRouter 9 | 10 | class TestConfigRouter(unittest.TestCase): 11 | """ 12 | Test that the config router manages multiple files correctly. 13 | """ 14 | def setUp(self): 15 | self.test_dir = tempfile.mkdtemp() 16 | self.path = os.path.join(self.test_dir, "test") 17 | os.mkdir(self.path) 18 | 19 | self.path1 = os.path.join(self.path, "conf1.json") 20 | self.path2 = os.path.join(self.path, "conf2.json") 21 | self.conf1 = ConfigFile(self.path1) 22 | self.conf2 = ConfigFile(self.path2) 23 | 24 | self.conf1.set("a", 1) 25 | self.conf1.write() 26 | self.conf2.set("b", 2) 27 | self.conf2.write() 28 | 29 | def tearDown(self): 30 | shutil.rmtree(self.test_dir) 31 | 32 | def test_read(self): 33 | """ 34 | Check that the config router reads correctly from the filesystem 35 | """ 36 | router = ConfigRouter([self.path1, self.path2]) 37 | 38 | self.assertEqual(router.get("a"), 1) 39 | self.assertEqual(router.get("b"), 2) 40 | self.assertEqual(router.get("c"), None) 41 | 42 | def test_read_write(self): 43 | """ 44 | Check that our config is readable after writing it 45 | """ 46 | router = ConfigRouter([self.path1, self.path2]) 47 | 48 | router.set("a", 3) 49 | router.set("b", 4) 50 | 51 | self.assertEqual(3, router.get("a")) 52 | self.assertEqual(4, router.get("b")) 53 | 54 | def test_write(self): 55 | """ 56 | Check that the config router writes correctly to the filesystem 57 | """ 58 | router = ConfigRouter([self.path1, self.path2]) 59 | router.set("a", 3) 60 | router.set("b", 4) 61 | router.write() 62 | 63 | self.conf1.load() 64 | self.conf2.load() 65 | 66 | self.assertEqual(self.conf1.get("a"), 3) 67 | self.assertEqual(self.conf1.get("b"), None) 68 | self.assertEqual(self.conf2.get("b"), 4) 69 | self.assertEqual(self.conf2.get("a"), None) 70 | 71 | def test_collision(self): 72 | """ 73 | Check that we get the right key when there is a collision 74 | """ 75 | self.conf1.set("b", 3) 76 | self.conf2.set("a", 4) 77 | self.conf1.write() 78 | self.conf2.write() 79 | 80 | router = ConfigRouter([self.path1, self.path2]) 81 | 82 | self.assertEqual(router.get("a"), 1) 83 | self.assertEqual(router.get("b"), 3) 84 | 85 | def test_duplicate(self): 86 | """ 87 | Check that the config router handles duplicate files properly. 88 | """ 89 | router = ConfigRouter([self.path1, self.path1]) 90 | router.set("a", 3) 91 | router.write() 92 | 93 | self.conf1.load() 94 | self.assertEqual(self.conf1.get("a"), 3) 95 | 96 | def test_nested(self): 97 | """ 98 | Test that we support nested config for context 99 | """ 100 | self.conf1.set("context", {"k1":"v1"}) 101 | self.conf2.set("context", {"k2":"v2"}) 102 | self.conf1.write() 103 | self.conf2.write() 104 | 105 | router = ConfigRouter([self.path1, self.path2]) 106 | context = router.get("context", default={}, nested=True) 107 | 108 | self.assertEqual(context.get("k1"), "v1") 109 | self.assertEqual(context.get("k2"), "v2") 110 | 111 | def test_dirty(self): 112 | """ 113 | Test that we don't re-write files that we haven't changed 114 | """ 115 | 116 | self.conf1.set("a", "b") 117 | self.conf1.write() 118 | 119 | with open(self.path1, "w") as f: 120 | f.write("canary") 121 | 122 | self.conf1.write() 123 | 124 | with open(self.path1) as f: 125 | self.assertEqual("canary", f.read()) 126 | 127 | def test_missing_file(self): 128 | """ 129 | Test that we don't throw on a missing file, and that the configuration 130 | remains in a consistent state. 131 | """ 132 | wrong_path = os.path.join(self.path, "does_not_exist.json") 133 | 134 | self.conf1.set("context", {"k1":"v1"}) 135 | self.conf1.write() 136 | 137 | router = ConfigRouter([wrong_path, self.path1]) 138 | 139 | self.assertEqual(router.get("context").get("k1"), "v1") 140 | 141 | def test_broken_file(self): 142 | """ 143 | Test that we don't throw on a broken file, and that the configuration 144 | remains in a consistent state. 145 | """ 146 | 147 | with open(self.path1, "w") as f: 148 | f.write("{broken}") 149 | 150 | self.conf2.set("context", {"k1":"v1"}) 151 | self.conf2.write() 152 | 153 | router = ConfigRouter([self.path1, self.path2]) 154 | 155 | self.assertEqual(router.get("context").get("k1"), "v1") 156 | 157 | -------------------------------------------------------------------------------- /cactus/tests/test_context.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | from cactus.tests import SiteTestCase 4 | 5 | 6 | class TestSiteContext(SiteTestCase): 7 | """ 8 | Test that the proper context is provided to the pages 9 | 10 | Includes the built-in site context ('CACTUS'), and custom context. 11 | """ 12 | def setUp(self): 13 | super(TestSiteContext, self).setUp() 14 | 15 | page = "{{ a }}\n{{ b }}" 16 | with open(os.path.join(self.site.page_path, "test.html"), "w") as f: 17 | f.write(page) 18 | 19 | self.site.build() 20 | 21 | def get_config_for_test(self): 22 | return {"context": {"a":"1", "b":True}} 23 | 24 | def test_site_context(self): 25 | """ 26 | Test that the site context is provided. 27 | """ 28 | self.assertEqual( 29 | sorted([page.source_path for page in self.site.context()['CACTUS']['pages']]), 30 | sorted(['error.html', 'index.html', 'test.html']) 31 | ) 32 | 33 | def test_custom_context(self): 34 | """ 35 | Test that custom context is provided 36 | """ 37 | with open(os.path.join(self.site.build_path, "test.html")) as f: 38 | a, b = f.read().split("\n") 39 | 40 | self.assertEqual(a, "1") 41 | self.assertEqual(b, "True") 42 | 43 | class TestCustomPageContext(SiteTestCase): 44 | """ 45 | Test that custom context in the header of pages is feeded to a page 46 | 47 | Includes the built-in site context ('CACTUS'), and custom context. 48 | """ 49 | def setUp(self): 50 | super(TestCustomPageContext, self).setUp() 51 | 52 | page = "a: 1\n\tb: Monkey\n{{ a }}\n{{ b }}" 53 | with open(os.path.join(self.site.page_path, "test.html"), "w") as f: 54 | f.write(page) 55 | 56 | self.site.build() 57 | 58 | def test_custom_context(self): 59 | """ 60 | Test that custom context is provided 61 | """ 62 | with open(os.path.join(self.site.build_path, "test.html")) as f: 63 | a, b = f.read().split("\n") 64 | 65 | self.assertEqual(a, "1") 66 | self.assertEqual(b, "Monkey") 67 | -------------------------------------------------------------------------------- /cactus/tests/test_credentials.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | import shutil 4 | import tempfile 5 | import keyring 6 | import keyring.backend 7 | import unittest2 as unittest 8 | 9 | from cactus.deployment.s3.auth import AWSCredentialsManager 10 | from cactus.config.file import ConfigFile 11 | 12 | 13 | class DummySite(object): 14 | def __init__(self, config): 15 | self.config = config 16 | 17 | 18 | class DummyEngine(object): 19 | def __init__(self, site): 20 | self.site = site 21 | 22 | 23 | class TestKeyring(keyring.backend.KeyringBackend): 24 | def __init__(self): 25 | self.password = '' 26 | 27 | def supported(self): 28 | return 0 29 | 30 | def get_password(self, service, username): 31 | return self.password 32 | 33 | def set_password(self, service, username, password): 34 | self.password = password 35 | return 0 36 | 37 | def delete_password(self, service, username): 38 | self.password = None 39 | 40 | 41 | class CredentialsManagerTestCase(unittest.TestCase): 42 | aws_access_key = "123" 43 | aws_secret_key = "456" 44 | 45 | def setUp(self): 46 | self.test_dir = tempfile.mkdtemp() 47 | self.path = os.path.join(self.test_dir, "conf1.json") 48 | self.config = ConfigFile(self.path) 49 | 50 | self.site = DummySite(self.config) 51 | self.engine = DummyEngine(self.site) 52 | 53 | self._initial_keyring = keyring.get_keyring() 54 | self.keyring = TestKeyring() 55 | keyring.set_keyring(self.keyring) 56 | 57 | self.credentials_manager = AWSCredentialsManager(self.engine) 58 | 59 | def tearDown(self): 60 | shutil.rmtree(self.test_dir) 61 | keyring.set_keyring(self._initial_keyring) 62 | 63 | def test_read_config(self): 64 | """ 65 | Test that credentials can be retrieved from the config 66 | """ 67 | self.config.set("aws-access-key", self.aws_access_key) 68 | self.keyring.set_password("aws", self.aws_access_key, self.aws_secret_key) 69 | 70 | credentials = self.credentials_manager.get_credentials() 71 | self.assertEqual(2, len(credentials)) 72 | 73 | username, password = credentials 74 | self.assertEqual(self.aws_access_key, username) 75 | self.assertEqual(self.aws_secret_key, password) 76 | 77 | def test_write_config(self): 78 | """ 79 | Test that credentials are persisted to a config file 80 | """ 81 | self.credentials_manager.username = self.aws_access_key 82 | self.credentials_manager.password = self.aws_secret_key 83 | self.credentials_manager.save_credentials() 84 | 85 | self.config.load() 86 | 87 | self.assertEqual(self.aws_access_key, self.config.get("aws-access-key")) 88 | self.assertEqual(self.aws_secret_key, self.keyring.get_password("aws", "123")) 89 | -------------------------------------------------------------------------------- /cactus/tests/test_deploy.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | from __future__ import unicode_literals 3 | 4 | import os 5 | import shutil 6 | import tempfile 7 | import hashlib 8 | import unittest2 as unittest 9 | import mock 10 | 11 | from cactus.deployment.engine import BaseDeploymentEngine 12 | from cactus.config.router import ConfigRouter 13 | from cactus.deployment.file import BaseFile 14 | from cactus.plugin.builtin.cache import CacheDurationPlugin 15 | from cactus.plugin.loader import ObjectsPluginLoader 16 | from cactus.plugin.manager import PluginManager 17 | 18 | 19 | class TestHeadersPlugin(object): 20 | """ 21 | An utility plugin to retrieve a file's header. 22 | """ 23 | def __init__(self): 24 | self.headers = None 25 | 26 | def preDeployFile(self, file): 27 | self.file = file 28 | 29 | 30 | class TestFile(BaseFile): 31 | def remote_changed(self): 32 | return True 33 | 34 | def do_upload(self): 35 | pass 36 | 37 | class TestDeploymentEngine(BaseDeploymentEngine): 38 | CredentialsManagerClass = lambda self, engine: None # We never use it here 39 | 40 | 41 | #TODO: Retest this with a custom deployment engine or file class 42 | 43 | class TestDeployFile(unittest.TestCase): 44 | def setUp(self): 45 | self.test_dir = tempfile.mkdtemp() 46 | self.build_path = os.path.join(self.test_dir, '.build') 47 | os.mkdir(self.build_path) 48 | 49 | 50 | self.site = mock.MagicMock() 51 | self.site.plugin_manager = PluginManager(self.site, []) 52 | self.site.path = self.test_dir 53 | self.site.build_path = self.build_path 54 | self.site.config = ConfigRouter([os.path.join(self.test_dir, "config.json")]) 55 | self.site.config.set("site-url", "http://example.com") 56 | 57 | self.engine = TestDeploymentEngine(self.site) 58 | 59 | 60 | def tearDown(self): 61 | shutil.rmtree(self.test_dir) 62 | 63 | def test_cache_control(self): 64 | """ 65 | Ensure that the Cache control headers are properly set 66 | """ 67 | # Test a fingerprinted file 68 | content = 'abc' 69 | h = hashlib.md5(content.encode('utf-8')).hexdigest() 70 | filename = "file.{0}.data".format(h) 71 | 72 | with open(os.path.join(self.build_path, filename), "w") as f: 73 | f.write(content) 74 | 75 | p = TestHeadersPlugin() 76 | self.site.plugin_manager.loaders = [ObjectsPluginLoader([p, CacheDurationPlugin()])] 77 | self.site.plugin_manager.reload() 78 | self.site.plugin_manager.preDeploy(self.site) 79 | 80 | f = TestFile(self.engine, filename) 81 | f.upload() 82 | 83 | self.assertEqual(p.file.cache_control, f.MAX_CACHE_EXPIRATION) 84 | 85 | 86 | # Test a non fingerprinted file 87 | with open(os.path.join(self.build_path, "123.html"), "w") as f: 88 | f.write("abc") 89 | 90 | # Prepare setup 91 | p = TestHeadersPlugin() 92 | self.site.plugin_manager.loaders = [ObjectsPluginLoader([p, CacheDurationPlugin()])] 93 | self.site.plugin_manager.reload() 94 | f = TestFile(self.engine, "123.html") 95 | 96 | # Test with no configured cache duration 97 | self.site.config.set("cache-duration", None) 98 | self.site.plugin_manager.preDeploy(self.site) 99 | 100 | f.upload() 101 | self.assertEqual(p.file.cache_control, f.DEFAULT_CACHE_EXPIRATION) 102 | 103 | # Test with a configured cache duration 104 | self.site.config.set("cache-duration", 123) 105 | self.site.plugin_manager.preDeploy(self.site) 106 | 107 | f.upload() 108 | self.assertEqual(p.file.cache_control, 123) 109 | -------------------------------------------------------------------------------- /cactus/tests/test_fingerprint.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | from __future__ import unicode_literals 3 | 4 | import os 5 | import io 6 | import hashlib 7 | 8 | from cactus.tests import SiteTestCase 9 | 10 | 11 | class TestFingerPrintingMixin(object): 12 | fingerprint_extensions = None 13 | 14 | def get_config_for_test(self): 15 | return {"fingerprint": self.fingerprint_extensions} 16 | 17 | 18 | class TestFingerprintingOff(TestFingerPrintingMixin, SiteTestCase): 19 | fingerprint_extensions = [] 20 | 21 | def setUp(self): 22 | super(TestFingerprintingOff, self).setUp() 23 | self.site.build() 24 | 25 | def test_fingerprinting_off(self): 26 | """ 27 | Test that fingerprinting can be disabled. 28 | """ 29 | static = '/static/css/style.css' 30 | self.assertEqual(self.site.get_url_for_static(static), static) 31 | self.assertFileExists(os.path.join(self.site.build_path, self.site.get_url_for_static(static)[1:])) 32 | 33 | 34 | class TestFingerprintingOn(TestFingerPrintingMixin, SiteTestCase): 35 | fingerprint_extensions = ["css", "js"] 36 | 37 | def setUp(self): 38 | super(TestFingerprintingOn, self).setUp() 39 | self.site.build() 40 | 41 | def test_fingerprinting_on(self): 42 | """ 43 | Test that fingerprinting provides existing URLs. 44 | """ 45 | static = '/static/css/style.css' 46 | self.assertNotEqual(self.site.get_url_for_static(static), static) 47 | self.assertFileExists(os.path.join(self.site.build_path, self.site.get_url_for_static(static)[1:])) 48 | 49 | static = '/static/js/main.js' 50 | self.assertNotEqual(self.site.get_url_for_static(static), static) 51 | self.assertFileExists(os.path.join(self.site.build_path, self.site.get_url_for_static(static)[1:])) 52 | 53 | 54 | class TestFingerprintingSelective(TestFingerPrintingMixin, SiteTestCase): 55 | fingerprint_extensions = ["css"] 56 | 57 | def setUp(self): 58 | super(TestFingerprintingSelective, self).setUp() 59 | self.site.build() 60 | 61 | def test_fingerprinting_selective(self): 62 | """ 63 | Test that fingerprinting can be restricted to certain filetypes. 64 | """ 65 | static = '/static/css/style.css' 66 | self.assertNotEqual(self.site.get_url_for_static(static), static) 67 | self.assertFileExists(os.path.join(self.site.build_path, self.site.get_url_for_static(static)[1:])) 68 | 69 | static = '/static/js/main.js' 70 | self.assertEqual(self.site.get_url_for_static(static), static) 71 | self.assertFileExists(os.path.join(self.site.build_path, self.site.get_url_for_static(static)[1:])) 72 | 73 | 74 | class TestFingerprintingValues(TestFingerPrintingMixin, SiteTestCase): 75 | fingerprint_extensions = ["dat"] 76 | 77 | def test_fingerprinting_hashes(self): 78 | payload = b"\x01" * 131072 # (2**17 bytes = 2 * 65536) 79 | expected_checksum = hashlib.md5(payload).hexdigest() 80 | 81 | with io.FileIO(os.path.join(self.path, "static", "data.dat"), "w") as f: 82 | f.write(payload) 83 | 84 | self.site.build() 85 | self.assertTrue(expected_checksum in self.site.get_url_for_static("/static/data.dat")) 86 | -------------------------------------------------------------------------------- /cactus/tests/test_ignore.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | from cactus.tests import SiteTestCase 4 | 5 | 6 | class TestIgnore(SiteTestCase): 7 | def test_ignore_static(self): 8 | with open(os.path.join(self.site.static_path, "koen.psd"), "w") as f: 9 | f.write("Not really a psd") 10 | 11 | with open(os.path.join(self.site.static_path, "koen.gif"), "w") as f: 12 | f.write("Not really a gif") 13 | 14 | self.site.config.set("ignore", ["*.psd"]) 15 | self.site.build() 16 | 17 | self.assertFileDoesNotExist(os.path.join(self.site.build_path, "static", "koen.psd")) 18 | self.assertFileExists(os.path.join(self.site.build_path, "static", "koen.gif")) 19 | 20 | def test_ignore_pages(self): 21 | with open(os.path.join(self.site.page_path, "page.html.swp"), "w") as f: 22 | f.write("Not really a swap file") 23 | 24 | with open(os.path.join(self.site.page_path, "page.txt"), "w") as f: 25 | f.write("Actually a text file") 26 | 27 | self.site.config.set("ignore", ["*.swp"]) 28 | self.site.build() 29 | 30 | self.assertFileDoesNotExist(os.path.join(self.site.build_path, "page.html.swp")) 31 | self.assertFileExists(os.path.join(self.site.build_path, "page.txt")) 32 | -------------------------------------------------------------------------------- /cactus/tests/test_legacy_context.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | 4 | from cactus.tests import SiteTestCase 5 | 6 | 7 | class TestLegacyContext(SiteTestCase): 8 | def setUp(self): 9 | super(TestLegacyContext, self).setUp() 10 | os.mkdir(os.path.join(self.site.page_path, "test")) 11 | 12 | with open(os.path.join(self.site.page_path, "static.html"), "w") as f: 13 | f.write("{{ STATIC_URL }}") 14 | 15 | with open(os.path.join(self.site.page_path, "test", "static.html"), "w") as f: 16 | f.write("{{ STATIC_URL }}") 17 | 18 | with open(os.path.join(self.site.page_path, "root.html"), "w") as f: 19 | f.write("{{ ROOT_URL }}") 20 | 21 | with open(os.path.join(self.site.page_path, "test", "root.html"), "w") as f: 22 | f.write("{{ ROOT_URL }}") 23 | 24 | with open(os.path.join(self.site.page_path, "page.html"), "w") as f: 25 | f.write("{{ PAGE_URL }}") 26 | 27 | def test_context(self): 28 | self.site.build() 29 | 30 | with open(os.path.join(self.site.build_path, "static.html")) as f: 31 | self.assertEqual(f.read(), "./static") 32 | 33 | with open(os.path.join(self.site.build_path, "test", "static.html")) as f: 34 | self.assertEqual(f.read(), "../static") 35 | 36 | with open(os.path.join(self.site.build_path, "root.html")) as f: 37 | self.assertEqual(f.read(), ".") 38 | 39 | with open(os.path.join(self.site.build_path, "test", "root.html")) as f: 40 | self.assertEqual(f.read(), "..") 41 | 42 | with open(os.path.join(self.site.build_path, "page.html")) as f: 43 | self.assertEqual(f.read(), "page.html") 44 | 45 | def test_pretty_urls(self): 46 | self.site.prettify_urls = True 47 | 48 | self.site.build() 49 | 50 | with open(os.path.join(self.site.build_path, "test", "static", "index.html")) as f: 51 | self.assertEqual(f.read(), "../../static") 52 | 53 | with open(os.path.join(self.site.build_path, "root", "index.html")) as f: 54 | self.assertEqual(f.read(), "..") 55 | 56 | with open(os.path.join(self.site.build_path, "test", "root", "index.html")) as f: 57 | self.assertEqual(f.read(), "../..") 58 | 59 | with open(os.path.join(self.site.build_path, "page", "index.html")) as f: 60 | self.assertEqual(f.read(), "page/") 61 | -------------------------------------------------------------------------------- /cactus/tests/test_mime.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import unittest 3 | 4 | from cactus.mime import guess 5 | 6 | 7 | class MimeTestCase(unittest.TestCase): 8 | def test_mime_eot(self): 9 | font_path = "test/font.eot" 10 | self.assertFalse(guess(font_path) is None) 11 | 12 | def test_mime_dummy(self): 13 | # Make sure we never return a None mime type! 14 | path = "test/format.thisisnomime" 15 | self.assertFalse(guess(path) is None) 16 | -------------------------------------------------------------------------------- /cactus/tests/test_plugins.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | import shutil 4 | 5 | from cactus.plugin.loader import CustomPluginsLoader 6 | from cactus.plugin.manager import PluginManager 7 | from cactus.tests import SiteTestCase 8 | 9 | 10 | class TestPluginLoader(SiteTestCase): 11 | def setUp(self): 12 | super(TestPluginLoader, self).setUp() 13 | self.site.plugin_manager = PluginManager(self.site, [CustomPluginsLoader(self.site.path)]) 14 | shutil.rmtree(self.site.plugin_path) 15 | os.makedirs(self.site.plugin_path) 16 | 17 | def _load_test_plugin(self, plugin, to_filename): 18 | src_path = os.path.join('cactus', 'tests', 'data', 'plugins', plugin) 19 | dst_path = os.path.join(self.site.plugin_path, to_filename) 20 | shutil.copy(src_path, dst_path) 21 | self.site.plugin_manager.reload() 22 | 23 | def test_ignore_disabled(self): 24 | self._load_test_plugin('test.py', 'test.disabled.py') 25 | self.assertEqual([], [p for p in self.site.plugin_manager.plugins if not p.builtin]) 26 | 27 | def test_load_plugin(self): 28 | self._load_test_plugin('test.py', 'test.py') 29 | 30 | plugins = self.site.plugin_manager.plugins 31 | self.assertEqual(1, len(plugins )) 32 | self.assertEqual('plugin_test', plugins[0].plugin_name) 33 | self.assertEqual(2, plugins[0].ORDER) 34 | 35 | def test_defaults(self): 36 | """ 37 | Check that defaults get initialized 38 | """ 39 | self._load_test_plugin('empty.py', 'empty.py') 40 | plugins = self.site.plugin_manager.plugins 41 | 42 | plugin = plugins[0] 43 | self.assert_(hasattr(plugin, 'preBuild')) 44 | self.assert_(hasattr(plugin, 'postBuild')) 45 | self.assertEqual(-1, plugin.ORDER) 46 | 47 | def test_call(self): 48 | """ 49 | Check that plugins get called 50 | """ 51 | self._load_test_plugin('test.py', 'call.py') 52 | plugins = self.site.plugin_manager.plugins 53 | 54 | plugin = plugins[0] 55 | 56 | self.assertEqual('plugin_call', plugin.plugin_name) # Just to check we're looking at the right one. 57 | 58 | self.site.build() 59 | 60 | # preBuild 61 | self.assertEqual(1, len(plugin.preBuild.calls)) 62 | self.assertEqual((self.site,), plugin.preBuild.calls[0]['args']) 63 | 64 | # preBuildPage 65 | self.assertEqual(len(self.site.pages()), len(plugin.preBuildPage.calls)) 66 | for call in plugin.preBuildPage.calls: 67 | self.assertIn(len(call['args']), (3, 4)) 68 | 69 | # postBuildPage 70 | self.assertEqual(len(self.site.pages()), len(plugin.postBuildPage.calls)) 71 | 72 | #postBuild 73 | self.assertEqual(1, len(plugin.postBuild.calls)) 74 | self.assertEqual((self.site,), plugin.postBuild.calls[0]['args']) 75 | -------------------------------------------------------------------------------- /cactus/tests/test_ui.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import unittest2 as unittest 3 | 4 | from cactus import ui 5 | 6 | 7 | class UITestCase(unittest.TestCase): 8 | def test_coerce_yes_no(self): 9 | self.assertEqual(True, ui._yes_no_coerce_fn("y")) 10 | self.assertEqual(True, ui._yes_no_coerce_fn("Y")) 11 | 12 | self.assertEqual(False, ui._yes_no_coerce_fn("n")) 13 | self.assertEqual(False, ui._yes_no_coerce_fn("N")) 14 | 15 | self.assertRaises(ui.InvalidInput, ui._yes_no_coerce_fn, "True") 16 | self.assertRaises(ui.InvalidInput, ui._yes_no_coerce_fn, "False") 17 | self.assertRaises(ui.InvalidInput, ui._yes_no_coerce_fn, "yes") 18 | self.assertRaises(ui.InvalidInput, ui._yes_no_coerce_fn, "no") 19 | 20 | def test_coerce_normalized(self): 21 | self.assertEqual("a", ui._normalized_coerce_fn("a ")) 22 | self.assertEqual("a", ui._normalized_coerce_fn("A ")) 23 | self.assertEqual("a", ui._normalized_coerce_fn(" A ")) 24 | 25 | def test_coerce_url(self): 26 | self.assertEqual("http://www.example.com/", ui._url_coerce_fn("http://www.example.com/")) 27 | self.assertEqual("http://www.example.com/", ui._url_coerce_fn("http://www.EXAMPLE.com/")) 28 | self.assertEqual("http://www.example.com/", ui._url_coerce_fn("http://www.example.com")) 29 | 30 | self.assertRaises(ui.InvalidInput, ui._url_coerce_fn, "") 31 | self.assertRaises(ui.InvalidInput, ui._url_coerce_fn, "www.example.com") 32 | self.assertRaises(ui.InvalidInput, ui._url_coerce_fn, "www.example.com ") 33 | self.assertRaises(ui.InvalidInput, ui._url_coerce_fn, "http://") 34 | self.assertRaises(ui.InvalidInput, ui._url_coerce_fn, "/") 35 | self.assertRaises(ui.InvalidInput, ui._url_coerce_fn, "http://www.example.com/somewhere/") 36 | self.assertRaises(ui.InvalidInput, ui._url_coerce_fn, "http://www.example.com/#hash") 37 | 38 | 39 | # Disabled for now with the desktop app 40 | 41 | # class InteractiveUITestCase(BaseTestCase): 42 | # def test_site_url_not_set(self): 43 | # class DummyUI(object): 44 | # def prompt_url(self, q): 45 | # return "http://example.com" 46 | 47 | # site = Site(self.path, ui=DummyUI()) 48 | # self.assertEqual(None, site.url) 49 | # site.build() 50 | # self.assertEqual("http://example.com", site.url) 51 | -------------------------------------------------------------------------------- /cactus/tests/test_urls.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | 4 | from cactus.tests import SiteTestCase 5 | 6 | 7 | class TestPrettyURLS(SiteTestCase): 8 | def get_config_for_test(self): 9 | return {"prettify": True} 10 | 11 | def setUp(self): 12 | super(TestPrettyURLS, self).setUp() 13 | 14 | open(os.path.join(self.path, 'pages', 'test.html'), 'w') 15 | subfolder = os.path.join(self.path, 'pages', 'folder') 16 | os.makedirs(subfolder) 17 | open(os.path.join(subfolder, 'index.html'), 'w') 18 | open(os.path.join(subfolder, 'page.html'), 'w') 19 | 20 | 21 | self.site.build() 22 | 23 | def test_get_path(self): 24 | """ 25 | Test that URL rewriting makes pretty links. 26 | """ 27 | self.assertEqual(self.site.get_url_for_page('/index.html'), '/') 28 | self.assertEqual(self.site.get_url_for_page('/test.html'), '/test/') 29 | self.assertEqual(self.site.get_url_for_page('/folder/index.html'), '/folder/') 30 | self.assertEqual(self.site.get_url_for_page('/folder/page.html'), '/folder/page/') 31 | 32 | def test_build_page(self): 33 | """ 34 | Check that we rewrite paths for .html files. 35 | """ 36 | self.assertFileExists(os.path.join(self.site.build_path, 'index.html')) 37 | self.assertFileExists(os.path.join(self.site.build_path, 'test', 'index.html')) 38 | self.assertFileExists(os.path.join(self.site.build_path, 'folder', 'index.html')) 39 | self.assertFileExists(os.path.join(self.site.build_path, 'folder', 'page', 'index.html')) 40 | self.assertRaises(IOError, open, os.path.join(self.path, '.build', 'test.html'), 'rU') 41 | 42 | def test_ignore_non_html(self): 43 | """ 44 | Check that we don't rewrite paths for .txt files. 45 | """ 46 | self.assertFileExists(os.path.join(self.site.build_path, 'sitemap.xml')) 47 | self.assertFileExists(os.path.join(self.site.build_path, 'robots.txt')) 48 | -------------------------------------------------------------------------------- /cactus/ui.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | from __future__ import print_function 3 | from six.moves import urllib 4 | from six.moves import input 5 | 6 | 7 | class InvalidInput(Exception): 8 | """ 9 | Raised when an input is invalid. 10 | You should use this in your `coerce_fn` passed to `prompt`, to indicate that the 11 | input you received was no valid. 12 | """ 13 | def __init__(self, reason=""): 14 | self.reason = reason 15 | 16 | 17 | def prompt(q, coerce_fn=None, error_msg="Invalid input, please try again", prompt_fn=input): 18 | """ 19 | :param q: The prompt to display to the user 20 | :param coerce_fn: A function to coerce, and validate, the user input. 21 | You may raise InvalidInput in it, to indicate that the input was not valid. 22 | :param error_msg: An error message to display if the input is incorrect 23 | :return: The user's input 24 | :rtype: str 25 | """ 26 | if coerce_fn is None: 27 | coerce_fn = lambda x:x 28 | 29 | while 1: 30 | r = prompt_fn(q + " > ") 31 | try: 32 | return coerce_fn(r) 33 | except InvalidInput as e: 34 | print(e.reason or error_msg) 35 | 36 | 37 | _yes_no_mapping = {"y":True, "n":False} 38 | def _yes_no_coerce_fn(r): 39 | """ 40 | :rtype: bool 41 | """ 42 | try: 43 | return _yes_no_mapping[r.lower()] 44 | except KeyError: 45 | raise InvalidInput("Please enter `y` or `n`") 46 | 47 | def prompt_yes_no(q): 48 | """ 49 | :param q: The prompt to display to the user 50 | :return: True of False 51 | :rtype : bool 52 | """ 53 | return prompt(q + " [y/n]", _yes_no_coerce_fn) 54 | 55 | 56 | def _normalized_coerce_fn(r): 57 | """ 58 | :rtype: str 59 | """ 60 | return r.lower().strip() 61 | 62 | def prompt_normalized(q): 63 | """ 64 | :param q: The prompt to display to the user 65 | :returns: The user's normalized input 66 | :rtype: str 67 | """ 68 | return prompt(q, _normalized_coerce_fn) 69 | 70 | 71 | def _url_coerce_fn(r): 72 | """ 73 | :rtype: str 74 | """ 75 | p = urllib.parse.urlparse(r) 76 | if not p.scheme: 77 | raise InvalidInput("Specify an URL scheme (e.g. http://)") 78 | if not p.netloc: 79 | raise InvalidInput("Specify a domain (e.g. example.com)") 80 | if p.path and p.path != "/": 81 | raise InvalidInput("Do not specify a path") 82 | if p.params or p.query or p.fragment: 83 | raise InvalidInput("Do not leave trailing elements") 84 | 85 | if not p.path: 86 | r += "/" #TODO: Fixme once the sitemap is fixed! 87 | r = r.lower() 88 | 89 | return r 90 | 91 | 92 | def prompt_url(q): 93 | """ 94 | :param q: The prompt to display to the user 95 | :return: The user's normalized input. We ensure there is an URL scheme, a domain, a "/" path, 96 | and no trailing elements. 97 | :rtype: str 98 | """ 99 | return prompt(q, _url_coerce_fn) 100 | -------------------------------------------------------------------------------- /cactus/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eudicots/Cactus/b6dc9fb92248e1fd7fb6f44b57b8835802e9d880/cactus/utils/__init__.py -------------------------------------------------------------------------------- /cactus/utils/file.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import os 3 | import gzip 4 | import io 5 | import hashlib 6 | import subprocess 7 | 8 | from six import text_type, BytesIO 9 | 10 | from cactus.utils.helpers import checksum 11 | 12 | 13 | class FakeTime: 14 | """ 15 | Monkey-patch gzip.time to avoid changing files every time we deploy them. 16 | """ 17 | def time(self): 18 | return 1111111111.111 19 | 20 | 21 | def compressString(s): 22 | """Gzip a given string.""" 23 | 24 | gzip.time = FakeTime() 25 | 26 | zbuf = BytesIO() 27 | zfile = gzip.GzipFile(mode='wb', compresslevel=9, fileobj=zbuf) 28 | zfile.write(s) 29 | zfile.close() 30 | return zbuf.getvalue() 31 | 32 | 33 | def fileSize(num): 34 | for x in ['b', 'kb', 'mb', 'gb', 'tb']: 35 | if num < 1024.0: 36 | return "%.0f%s" % (num, x) 37 | num /= 1024.0 38 | 39 | def calculate_file_checksum(path): 40 | """ 41 | Calculate the MD5 sum for a file: 42 | Read chunks of a file and update the hasher. 43 | Returns the hex digest of the md5 hash. 44 | """ 45 | hasher = hashlib.md5() 46 | with io.FileIO(path, 'r') as fp: 47 | while True: 48 | buf = fp.read(65536) 49 | if not buf: 50 | break 51 | hasher.update(buf) 52 | return hasher.hexdigest() 53 | 54 | def file_changed_hash(path): 55 | info = os.stat(path) 56 | hashKey = text_type(info.st_mtime) + text_type(info.st_size) 57 | return checksum(hashKey.encode('utf-8')) 58 | -------------------------------------------------------------------------------- /cactus/utils/filesystem.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import tempfile 4 | from contextlib import contextmanager 5 | 6 | from cactus.utils.helpers import map_apply 7 | 8 | 9 | def mkdtemp(): 10 | return tempfile.mkdtemp(dir=os.environ.get("TEMPDIR")) 11 | 12 | def fileList(paths, relative=False, folders=False): 13 | """ 14 | Generate a recursive list of files from a given path. 15 | """ 16 | 17 | if not isinstance(paths, list): 18 | paths = [paths] 19 | 20 | files = [] 21 | 22 | def append(directory, name): 23 | if not name.startswith('.'): 24 | path = os.path.join(directory, name) 25 | files.append(path) 26 | 27 | for path in paths: 28 | for directory, dirnames, filenames in os.walk(path, followlinks=True): 29 | if folders: 30 | for dirname in dirnames: 31 | append(directory, dirname) 32 | for filename in filenames: 33 | append(directory, filename) 34 | if relative: 35 | files = map_apply(lambda x: x[len(path) + 1:], files) 36 | 37 | return files 38 | 39 | 40 | @contextmanager 41 | def alt_file(current_file): 42 | """ 43 | Create an alternate file next to an existing file. 44 | """ 45 | _alt_file = current_file + '-alt' 46 | yield _alt_file 47 | try: 48 | shutil.move(_alt_file, current_file) 49 | except IOError: 50 | # We didn't use an alt file. 51 | pass 52 | 53 | 54 | @contextmanager 55 | def chdir(new_dir): 56 | """ 57 | Chdir to another directory for an operation 58 | """ 59 | current_dir = os.getcwd() 60 | os.chdir(new_dir) 61 | yield 62 | os.chdir(current_dir) 63 | -------------------------------------------------------------------------------- /cactus/utils/helpers.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from functools import partial 3 | 4 | import six 5 | 6 | 7 | class CaseInsensitiveDict(dict): 8 | def __init__(self, obj = None, **kwargs): 9 | if obj is not None: 10 | if isinstance(obj, dict): 11 | for k, v in obj.items(): 12 | self[k] = v 13 | else: 14 | for k, v in obj: 15 | self[k] = v 16 | 17 | for k, v in kwargs.items(): 18 | self[k] = v 19 | 20 | def __setitem__(self, key, value): 21 | super(CaseInsensitiveDict, self).__setitem__(key.lower(), value) 22 | 23 | def __getitem__(self, key): 24 | return super(CaseInsensitiveDict, self).__getitem__(key.lower()) 25 | 26 | def __delitem__(self, key): 27 | return super(CaseInsensitiveDict, self).__delitem__(key.lower()) 28 | 29 | def __contains__(self, key): 30 | return super(CaseInsensitiveDict, self).__contains__(key.lower()) 31 | 32 | def pop(self, key): 33 | return super(CaseInsensitiveDict, self).pop(key.lower()) 34 | 35 | 36 | class memoize(object): 37 | """ 38 | Memoize the return parameters of a function. 39 | """ 40 | def __init__(self, func): 41 | self.func = func 42 | 43 | def __get__(self, obj, objtype=None): 44 | if obj is None: 45 | return self.func 46 | return partial(self, obj) 47 | 48 | def __call__(self, *args, **kw): 49 | obj = args[0] 50 | try: 51 | cache = obj.__cache 52 | except AttributeError: 53 | cache = obj.__cache = {} 54 | key = (self.func, args[1:], frozenset(kw.items())) 55 | try: 56 | res = cache[key] 57 | except KeyError: 58 | res = cache[key] = self.func(*args, **kw) 59 | return res 60 | 61 | 62 | if six.PY3: 63 | def map_apply(fn, iterable): 64 | return list(map(fn, iterable)) 65 | else: 66 | map_apply = map 67 | 68 | 69 | def checksum(s): 70 | """ 71 | Calculate the checksum of a string. 72 | Should eventually support files too. 73 | 74 | We use MD5 because S3 does. 75 | """ 76 | return hashlib.md5(s).hexdigest() 77 | 78 | 79 | def get_or_prompt(config, key, prompt_fn, *args, **kwargs): 80 | """ 81 | :param config: The configuration object to get the value from 82 | :param key: The configuration key to retrieve 83 | :type key: str 84 | :param prompt_fn: The prompt function to use to prompt the value 85 | :param args: Extra arguments for the prompt function 86 | :param kwargs: Extra keyword arguments for hte prompt function 87 | """ 88 | value = config.get(key) 89 | if value is None: 90 | value = prompt_fn(*args, **kwargs) 91 | config.set(key, value) 92 | return value 93 | -------------------------------------------------------------------------------- /cactus/utils/internal.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import six 3 | import inspect 4 | 5 | # Adapted from: http://kbyanc.blogspot.com/2007/07/python-more-generic-getargspec.html 6 | 7 | 8 | FUNC_OBJ_ATTR = "__func__" if six.PY3 else "im_func" 9 | 10 | 11 | def getargspec(obj): 12 | """ 13 | Get the names and default values of a callable's 14 | arguments 15 | 16 | A tuple of four things is returned: (args, varargs, 17 | varkw, defaults). 18 | - args is a list of the argument names (it may 19 | contain nested lists). 20 | - varargs and varkw are the names of the * and 21 | ** arguments or None. 22 | - defaults is a tuple of default argument values 23 | or None if there are no default arguments; if 24 | this tuple has n elements, they correspond to 25 | the last n elements listed in args. 26 | 27 | Unlike inspect.getargspec(), can return argument 28 | specification for functions, methods, callable 29 | objects, and classes. Does not support builtin 30 | functions or methods. 31 | """ 32 | if not callable(obj): 33 | raise TypeError("%s is not callable" % type(obj)) 34 | try: 35 | if inspect.isfunction(obj): 36 | return inspect.getargspec(obj) 37 | elif hasattr(obj, FUNC_OBJ_ATTR): 38 | # For methods or classmethods drop the first 39 | # argument from the returned list because 40 | # python supplies that automatically for us. 41 | # Note that this differs from what 42 | # inspect.getargspec() returns for methods. 43 | # NB: We use im_func so we work with 44 | # instancemethod objects also. 45 | spec = inspect.getargspec(getattr(obj, FUNC_OBJ_ATTR)) 46 | return inspect.ArgSpec(spec.args[:1], spec.varargs, spec.keywords, spec.defaults) 47 | elif inspect.isclass(obj): 48 | return getargspec(obj.__init__) 49 | elif isinstance(obj, object): 50 | # We already know the instance is callable, 51 | # so it must have a __call__ method defined. 52 | # Return the arguments it expects. 53 | return getargspec(obj.__call__) 54 | except NotImplementedError: 55 | # If a nested call to our own getargspec() 56 | # raises NotImplementedError, re-raise the 57 | # exception with the real object type to make 58 | # the error message more meaningful (the caller 59 | # only knows what they passed us; they shouldn't 60 | # care what aspect(s) of that object we actually 61 | # examined). 62 | pass 63 | raise NotImplementedError("do not know how to get argument list for %s" % type(obj)) 64 | -------------------------------------------------------------------------------- /cactus/utils/ipc.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | 5 | def signal(signal, data=None): 6 | if data is None: 7 | data = {} 8 | 9 | if not os.environ.get('DESKTOPAPP'): 10 | return 11 | 12 | data["signal"] = signal 13 | logging.warning("", data) 14 | -------------------------------------------------------------------------------- /cactus/utils/network.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | import logging 3 | import time 4 | from six.moves import urllib 5 | 6 | from cactus.utils.parallel import multiMap 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def retry(exceptions, tries=4, delay=3, backoff=2): 13 | """ 14 | Retry execution in case we fail on one of the exceptions 15 | """ 16 | def deco_retry(f): 17 | def f_retry(*args, **kwargs): 18 | mtries, mdelay = tries, delay 19 | try_one_last_time = True 20 | while mtries > 1: 21 | try: 22 | return f(*args, **kwargs) 23 | except exceptions as e: 24 | logger.warning("%s, Retrying in %.1f seconds..." % (str(e), mdelay)) 25 | time.sleep(mdelay) 26 | mtries -= 1 27 | mdelay *= backoff 28 | if try_one_last_time: 29 | return f(*args, **kwargs) 30 | return 31 | 32 | return f_retry # true decorator 33 | 34 | return deco_retry 35 | 36 | 37 | def internetWorking(): 38 | def check(url): 39 | try: 40 | response = urllib.request.urlopen(url, timeout = 1) 41 | return True 42 | except urllib.error.URLError as err: 43 | pass 44 | return False 45 | 46 | return True in multiMap(check, [ 47 | 'http://www.google.com', 48 | 'http://www.apple.com']) 49 | -------------------------------------------------------------------------------- /cactus/utils/packaging.py: -------------------------------------------------------------------------------- 1 | import posixpath 2 | import pkg_resources 3 | 4 | 5 | def pkg_walk(package, top): 6 | """ 7 | Walk the package resources. Implementation from os.walk. 8 | """ 9 | 10 | names = pkg_resources.resource_listdir(package, top) 11 | 12 | dirs, nondirs = [], [] 13 | 14 | for name in names: 15 | # Forward slashes with pkg_resources 16 | if pkg_resources.resource_isdir(package, posixpath.join(top, name)): 17 | dirs.append(name) 18 | else: 19 | nondirs.append(name) 20 | 21 | yield top, dirs, nondirs 22 | 23 | for name in dirs: 24 | new_path = posixpath.join(top, name) 25 | for out in pkg_walk(package, new_path): 26 | yield out 27 | -------------------------------------------------------------------------------- /cactus/utils/parallel.py: -------------------------------------------------------------------------------- 1 | #coding:utf-8 2 | from __future__ import print_function 3 | import sys 4 | import logging 5 | import multiprocessing.pool 6 | 7 | 8 | PARALLEL_AGGRESSIVE = 2 9 | PARALLEL_CONSERVATIVE = 1 10 | PARALLEL_DISABLED = 0 11 | 12 | def multiMap(f, items, workers = 8): 13 | 14 | # Code in GCS engine +/- depends on this being threads 15 | pool = multiprocessing.pool.ThreadPool(workers) 16 | 17 | # Simple wrapper to provide decent tracebacks 18 | def wrapper(*args, **kwargs): 19 | try: 20 | return f(*args, **kwargs) 21 | except Exception as e: 22 | import traceback 23 | print(traceback.format_exc()) 24 | pool.join() 25 | sys.exit() 26 | 27 | return pool.map(wrapper, items) 28 | -------------------------------------------------------------------------------- /cactus/utils/sync.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python 2 | # Dispatch - synchronize two folders 3 | from __future__ import print_function 4 | 5 | import os 6 | import filecmp 7 | import shutil 8 | 9 | from stat import * 10 | 11 | 12 | class Dispatch: 13 | ''' This class represents a synchronization object ''' 14 | 15 | def __init__(self, name=''): 16 | self.name = name 17 | self.node_list = [] 18 | self.file_copied_count = 0 19 | self.folder_copied_count = 0 20 | 21 | def add_node(self, node): 22 | self.node_list.append(node) 23 | 24 | def compare_nodes(self): 25 | ''' This method takes the nodes in the node_list and compares them ''' 26 | # For each node in the list 27 | for node in self.node_list: 28 | # If the list has another item after it, compare them 29 | if self.node_list.index(node) < len(self.node_list) - 1: 30 | node2 = self.node_list[self.node_list.index(node) + 1] 31 | print('\nComparing Node ' + str(self.node_list.index(node)) + ' and Node ' + str(self.node_list.index(node) + 1) + ':') 32 | # Passes the two root directories of the nodes to the recursive _compare_directories. 33 | self._compare_directories(node.root_path, node2.root_path) 34 | 35 | def _compare_directories(self, left, right): 36 | ''' This method compares directories. If there is a common directory, the 37 | algorithm must compare what is inside of the directory by calling this 38 | recursively. 39 | ''' 40 | comparison = filecmp.dircmp(left, right) 41 | if comparison.common_dirs: 42 | for d in comparison.common_dirs: 43 | self._compare_directories(os.path.join(left, d), os.path.join(right, d)) 44 | if comparison.left_only: 45 | self._copy(comparison.left_only, left, right) 46 | if comparison.right_only: 47 | self._copy(comparison.right_only, right, left) 48 | left_newer = [] 49 | right_newer = [] 50 | if comparison.diff_files: 51 | for d in comparison.diff_files: 52 | l_modified = os.stat(os.path.join(left, d)).st_mtime 53 | r_modified = os.stat(os.path.join(right, d)).st_mtime 54 | if l_modified > r_modified: 55 | left_newer.append(d) 56 | else: 57 | right_newer.append(d) 58 | self._copy(left_newer, left, right) 59 | self._copy(right_newer, right, left) 60 | 61 | def _copy(self, file_list, src, dest): 62 | ''' This method copies a list of files from a source node to a destination node ''' 63 | for f in file_list: 64 | srcpath = os.path.join(src, os.path.basename(f)) 65 | if os.path.isdir(srcpath): 66 | shutil.copytree(srcpath, os.path.join(dest, os.path.basename(f))) 67 | self.folder_copied_count = self.folder_copied_count + 1 68 | print('Copied directory \"' + os.path.basename(srcpath) + '\" from \"' + os.path.dirname(srcpath) + '\" to \"' + dest + '\"') 69 | else: 70 | shutil.copy2(srcpath, dest) 71 | self.file_copied_count = self.file_copied_count + 1 72 | print('Copied \"' + os.path.basename(srcpath) + '\" from \"' + os.path.dirname(srcpath) + '\" to \"' + dest + '\"') 73 | 74 | 75 | class Node: 76 | ''' This class represents a node in a dispatch synchronization ''' 77 | def __init__(self, path, name=''): 78 | self.name = name 79 | self.root_path = os.path.abspath(path) 80 | self.file_list = os.listdir(self.root_path) 81 | 82 | 83 | def syncFiles(src, dst): 84 | dispatch = Dispatch('dispatch') 85 | dispatch.add_node(Node(src, 'node1')) 86 | dispatch.add_node(Node(dst, 'node2')) 87 | dispatch.compare_nodes() 88 | -------------------------------------------------------------------------------- /cactus/utils/url.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from six.moves import urllib, http_client 4 | 5 | from cactus.utils.helpers import CaseInsensitiveDict 6 | 7 | 8 | EXTERNAL_SCHEMES = ("//", "http://", "https://", "mailto:") 9 | 10 | 11 | def getURLHeaders(url): 12 | url = urllib.parse.urlparse(url) 13 | 14 | conn = http_client.HTTPConnection(url.netloc) 15 | conn.request('HEAD', urllib.parse.quote(url.path)) 16 | 17 | response = conn.getresponse() 18 | 19 | return CaseInsensitiveDict(response.getheaders()) 20 | 21 | 22 | def is_external(url): 23 | for scheme in EXTERNAL_SCHEMES: 24 | if url.startswith(scheme): 25 | return True 26 | return False 27 | 28 | 29 | def path_to_url(path): 30 | """ 31 | Convert a system path to an URL 32 | """ 33 | return path.replace(os.sep, "/") 34 | 35 | 36 | def URLHelperMixinFactory(class_name, property_name): 37 | 38 | inner_property_name = "_" + property_name 39 | 40 | def setter(self, value): 41 | setattr(self, inner_property_name, value) 42 | 43 | def getter(self): 44 | return path_to_url(getattr(self, inner_property_name)) 45 | 46 | def deleter(self): 47 | delattr(self, inner_property_name) 48 | 49 | return type(class_name, (object,), { 50 | property_name: property(getter, setter, deleter) 51 | }) 52 | 53 | 54 | class ResourceURLHelperMixin( 55 | URLHelperMixinFactory("LinkURLHelperMixin", "link_url"), 56 | URLHelperMixinFactory("FinalURLHelperMixin", "final_url") 57 | ): 58 | """ 59 | This helper lets us set the `link_url` and `final_url` for our resources and ensure that we get compliant URLS on 60 | all systems, including Windows where os.path is not a POSIX path. 61 | """ 62 | -------------------------------------------------------------------------------- /requirements.2.6.txt: -------------------------------------------------------------------------------- 1 | keyring>=5.0,<6.0 2 | -------------------------------------------------------------------------------- /requirements.2.7.txt: -------------------------------------------------------------------------------- 1 | keyring>=4.0 2 | -------------------------------------------------------------------------------- /requirements.3.txt: -------------------------------------------------------------------------------- 1 | keyring>=4.0 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=1.6,<1.7 2 | django-markwhat>=1.4,<2 3 | markdown2 4 | argparse 5 | keyring 6 | boto>=2.4.1 7 | tornado>=3.2 8 | colorlog 9 | colorama 10 | six>=1.9.0 11 | PyYAML 12 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | 4 | import cactus 5 | 6 | from cactus.cli import main 7 | 8 | print "Using: %s" % cactus.__file__ 9 | 10 | if __name__ == "__main__": 11 | main() -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | nose 2 | mock 3 | tox 4 | unittest2 5 | requests 6 | -------------------------------------------------------------------------------- /todo.txt: -------------------------------------------------------------------------------- 1 | # Features 2 | 3 | - Attempt to re-use the same port 4 | - Kill process when parent dies 5 | 6 | - GC old stuff from s3? 7 | - See if all assets are used 8 | - Update html, css last on deploy to prevent/minimize weirdness 9 | 10 | # Plugins 11 | 12 | - Less CSS 13 | - Javascript Clojure/Minify 14 | - Offline Manifest 15 | - CloudFront -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist={py26,py27,py34,py35}-{win,linux,darwin} 3 | 4 | [testenv] 5 | platform= 6 | win: win 7 | linux: linux 8 | darwin: darwin 9 | deps = 10 | --requirement={toxinidir}/requirements.txt 11 | --requirement={toxinidir}/test_requirements.txt 12 | darwin: macfsevents 13 | commands= 14 | nosetests {posargs} 15 | --------------------------------------------------------------------------------