| @@ -3,7 +3,7 @@ | |||||
| Blockdown plugin | Blockdown plugin | ||||
| """ | """ | ||||
| from hyde.ext.plugins.texty import TextyPlugin | |||||
| from hyde.plugin import TextyPlugin | |||||
| class BlockdownPlugin(TextyPlugin): | class BlockdownPlugin(TextyPlugin): | ||||
| """ | """ | ||||
| @@ -46,4 +46,5 @@ class DependsPlugin(Plugin): | |||||
| resource=resource, | resource=resource, | ||||
| site=self.site, | site=self.site, | ||||
| context=self.site.context)) | context=self.site.context)) | ||||
| resource.depends = list(set(resource.depends)) | |||||
| return text | return text | ||||
| @@ -3,15 +3,14 @@ | |||||
| Less css plugin | Less css plugin | ||||
| """ | """ | ||||
| from hyde.plugin import Plugin | |||||
| from hyde.fs import File, Folder | |||||
| from hyde.plugin import CLTransformer | |||||
| from hyde.fs import File | |||||
| import re | import re | ||||
| import subprocess | import subprocess | ||||
| import traceback | |||||
| class LessCSSPlugin(Plugin): | |||||
| class LessCSSPlugin(CLTransformer): | |||||
| """ | """ | ||||
| The plugin class for less css | The plugin class for less css | ||||
| """ | """ | ||||
| @@ -46,6 +45,14 @@ class LessCSSPlugin(Plugin): | |||||
| text = import_finder.sub(import_to_include, text) | text = import_finder.sub(import_to_include, text) | ||||
| return text | return text | ||||
| @property | |||||
| def plugin_name(self): | |||||
| """ | |||||
| The name of the plugin. | |||||
| """ | |||||
| return "less" | |||||
| def text_resource_complete(self, resource, text): | def text_resource_complete(self, resource, text): | ||||
| """ | """ | ||||
| Save the file to a temporary place and run less compiler. | Save the file to a temporary place and run less compiler. | ||||
| @@ -54,30 +61,15 @@ class LessCSSPlugin(Plugin): | |||||
| """ | """ | ||||
| if not resource.source_file.kind == 'less': | if not resource.source_file.kind == 'less': | ||||
| return | return | ||||
| if not (hasattr(self.site.config, 'less') and | |||||
| hasattr(self.site.config.less, 'app')): | |||||
| raise self.template.exception_class( | |||||
| "Less css path not configured. " | |||||
| "This plugin expects `less.app` to point " | |||||
| "to the `lessc` executable.") | |||||
| less = File(self.site.config.less.app) | |||||
| if not File(less).exists: | |||||
| raise self.template.exception_class( | |||||
| "Cannot find the less executable. The given path [%s] " | |||||
| "is incorrect" % less) | |||||
| less = self.app | |||||
| source = File.make_temp(text) | source = File.make_temp(text) | ||||
| target = File.make_temp('') | target = File.make_temp('') | ||||
| try: | try: | ||||
| subprocess.check_call([str(less), str(source), str(target)]) | |||||
| except subprocess.CalledProcessError, error: | |||||
| self.logger.error(traceback.format_exc()) | |||||
| self.logger.error(error.output) | |||||
| raise self.template.exception_class( | |||||
| "Cannot process less css. Error occurred when " | |||||
| "processing [%s]" % resource.source_file) | |||||
| self.call_app([str(less), str(source), str(target)]) | |||||
| except subprocess.CalledProcessError: | |||||
| raise self.template.exception_class( | |||||
| "Cannot process %s. Error occurred when " | |||||
| "processing [%s]" % (self.app.name, resource.source_file)) | |||||
| out = target.read_all() | out = target.read_all() | ||||
| new_name = resource.source_file.name_without_extension + ".css" | new_name = resource.source_file.name_without_extension + ".css" | ||||
| target_folder = File(resource.relative_path).parent | target_folder = File(resource.relative_path).parent | ||||
| @@ -3,7 +3,7 @@ | |||||
| Markings plugin | Markings plugin | ||||
| """ | """ | ||||
| from hyde.ext.plugins.texty import TextyPlugin | |||||
| from hyde.plugin import TextyPlugin | |||||
| class MarkingsPlugin(TextyPlugin): | class MarkingsPlugin(TextyPlugin): | ||||
| """ | """ | ||||
| @@ -3,7 +3,7 @@ | |||||
| Syntext plugin | Syntext plugin | ||||
| """ | """ | ||||
| from hyde.ext.plugins.texty import TextyPlugin | |||||
| from hyde.plugin import TextyPlugin | |||||
| class SyntextPlugin(TextyPlugin): | class SyntextPlugin(TextyPlugin): | ||||
| """ | """ | ||||
| @@ -1,91 +0,0 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| """ | |||||
| Provides classes and utilities that allow text | |||||
| to be replaced before the templates are | |||||
| rendered. | |||||
| """ | |||||
| from hyde.plugin import Plugin | |||||
| import abc | |||||
| import re | |||||
| from functools import partial | |||||
| class TextyPlugin(Plugin): | |||||
| """ | |||||
| Base class for text preprocessing plugins. | |||||
| Plugins that desire to provide syntactic sugar for | |||||
| commonly used hyde functions for various templates | |||||
| can inherit from this class. | |||||
| """ | |||||
| __metaclass__ = abc.ABCMeta | |||||
| def __init__(self, site): | |||||
| super(TextyPlugin, self).__init__(site) | |||||
| self.open_pattern = self.default_open_pattern | |||||
| self.close_pattern = self.default_close_pattern | |||||
| self.template = None | |||||
| config = getattr(site.config, self.plugin_name, None) | |||||
| if config and hasattr(config, 'open_pattern'): | |||||
| self.open_pattern = config.open_pattern | |||||
| if self.close_pattern and config and hasattr(config, 'close_pattern'): | |||||
| self.close_pattern = config.close_pattern | |||||
| @property | |||||
| def plugin_name(self): | |||||
| """ | |||||
| The name of the plugin. Makes an intelligent guess. | |||||
| """ | |||||
| return self.__class__.__name__.replace('Plugin', '').lower() | |||||
| @abc.abstractproperty | |||||
| def tag_name(self): | |||||
| """ | |||||
| The tag that this plugin tries add syntactic sugar for. | |||||
| """ | |||||
| return self.plugin_name | |||||
| @abc.abstractproperty | |||||
| def default_open_pattern(self): | |||||
| """ | |||||
| The default pattern for opening the tag. | |||||
| """ | |||||
| return None | |||||
| @abc.abstractproperty | |||||
| def default_close_pattern(self): | |||||
| """ | |||||
| The default pattern for closing the tag. | |||||
| """ | |||||
| return None | |||||
| def get_params(self, match, start=True): | |||||
| return match.groups(1)[0] if match.lastindex else '' | |||||
| @abc.abstractmethod | |||||
| def text_to_tag(self, match, start=True): | |||||
| """ | |||||
| Replaces the matched text with tag statement | |||||
| given by the template. | |||||
| """ | |||||
| params = self.get_params(match, start) | |||||
| return (self.template.get_open_tag(self.tag_name, params) | |||||
| if start | |||||
| else self.template.get_close_tag(self.tag_name, params)) | |||||
| def begin_text_resource(self, resource, text): | |||||
| """ | |||||
| Replace a text base pattern with a template statement. | |||||
| """ | |||||
| text_open = re.compile(self.open_pattern, re.UNICODE|re.MULTILINE) | |||||
| text = text_open.sub(self.text_to_tag, text) | |||||
| if self.close_pattern: | |||||
| text_close = re.compile(self.close_pattern, re.UNICODE|re.MULTILINE) | |||||
| text = text_close.sub( | |||||
| partial(self.text_to_tag, start=False), text) | |||||
| return text | |||||
| @@ -0,0 +1,75 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| """ | |||||
| Uglify plugin | |||||
| """ | |||||
| from hyde.plugin import CLTransformer | |||||
| from hyde.fs import File, Folder | |||||
| import subprocess | |||||
| import traceback | |||||
| class UglifyPlugin(CLTransformer): | |||||
| """ | |||||
| The plugin class for Uglify JS | |||||
| """ | |||||
| def __init__(self, site): | |||||
| super(UglifyPlugin, self).__init__(site) | |||||
| @property | |||||
| def plugin_name(self): | |||||
| """ | |||||
| The name of the plugin. | |||||
| """ | |||||
| return "uglify" | |||||
| def text_resource_complete(self, resource, text): | |||||
| """ | |||||
| If the site is in development mode, just return. | |||||
| Otherwise, save the file to a temporary place | |||||
| and run the uglify app. Read the generated file | |||||
| and return the text as output. | |||||
| """ | |||||
| try: | |||||
| mode = self.site.config.mode | |||||
| except AttributeError: | |||||
| mode = "production" | |||||
| if not resource.source_file.kind == 'js': | |||||
| return | |||||
| if self.site.config.mode.startswith('dev'): | |||||
| self.logger.debug("Skipping uglify in development mode.") | |||||
| return | |||||
| supported = [ | |||||
| ("beautify", "b"), | |||||
| ("indent", "i"), | |||||
| ("quote-keys", "q"), | |||||
| ("mangle-toplevel", "mt"), | |||||
| ("no-mangle", "nm"), | |||||
| ("no-squeeze", "ns"), | |||||
| "no-seqs", | |||||
| "no-dead-code", | |||||
| ("no-copyright", "nc"), | |||||
| "overwrite", | |||||
| "verbose", | |||||
| "unsafe", | |||||
| "max-line-len", | |||||
| "reserved-names", | |||||
| "ascii" | |||||
| ] | |||||
| uglify = self.app | |||||
| source = File.make_temp(text) | |||||
| target = File.make_temp('') | |||||
| args = [str(uglify)] | |||||
| args.extend(self.process_args(supported)) | |||||
| args.extend(["-o", str(target), str(source)]) | |||||
| self.call_app(args) | |||||
| out = target.read_all() | |||||
| return out | |||||
| @@ -9,7 +9,8 @@ from hyde.template import HtmlWrap, Template | |||||
| from hyde.site import Resource | from hyde.site import Resource | ||||
| from hyde.util import getLoggerWithNullHandler, getLoggerWithConsoleHandler | from hyde.util import getLoggerWithNullHandler, getLoggerWithConsoleHandler | ||||
| from jinja2 import contextfunction, Environment, FileSystemLoader | |||||
| from jinja2 import contextfunction, Environment | |||||
| from jinja2 import FileSystemLoader, FileSystemBytecodeCache | |||||
| from jinja2 import environmentfilter, Markup, Undefined, nodes | from jinja2 import environmentfilter, Markup, Undefined, nodes | ||||
| from jinja2.ext import Extension | from jinja2.ext import Extension | ||||
| from jinja2.exceptions import TemplateError | from jinja2.exceptions import TemplateError | ||||
| @@ -358,7 +359,8 @@ class Refer(Extension): | |||||
| namespace['parent_resource'] = resource | namespace['parent_resource'] = resource | ||||
| if not hasattr(resource, 'depends'): | if not hasattr(resource, 'depends'): | ||||
| resource.depends = [] | resource.depends = [] | ||||
| resource.depends.append(template) | |||||
| if not template in resource.depends: | |||||
| resource.depends.append(template) | |||||
| namespace['resource'] = site.content.resource_from_relative_path(template) | namespace['resource'] = site.content.resource_from_relative_path(template) | ||||
| return '' | return '' | ||||
| @@ -433,6 +435,7 @@ class Jinja2Template(Template): | |||||
| self.env = Environment(loader=self.loader, | self.env = Environment(loader=self.loader, | ||||
| undefined=SilentUndefined, | undefined=SilentUndefined, | ||||
| trim_blocks=True, | trim_blocks=True, | ||||
| bytecode_cache=FileSystemBytecodeCache(), | |||||
| extensions=[IncludeText, | extensions=[IncludeText, | ||||
| Markdown, | Markdown, | ||||
| Syntax, | Syntax, | ||||
| @@ -98,6 +98,7 @@ class Config(Expando): | |||||
| def __init__(self, sitepath, config_file=None, config_dict=None): | def __init__(self, sitepath, config_file=None, config_dict=None): | ||||
| default_config = dict( | default_config = dict( | ||||
| mode='production', | |||||
| content_root='content', | content_root='content', | ||||
| deploy_root='deploy', | deploy_root='deploy', | ||||
| media_root='media', | media_root='media', | ||||
| @@ -3,10 +3,18 @@ | |||||
| Contains definition for a plugin protocol and other utiltities. | Contains definition for a plugin protocol and other utiltities. | ||||
| """ | """ | ||||
| import abc | import abc | ||||
| from hyde import loader | from hyde import loader | ||||
| from hyde.exceptions import HydeException | |||||
| from hyde.fs import File | |||||
| from hyde.util import getLoggerWithNullHandler | from hyde.util import getLoggerWithNullHandler | ||||
| from hyde.model import Expando | |||||
| from functools import partial | from functools import partial | ||||
| import re | |||||
| import subprocess | |||||
| import traceback | |||||
| logger = getLoggerWithNullHandler('hyde.engine') | logger = getLoggerWithNullHandler('hyde.engine') | ||||
| @@ -56,6 +64,7 @@ class Plugin(object): | |||||
| super(Plugin, self).__init__() | super(Plugin, self).__init__() | ||||
| self.site = site | self.site = site | ||||
| self.logger = getLoggerWithNullHandler(self.__class__.__name__) | self.logger = getLoggerWithNullHandler(self.__class__.__name__) | ||||
| self.template = None | |||||
| def template_loaded(self, template): | def template_loaded(self, template): | ||||
| @@ -170,9 +179,6 @@ class Plugin(object): | |||||
| """ | """ | ||||
| pass | pass | ||||
| def raise_event(self, event_name): | |||||
| return getattr(Plugin.proxy, event_name)() | |||||
| @staticmethod | @staticmethod | ||||
| def load_all(site): | def load_all(site): | ||||
| """ | """ | ||||
| @@ -184,4 +190,185 @@ class Plugin(object): | |||||
| @staticmethod | @staticmethod | ||||
| def get_proxy(site): | def get_proxy(site): | ||||
| """ | |||||
| Returns a new instance of the Plugin proxy. | |||||
| """ | |||||
| return PluginProxy(site) | return PluginProxy(site) | ||||
| class CLTransformer(Plugin): | |||||
| """ | |||||
| Handy class for plugins that simply call a command line app to | |||||
| transform resources. | |||||
| """ | |||||
| @property | |||||
| def plugin_name(self): | |||||
| """ | |||||
| The name of the plugin. Makes an intelligent guess. | |||||
| """ | |||||
| return self.__class__.__name__.replace('Plugin', '').lower() | |||||
| def defaults(self): | |||||
| """ | |||||
| Default command line options. Can be overridden | |||||
| by specifying them in config. | |||||
| """ | |||||
| return {} | |||||
| @property | |||||
| def executable_not_found_message(self): | |||||
| """ | |||||
| Message to be displayed if the command line application | |||||
| is not found. | |||||
| """ | |||||
| return ("%(name)s executable path not configured properly. " | |||||
| "This plugin expects `%(name)s.app` to point " | |||||
| "to the `%(name)s` executable." % {"name": self.plugin_name}) | |||||
| @property | |||||
| def settings(self): | |||||
| """ | |||||
| The settings for this plugin the site config. | |||||
| """ | |||||
| opts = Expando({}) | |||||
| try: | |||||
| opts = getattr(self.site.config, self.plugin_name) | |||||
| except AttributeError: | |||||
| pass | |||||
| return opts | |||||
| @property | |||||
| def app(self): | |||||
| """ | |||||
| Gets the application path from the site configuration. | |||||
| """ | |||||
| try: | |||||
| app_path = getattr(self.settings, 'app') | |||||
| except AttributeError: | |||||
| raise self.template.exception_class( | |||||
| self.executable_not_found_message) | |||||
| app = File(app_path) | |||||
| if not app.exists: | |||||
| raise self.template.exception_class( | |||||
| self.executable_not_found_message) | |||||
| return app | |||||
| def process_args(self, supported): | |||||
| try: | |||||
| args = getattr(self.settings, 'args').to_dict() | |||||
| except AttributeError: | |||||
| args = {} | |||||
| result = [] | |||||
| for arg in supported: | |||||
| if isinstance(arg, tuple): | |||||
| (descriptive, short) = arg | |||||
| else: | |||||
| descriptive = short = arg | |||||
| if descriptive in args or short in args: | |||||
| result.append("--%s" % descriptive) | |||||
| val = args[descriptive if descriptive in args else short] | |||||
| if val: | |||||
| result.append(val) | |||||
| return result | |||||
| def call_app(self, args): | |||||
| """ | |||||
| Calls the application with the given command line parameters. | |||||
| """ | |||||
| try: | |||||
| self.logger.debug( | |||||
| "Calling executable[%s] with arguments %s" % | |||||
| (args[0], str(args[1:]))) | |||||
| subprocess.check_call(args) | |||||
| except subprocess.CalledProcessError, error: | |||||
| self.logger.error(traceback.format_exc()) | |||||
| self.logger.error(error.output) | |||||
| raise | |||||
| class TextyPlugin(Plugin): | |||||
| """ | |||||
| Base class for text preprocessing plugins. | |||||
| Plugins that desire to provide syntactic sugar for | |||||
| commonly used hyde functions for various templates | |||||
| can inherit from this class. | |||||
| """ | |||||
| __metaclass__ = abc.ABCMeta | |||||
| def __init__(self, site): | |||||
| super(TextyPlugin, self).__init__(site) | |||||
| self.open_pattern = self.default_open_pattern | |||||
| self.close_pattern = self.default_close_pattern | |||||
| self.template = None | |||||
| config = getattr(site.config, self.plugin_name, None) | |||||
| if config and hasattr(config, 'open_pattern'): | |||||
| self.open_pattern = config.open_pattern | |||||
| if self.close_pattern and config and hasattr(config, 'close_pattern'): | |||||
| self.close_pattern = config.close_pattern | |||||
| @property | |||||
| def plugin_name(self): | |||||
| """ | |||||
| The name of the plugin. Makes an intelligent guess. | |||||
| """ | |||||
| return self.__class__.__name__.replace('Plugin', '').lower() | |||||
| @abc.abstractproperty | |||||
| def tag_name(self): | |||||
| """ | |||||
| The tag that this plugin tries add syntactic sugar for. | |||||
| """ | |||||
| return self.plugin_name | |||||
| @abc.abstractproperty | |||||
| def default_open_pattern(self): | |||||
| """ | |||||
| The default pattern for opening the tag. | |||||
| """ | |||||
| return None | |||||
| @abc.abstractproperty | |||||
| def default_close_pattern(self): | |||||
| """ | |||||
| The default pattern for closing the tag. | |||||
| """ | |||||
| return None | |||||
| def get_params(self, match, start=True): | |||||
| return match.groups(1)[0] if match.lastindex else '' | |||||
| @abc.abstractmethod | |||||
| def text_to_tag(self, match, start=True): | |||||
| """ | |||||
| Replaces the matched text with tag statement | |||||
| given by the template. | |||||
| """ | |||||
| params = self.get_params(match, start) | |||||
| return (self.template.get_open_tag(self.tag_name, params) | |||||
| if start | |||||
| else self.template.get_close_tag(self.tag_name, params)) | |||||
| def begin_text_resource(self, resource, text): | |||||
| """ | |||||
| Replace a text base pattern with a template statement. | |||||
| """ | |||||
| text_open = re.compile(self.open_pattern, re.UNICODE|re.MULTILINE) | |||||
| text = text_open.sub(self.text_to_tag, text) | |||||
| if self.close_pattern: | |||||
| text_close = re.compile(self.close_pattern, re.UNICODE|re.MULTILINE) | |||||
| text = text_close.sub( | |||||
| partial(self.text_to_tag, start=False), text) | |||||
| return text | |||||
| @@ -49,6 +49,7 @@ depends: index.html | |||||
| gen.template.env.filters['dateformat'] = dateformat | gen.template.env.filters['dateformat'] = dateformat | ||||
| gen.generate_resource_at_path(inc.name) | gen.generate_resource_at_path(inc.name) | ||||
| res = s.content.resource_from_relative_path(inc.name) | res = s.content.resource_from_relative_path(inc.name) | ||||
| print res.__dict__ | |||||
| assert len(res.depends) == 1 | assert len(res.depends) == 1 | ||||
| assert 'index.html' in res.depends | assert 'index.html' in res.depends | ||||
| deps = list(gen.get_dependencies(res)) | deps = list(gen.get_dependencies(res)) | ||||
| @@ -0,0 +1,95 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| """ | |||||
| Use nose | |||||
| `$ pip install nose` | |||||
| `$ nosetests` | |||||
| """ | |||||
| from hyde.fs import File, Folder | |||||
| from hyde.model import Expando | |||||
| from hyde.generator import Generator | |||||
| from hyde.site import Site | |||||
| UGLIFY_SOURCE = File(__file__).parent.child_folder('uglify') | |||||
| TEST_SITE = File(__file__).parent.parent.child_folder('_test') | |||||
| class TestLess(object): | |||||
| def setUp(self): | |||||
| TEST_SITE.make() | |||||
| TEST_SITE.parent.child_folder( | |||||
| 'sites/test_jinja').copy_contents_to(TEST_SITE) | |||||
| JS = TEST_SITE.child_folder('content/media/js') | |||||
| JS.make() | |||||
| UGLIFY_SOURCE.copy_contents_to(JS) | |||||
| def tearDown(self): | |||||
| TEST_SITE.delete() | |||||
| def test_can_uglify(self): | |||||
| s = Site(TEST_SITE) | |||||
| s.config.plugins = ['hyde.ext.plugins.uglify.UglifyPlugin'] | |||||
| s.config.mode = "production" | |||||
| paths = ['/usr/local/share/npm/bin/uglifyjs', '~/local/bin/uglifyjs'] | |||||
| uglify = [path for path in paths if File(path).exists] | |||||
| if not uglify: | |||||
| assert False, "Cannot find the uglify executable" | |||||
| uglify = uglify[0] | |||||
| s.config.uglify = Expando(dict(app=path)) | |||||
| source = TEST_SITE.child('content/media/js/jquery.js') | |||||
| target = File(Folder(s.config.deploy_root_path).child('media/js/jquery.js')) | |||||
| gen = Generator(s) | |||||
| gen.generate_resource_at_path(source) | |||||
| assert target.exists | |||||
| expected = File(UGLIFY_SOURCE.child('expected-jquery.js')) | |||||
| # TODO: Very fragile. Better comparison needed. | |||||
| assert target.read_all() == expected.read_all() | |||||
| def test_uglify_with_extra_options(self): | |||||
| s = Site(TEST_SITE) | |||||
| s.config.plugins = ['hyde.ext.plugins.uglify.UglifyPlugin'] | |||||
| s.config.mode = "production" | |||||
| paths = ['/usr/local/share/npm/bin/uglifyjs', '~/local/bin/uglifyjs'] | |||||
| uglify = [path for path in paths if File(path).exists] | |||||
| if not uglify: | |||||
| assert False, "Cannot find the uglify executable" | |||||
| uglify = uglify[0] | |||||
| s.config.uglify = Expando(dict(app=path, args={"nc":""})) | |||||
| source = TEST_SITE.child('content/media/js/jquery.js') | |||||
| target = File(Folder(s.config.deploy_root_path).child('media/js/jquery.js')) | |||||
| gen = Generator(s) | |||||
| gen.generate_resource_at_path(source) | |||||
| assert target.exists | |||||
| expected = File(UGLIFY_SOURCE.child('expected-jquery-nc.js')) | |||||
| # TODO: Very fragile. Better comparison needed. | |||||
| text = target.read_all() | |||||
| assert text.startswith("(function(") | |||||
| def test_no_uglify_in_dev_mode(self): | |||||
| s = Site(TEST_SITE) | |||||
| s.config.plugins = ['hyde.ext.plugins.uglify.UglifyPlugin'] | |||||
| s.config.mode = "dev" | |||||
| paths = ['/usr/local/share/npm/bin/uglifyjs', '~/local/bin/uglifyjs'] | |||||
| uglify = [path for path in paths if File(path).exists] | |||||
| if not uglify: | |||||
| assert False, "Cannot find the uglify executable" | |||||
| uglify = uglify[0] | |||||
| s.config.uglify = Expando(dict(app=path)) | |||||
| source = TEST_SITE.child('content/media/js/jquery.js') | |||||
| target = File(Folder(s.config.deploy_root_path).child('media/js/jquery.js')) | |||||
| gen = Generator(s) | |||||
| gen.generate_resource_at_path(source) | |||||
| assert target.exists | |||||
| expected = File(UGLIFY_SOURCE.child('jquery.js')) | |||||
| # TODO: Very fragile. Better comparison needed. | |||||
| text = target.read_all() | |||||
| expected = expected.read_all() | |||||
| assert text == expected | |||||