[Checkins] SVN: zope.pagetemplate/trunk/ Moved 'engine' module and here from ``zope.app.pagetemplate`` package.
Tres Seaver
tseaver at palladion.com
Mon May 25 14:57:21 EDT 2009
Log message for revision 100365:
Moved 'engine' module and here from ``zope.app.pagetemplate`` package.
o Moved / cleaned up related test scaffolding too.
Changed:
U zope.pagetemplate/trunk/CHANGES.txt
U zope.pagetemplate/trunk/setup.py
A zope.pagetemplate/trunk/src/zope/pagetemplate/engine.py
A zope.pagetemplate/trunk/src/zope/pagetemplate/i18n.py
A zope.pagetemplate/trunk/src/zope/pagetemplate/tests/test_engine.py
A zope.pagetemplate/trunk/src/zope/pagetemplate/tests/trusted.py
-=-
Modified: zope.pagetemplate/trunk/CHANGES.txt
===================================================================
--- zope.pagetemplate/trunk/CHANGES.txt 2009-05-25 16:51:03 UTC (rev 100364)
+++ zope.pagetemplate/trunk/CHANGES.txt 2009-05-25 18:57:21 UTC (rev 100365)
@@ -2,10 +2,11 @@
CHANGES
=======
-3.4.3 (unreleased)
+3.5.0 (unreleased)
------------------
-- ...
+- Moved 'engine' module and related test scaffolding here from
+ ``zope.app.pagetemplate`` package.
3.4.2 (2009-03-17)
------------------
Modified: zope.pagetemplate/trunk/setup.py
===================================================================
--- zope.pagetemplate/trunk/setup.py 2009-05-25 16:51:03 UTC (rev 100364)
+++ zope.pagetemplate/trunk/setup.py 2009-05-25 18:57:21 UTC (rev 100365)
@@ -22,7 +22,7 @@
return open(os.path.join(os.path.dirname(__file__), *rnames)).read()
setup(name='zope.pagetemplate',
- version = '3.4.3dev',
+ version = '3.5.0dev',
author='Zope Corporation and Contributors',
author_email='zope-dev at zope.org',
description='Zope Page Templates',
@@ -56,6 +56,11 @@
namespace_packages=['zope'],
extras_require = dict(
test=['zope.testing',
+ 'zope.component',
+ 'zope.proxy',
+ 'zope.traversing',
+ 'zope.security',
+ 'RestrictedPython',
]),
install_requires=['setuptools',
'zope.interface',
Copied: zope.pagetemplate/trunk/src/zope/pagetemplate/engine.py (from rev 100358, zope.app.pagetemplate/trunk/src/zope/app/pagetemplate/engine.py)
===================================================================
--- zope.pagetemplate/trunk/src/zope/pagetemplate/engine.py (rev 0)
+++ zope.pagetemplate/trunk/src/zope/pagetemplate/engine.py 2009-05-25 18:57:21 UTC (rev 100365)
@@ -0,0 +1,478 @@
+##############################################################################
+#
+# Copyright (c) 2002-2009 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE
+#
+##############################################################################
+"""Expression engine configuration and registration.
+
+Each expression engine can have its own expression types and base names.
+
+$Id$
+"""
+__docformat__ = 'restructuredtext'
+
+import sys
+
+from zope import component
+from zope.interface import implements
+from zope.component.interfaces import ComponentLookupError
+from zope.traversing.interfaces import IPathAdapter, ITraversable
+from zope.traversing.interfaces import TraversalError
+from zope.traversing.adapters import traversePathElement
+from zope.security.untrustedpython import rcompile
+from zope.security.proxy import ProxyFactory, removeSecurityProxy
+from zope.security.untrustedpython.builtins import SafeBuiltins
+from zope.i18n import translate
+
+from zope.tales.expressions import PathExpr, StringExpr, NotExpr, DeferExpr
+from zope.tales.expressions import SimpleModuleImporter
+from zope.tales.pythonexpr import PythonExpr
+from zope.tales.tales import ExpressionEngine, Context
+
+from i18n import ZopeMessageFactory as _
+
+class InlineCodeError(Exception):
+ pass
+
+class ZopeTraverser(object):
+
+ def __init__(self, proxify=None):
+ if proxify is None:
+ self.proxify = lambda x: x
+ else:
+ self.proxify = proxify
+
+ def __call__(self, object, path_items, econtext):
+ """Traverses a sequence of names, first trying attributes then items.
+ """
+ request = getattr(econtext, 'request', None)
+ path_items = list(path_items)
+ path_items.reverse()
+
+ while path_items:
+ name = path_items.pop()
+
+ # special-case dicts for performance reasons
+ if getattr(object, '__class__', None) == dict:
+ object = object[name]
+ else:
+ object = traversePathElement(object, name, path_items,
+ request=request)
+ object = self.proxify(object)
+ return object
+
+zopeTraverser = ZopeTraverser(ProxyFactory)
+
+class ZopePathExpr(PathExpr):
+
+ def __init__(self, name, expr, engine):
+ super(ZopePathExpr, self).__init__(name, expr, engine, zopeTraverser)
+
+trustedZopeTraverser = ZopeTraverser()
+
+class TrustedZopePathExpr(PathExpr):
+
+ def __init__(self, name, expr, engine):
+ super(TrustedZopePathExpr, self).__init__(name, expr, engine,
+ trustedZopeTraverser)
+
+
+# Create a version of the restricted built-ins that uses a safe
+# version of getattr() that wraps values in security proxies where
+# appropriate:
+
+
+class ZopePythonExpr(PythonExpr):
+
+ def __call__(self, econtext):
+ __traceback_info__ = self.text
+ vars = self._bind_used_names(econtext, SafeBuiltins)
+ return eval(self._code, vars)
+
+ def _compile(self, text, filename):
+ return rcompile.compile(text, filename, 'eval')
+
+
+class ZopeContextBase(Context):
+ """Base class for both trusted and untrusted evaluation contexts."""
+
+ def translate(self, msgid, domain=None, mapping=None, default=None):
+ return translate(msgid, domain, mapping,
+ context=self.request, default=default)
+
+ evaluateInlineCode = False
+
+ def evaluateCode(self, lang, code):
+ if not self.evaluateInlineCode:
+ raise InlineCodeError(
+ _('Inline Code Evaluation is deactivated, which means that '
+ 'you cannot have inline code snippets in your Page '
+ 'Template. Activate Inline Code Evaluation and try again.'))
+
+ # TODO This is only needed when self.evaluateInlineCode is true,
+ # so should only be needed for zope.app.pythonpage.
+ from zope.app.interpreter.interfaces import IInterpreter
+ interpreter = component.queryUtility(IInterpreter, lang)
+ if interpreter is None:
+ error = _('No interpreter named "${lang_name}" was found.',
+ mapping={'lang_name': lang})
+ raise InlineCodeError(error)
+
+ globals = self.vars.copy()
+ result = interpreter.evaluateRawCode(code, globals)
+ # Add possibly new global variables.
+ old_names = self.vars.keys()
+ for name, value in globals.items():
+ if name not in old_names:
+ self.setGlobal(name, value)
+ return result
+
+
+class ZopeContext(ZopeContextBase):
+ """Evaluation context for untrusted programs."""
+
+ def evaluateMacro(self, expr):
+ """evaluateMacro gets security-proxied macro programs when this
+ is run with the zopeTraverser, and in other untrusted
+ situations. This will cause evaluation to fail in
+ zope.tal.talinterpreter, which knows nothing of security proxies.
+ Therefore, this method removes any proxy from the evaluated
+ expression.
+
+ >>> output = [('version', 'xxx'), ('mode', 'html'), ('other', 'things')]
+ >>> def expression(context):
+ ... return ProxyFactory(output)
+ ...
+ >>> zc = ZopeContext(ExpressionEngine, {})
+ >>> out = zc.evaluateMacro(expression)
+ >>> type(out)
+ <type 'list'>
+
+ The method does some trivial checking to make sure we are getting
+ back a macro like we expect: it must be a sequence of sequences, in
+ which the first sequence must start with 'version', and the second
+ must start with 'mode'.
+
+ >>> del output[0]
+ >>> zc.evaluateMacro(expression) # doctest: +ELLIPSIS
+ Traceback (most recent call last):
+ ...
+ ValueError: ('unexpected result from macro evaluation.', ...)
+
+ >>> del output[:]
+ >>> zc.evaluateMacro(expression) # doctest: +ELLIPSIS
+ Traceback (most recent call last):
+ ...
+ ValueError: ('unexpected result from macro evaluation.', ...)
+
+ >>> output = None
+ >>> zc.evaluateMacro(expression) # doctest: +ELLIPSIS
+ Traceback (most recent call last):
+ ...
+ ValueError: ('unexpected result from macro evaluation.', ...)
+ """
+ macro = removeSecurityProxy(Context.evaluateMacro(self, expr))
+ # we'll do some basic checks that it is the sort of thing we expect
+ problem = False
+ try:
+ problem = macro[0][0] != 'version' or macro[1][0] != 'mode'
+ except (TypeError, IndexError):
+ problem = True
+ if problem:
+ raise ValueError('unexpected result from macro evaluation.', macro)
+ return macro
+
+ def setContext(self, name, value):
+ # Hook to allow subclasses to do things like adding security proxies
+ Context.setContext(self, name, ProxyFactory(value))
+
+
+class TrustedZopeContext(ZopeContextBase):
+ """Evaluation context for trusted programs."""
+
+
+class AdapterNamespaces(object):
+ """Simulate tales function namespaces with adapter lookup.
+
+ When we are asked for a namespace, we return an object that
+ actually computes an adapter when called:
+
+ To demonstrate this, we need to register an adapter:
+
+ >>> from zope.component.testing import setUp, tearDown
+ >>> setUp()
+ >>> from zope.component import provideAdapter
+ >>> def adapter1(ob):
+ ... return 1
+ >>> adapter1.__component_adapts__ = (None,)
+ >>> provideAdapter(adapter1, None, IPathAdapter, 'a1')
+
+ Now, with this adapter in place, we can try out the namespaces:
+
+ >>> ob = object()
+ >>> namespaces = AdapterNamespaces()
+ >>> namespace = namespaces['a1']
+ >>> namespace(ob)
+ 1
+ >>> namespace = namespaces['a2']
+ >>> namespace(ob)
+ Traceback (most recent call last):
+ ...
+ KeyError: 'a2'
+
+
+ Cleanup:
+
+ >>> tearDown()
+ """
+
+ def __init__(self):
+ self.namespaces = {}
+
+ def __getitem__(self, name):
+ namespace = self.namespaces.get(name)
+ if namespace is None:
+ def namespace(object):
+ try:
+ return component.getAdapter(object, IPathAdapter, name)
+ except ComponentLookupError:
+ raise KeyError(name)
+
+ self.namespaces[name] = namespace
+ return namespace
+
+
+class ZopeBaseEngine(ExpressionEngine):
+
+ _create_context = ZopeContext
+
+ def __init__(self):
+ ExpressionEngine.__init__(self)
+ self.namespaces = AdapterNamespaces()
+
+ def getContext(self, __namespace=None, **namespace):
+ if __namespace:
+ if namespace:
+ namespace.update(__namespace)
+ else:
+ namespace = __namespace
+
+ context = self._create_context(self, namespace)
+
+ # Put request into context so path traversal can find it
+ if 'request' in namespace:
+ context.request = namespace['request']
+
+ # Put context into context so path traversal can find it
+ if 'context' in namespace:
+ context.context = namespace['context']
+
+ return context
+
+class ZopeEngine(ZopeBaseEngine):
+ """Untrusted expression engine.
+
+ This engine does not allow modules to be imported; only modules
+ already available may be accessed::
+
+ >>> modname = 'zope.pagetemplate.tests.trusted'
+ >>> engine = _Engine()
+ >>> context = engine.getContext(engine.getBaseNames())
+
+ >>> modname in sys.modules
+ False
+ >>> context.evaluate('modules/' + modname)
+ Traceback (most recent call last):
+ ...
+ KeyError: 'zope.pagetemplate.tests.trusted'
+
+ (The use of ``KeyError`` is an unfortunate implementation detail; I
+ think this should be a ``TraversalError``.)
+
+ Modules which have already been imported by trusted code are
+ available, wrapped in security proxies::
+
+ >>> m = context.evaluate('modules/sys')
+ >>> m.__name__
+ 'sys'
+ >>> m._getframe
+ Traceback (most recent call last):
+ ...
+ ForbiddenAttribute: ('_getframe', <module 'sys' (built-in)>)
+
+ The results of Python expressions evaluated by this engine are
+ wrapped in security proxies::
+
+ >>> r = context.evaluate('python: {12: object()}.values')
+ >>> type(r)
+ <type 'zope.security._proxy._Proxy'>
+ >>> r = context.evaluate('python: {12: object()}.values()[0].__class__')
+ >>> type(r)
+ <type 'zope.security._proxy._Proxy'>
+
+ General path expressions provide objects that are wrapped in
+ security proxies as well::
+
+ >>> from zope.component.testing import setUp, tearDown
+ >>> from zope.security.checker import NamesChecker, defineChecker
+
+ >>> class Container(dict):
+ ... implements(ITraversable)
+ ... def traverse(self, name, further_path):
+ ... return self[name]
+
+ >>> setUp()
+ >>> defineChecker(Container, NamesChecker(['traverse']))
+ >>> d = engine.getBaseNames()
+ >>> foo = Container()
+ >>> foo.__name__ = 'foo'
+ >>> d['foo'] = ProxyFactory(foo)
+ >>> foo['bar'] = bar = Container()
+ >>> bar.__name__ = 'bar'
+ >>> bar.__parent__ = foo
+ >>> bar['baz'] = baz = Container()
+ >>> baz.__name__ = 'baz'
+ >>> baz.__parent__ = bar
+ >>> context = engine.getContext(d)
+
+ >>> o1 = context.evaluate('foo/bar')
+ >>> o1.__name__
+ 'bar'
+ >>> type(o1)
+ <type 'zope.security._proxy._Proxy'>
+
+ >>> o2 = context.evaluate('foo/bar/baz')
+ >>> o2.__name__
+ 'baz'
+ >>> type(o2)
+ <type 'zope.security._proxy._Proxy'>
+ >>> o3 = o2.__parent__
+ >>> type(o3)
+ <type 'zope.security._proxy._Proxy'>
+ >>> o1 == o3
+ True
+
+ >>> o1 is o2
+ False
+
+ Note that this engine special-cases dicts during path traversal:
+ it traverses only to their items, but not to their attributes
+ (e.g. methods on dicts), because of performance reasons:
+
+ >>> d = engine.getBaseNames()
+ >>> d['adict'] = {'items': 123}
+ >>> d['anotherdict'] = {}
+ >>> context = engine.getContext(d)
+ >>> context.evaluate('adict/items')
+ 123
+ >>> context.evaluate('anotherdict/keys')
+ Traceback (most recent call last):
+ ...
+ KeyError: 'keys'
+
+ >>> tearDown()
+
+ """
+
+ def getFunctionNamespace(self, namespacename):
+ """ Returns the function namespace """
+ return ProxyFactory(
+ super(ZopeEngine, self).getFunctionNamespace(namespacename))
+
+class TrustedZopeEngine(ZopeBaseEngine):
+ """Trusted expression engine.
+
+ This engine allows modules to be imported::
+
+ >>> modname = 'zope.pagetemplate.tests.trusted'
+ >>> engine = _TrustedEngine()
+ >>> context = engine.getContext(engine.getBaseNames())
+
+ >>> modname in sys.modules
+ False
+ >>> m = context.evaluate('modules/' + modname)
+ >>> m.__name__ == modname
+ True
+ >>> modname in sys.modules
+ True
+
+ Since this is trusted code, we can look at whatever is in the
+ module, not just __name__ or what's declared in a security
+ assertion::
+
+ >>> m.x
+ 42
+
+ Clean up after ourselves::
+
+ >>> del sys.modules[modname]
+
+ """
+
+ _create_context = TrustedZopeContext
+
+
+class TraversableModuleImporter(SimpleModuleImporter):
+
+ implements(ITraversable)
+
+ def traverse(self, name, further_path):
+ try:
+ return self[name]
+ except KeyError:
+ raise TraversalError(self, name)
+
+
+def _Engine(engine=None):
+ if engine is None:
+ engine = ZopeEngine()
+ engine = _create_base_engine(engine, ZopePathExpr)
+ engine.registerType('python', ZopePythonExpr)
+
+ # Using a proxy around sys.modules allows page templates to use
+ # modules for which security declarations have been made, but
+ # disallows execution of any import-time code for modules, which
+ # should not be allowed to happen during rendering.
+ engine.registerBaseName('modules', ProxyFactory(sys.modules))
+
+ return engine
+
+def _TrustedEngine(engine=None):
+ if engine is None:
+ engine = TrustedZopeEngine()
+ engine = _create_base_engine(engine, TrustedZopePathExpr)
+ engine.registerType('python', PythonExpr)
+ engine.registerBaseName('modules', TraversableModuleImporter())
+ return engine
+
+def _create_base_engine(engine, pathtype):
+ for pt in pathtype._default_type_names:
+ engine.registerType(pt, pathtype)
+ engine.registerType('string', StringExpr)
+ engine.registerType('not', NotExpr)
+ engine.registerType('defer', DeferExpr)
+ return engine
+
+
+Engine = _Engine()
+TrustedEngine = _TrustedEngine()
+
+
+class AppPT(object):
+
+ def pt_getEngine(self):
+ return Engine
+
+
+class TrustedAppPT(object):
+
+ def pt_getEngine(self):
+ return TrustedEngine
Copied: zope.pagetemplate/trunk/src/zope/pagetemplate/i18n.py (from rev 100358, zope.app.pagetemplate/trunk/src/zope/app/pagetemplate/i18n.py)
===================================================================
--- zope.pagetemplate/trunk/src/zope/pagetemplate/i18n.py (rev 0)
+++ zope.pagetemplate/trunk/src/zope/pagetemplate/i18n.py 2009-05-25 18:57:21 UTC (rev 100365)
@@ -0,0 +1,22 @@
+##############################################################################
+#
+# Copyright (c) 2003 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""Customization of zope.i18n for the Zope application server
+
+$Id$
+"""
+__docformat__ = 'restructuredtext'
+
+# import this as _ to create i18n messages in the zope domain
+from zope.i18nmessageid import MessageFactory
+ZopeMessageFactory = MessageFactory('zope')
Copied: zope.pagetemplate/trunk/src/zope/pagetemplate/tests/test_engine.py (from rev 100358, zope.app.pagetemplate/trunk/src/zope/app/pagetemplate/tests/test_engine.py)
===================================================================
--- zope.pagetemplate/trunk/src/zope/pagetemplate/tests/test_engine.py (rev 0)
+++ zope.pagetemplate/trunk/src/zope/pagetemplate/tests/test_engine.py 2009-05-25 18:57:21 UTC (rev 100365)
@@ -0,0 +1,94 @@
+##############################################################################
+#
+# Copyright (c) 2004-2009 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""Doc tests for the pagetemplate's 'engine' module
+
+$Id$
+"""
+import unittest
+
+class DummyNamespace(object):
+
+ def __init__(self, context):
+ self.context = context
+
+class EngineTests(unittest.TestCase):
+
+ def setUp(self):
+ from zope.component.testing import setUp
+ setUp()
+
+ def tearDown(self):
+ from zope.component.testing import tearDown
+ tearDown()
+
+ def test_function_namespaces_return_secured_proxies(self):
+ # See https://bugs.launchpad.net/zope3/+bug/98323
+ from zope.component import provideAdapter
+ from zope.traversing.interfaces import IPathAdapter
+ from zope.pagetemplate.engine import _Engine
+ from zope.proxy import isProxy
+ provideAdapter(DummyNamespace, (None,), IPathAdapter, name='test')
+ engine = _Engine()
+ namespace = engine.getFunctionNamespace('test')
+ self.failUnless(isProxy(namespace))
+
+class DummyEngine(object):
+
+ def getTypes(self):
+ return {}
+
+class DummyContext(object):
+
+ _engine = DummyEngine()
+
+ def __init__(self, **kw):
+ self.vars = kw
+
+class ZopePythonExprTests(unittest.TestCase):
+
+ def test_simple(self):
+ from zope.pagetemplate.engine import ZopePythonExpr
+ expr = ZopePythonExpr('python', 'max(a,b)', DummyEngine())
+ self.assertEqual(expr(DummyContext(a=1, b=2)), 2)
+
+ def test_allowed_module_name(self):
+ from zope.pagetemplate.engine import ZopePythonExpr
+ expr = ZopePythonExpr('python', '__import__("sys").__name__',
+ DummyEngine())
+ self.assertEqual(expr(DummyContext()), 'sys')
+
+ def test_forbidden_module_name(self):
+ from zope.pagetemplate.engine import ZopePythonExpr
+ from zope.security.interfaces import Forbidden
+ expr = ZopePythonExpr('python', '__import__("sys").exit',
+ DummyEngine())
+ self.assertRaises(Forbidden, expr, DummyContext())
+
+ def test_disallowed_builtin(self):
+ from zope.pagetemplate.engine import ZopePythonExpr
+ expr = ZopePythonExpr('python', 'open("x", "w")', DummyEngine())
+ self.assertRaises(NameError, expr, DummyContext())
+
+
+def test_suite():
+ from zope.testing.doctestunit import DocTestSuite
+ suite = unittest.TestSuite()
+ suite.addTest(DocTestSuite('zope.pagetemplate.engine'))
+ suite.addTest(unittest.makeSuite(EngineTests))
+ suite.addTest(unittest.makeSuite(ZopePythonExprTests))
+ return suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='test_suite')
Copied: zope.pagetemplate/trunk/src/zope/pagetemplate/tests/trusted.py (from rev 100358, zope.app.pagetemplate/trunk/src/zope/app/pagetemplate/tests/trusted.py)
===================================================================
--- zope.pagetemplate/trunk/src/zope/pagetemplate/tests/trusted.py (rev 0)
+++ zope.pagetemplate/trunk/src/zope/pagetemplate/tests/trusted.py 2009-05-25 18:57:21 UTC (rev 100365)
@@ -0,0 +1,22 @@
+##############################################################################
+#
+# Copyright (c) 2004 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""Sample of a module imported by a trusted module.
+
+This module won't be imported by an untrusted template using a
+path:modules/... expression.
+
+$Id$
+"""
+
+x = 42
More information about the Checkins
mailing list