import py |
import os |
import inspect |
from py.__.apigen.layout import LayoutPage |
from py.__.apigen.source import browser as source_browser |
from py.__.apigen.source import html as source_html |
from py.__.apigen.source import color as source_color |
from py.__.apigen.tracer.description import is_private |
from py.__.apigen.rest.genrest import split_of_last_part |
from py.__.apigen.linker import relpath |
from py.__.apigen.html import H |
|
reversed = py.builtin.reversed |
|
sorted = py.builtin.sorted |
html = py.xml.html |
raw = py.xml.raw |
|
REDUCE_CALLSITES = True |
|
def is_navigateable(name): |
return (not is_private(name) and name != '__doc__') |
|
def show_property(name): |
if not name.startswith('_'): |
return True |
if name.startswith('__') and name.endswith('__'): |
|
if (name not in dir(object) and |
name not in ['__doc__', '__dict__', '__name__', '__module__', |
'__weakref__', '__apigen_hide_from_nav__']): |
return True |
return False |
|
def deindent(str, linesep='\n'): |
""" de-indent string |
|
can be used to de-indent Python docstrings, it de-indents the first |
line to the side always, and determines the indentation of the rest |
of the text by taking that of the least indented (filled) line |
""" |
lines = str.strip().split(linesep) |
normalized = [] |
deindent = None |
normalized.append(lines[0].strip()) |
|
|
for line in lines[1:]: |
line = line.replace('\t', ' ' * 4) |
stripped = line.strip() |
if not stripped: |
normalized.append('') |
else: |
rstripped = line.rstrip() |
indent = len(rstripped) - len(stripped) |
if deindent is None or indent < deindent: |
deindent = indent |
normalized.append(line) |
ret = [normalized[0]] |
for line in normalized[1:]: |
if not line: |
ret.append(line) |
else: |
ret.append(line[deindent:]) |
return '%s\n' % (linesep.join(ret),) |
|
def get_linesep(s, default='\n'): |
""" return the line seperator of a string |
|
returns 'default' if no seperator can be found |
""" |
for sep in ('\r\n', '\r', '\n'): |
if sep in s: |
return sep |
return default |
|
def get_param_htmldesc(linker, func): |
""" get the html for the parameters of a function """ |
import inspect |
|
return inspect.formatargspec(*inspect.getargspec(func)) |
|
|
def source_dirs_files(fspath): |
""" returns a tuple (dirs, files) for fspath |
|
dirs are all the subdirs, files are the files which are interesting |
in building source documentation for a Python code tree (basically all |
normal files excluding .pyc and .pyo ones) |
|
all files and dirs that have a name starting with . are considered |
hidden |
""" |
dirs = [] |
files = [] |
for child in fspath.listdir(): |
if child.basename.startswith('.'): |
continue |
if child.check(dir=True): |
dirs.append(child) |
elif child.check(file=True): |
if child.ext in ['.pyc', '.pyo']: |
continue |
files.append(child) |
return sorted(dirs), sorted(files) |
|
def create_namespace_tree(dotted_names): |
""" creates a tree (in dict form) from a set of dotted names |
""" |
ret = {} |
for dn in dotted_names: |
path = dn.split('.') |
for i in xrange(len(path)): |
ns = '.'.join(path[:i]) |
itempath = '.'.join(path[:i + 1]) |
if ns not in ret: |
ret[ns] = [] |
if itempath not in ret[ns]: |
ret[ns].append(itempath) |
return ret |
|
def wrap_page(project, title, targetpath, contentel, navel, basepath, |
pageclass): |
page = pageclass(project, title, targetpath, nav=navel, encoding='UTF-8') |
page.set_content(contentel) |
page.setup_scripts_styles(basepath) |
return page |
|
def enumerate_and_color(codelines, firstlineno, enc): |
snippet = H.SourceBlock() |
tokenizer = source_color.Tokenizer(source_color.PythonSchema) |
for i, line in enumerate(codelines): |
try: |
snippet.add_line(i + firstlineno + 1, |
source_html.prepare_line([line], tokenizer, enc)) |
except py.error.ENOENT: |
|
snippet = org |
break |
return snippet |
|
_get_obj_cache = {} |
def get_obj(dsa, pkg, dotted_name): |
full_dotted_name = '%s.%s' % (pkg.__name__, dotted_name) |
if dotted_name == '': |
return pkg |
try: |
return _get_obj_cache[dotted_name] |
except KeyError: |
pass |
path = dotted_name.split('.') |
ret = pkg |
for item in path: |
marker = [] |
ret = getattr(ret, item, marker) |
if ret is marker: |
try: |
ret = dsa.get_obj(dotted_name) |
except KeyError: |
raise NameError('can not access %s in %s' % (item, |
full_dotted_name)) |
else: |
break |
_get_obj_cache[dotted_name] = ret |
return ret |
|
def get_rel_sourcepath(projpath, filename, default=None): |
relpath = py.path.local(filename).relto(projpath) |
if not relpath: |
return default |
return relpath |
|
def get_package_revision(packageroot, _revcache={}): |
try: |
rev = _revcache[packageroot] |
except KeyError: |
wc = py.path.svnwc(packageroot) |
rev = None |
if wc.check(versioned=True): |
rev = py.path.svnwc(packageroot).info().rev |
else: |
rev = 'unknown' |
_revcache[packageroot] = rev |
if packageroot.basename == "py": |
assert rev is not None |
return rev |
|
|
|
class AbstractPageBuilder(object): |
pageclass = LayoutPage |
|
def write_page(self, title, reltargetpath, tag, nav): |
targetpath = self.base.join(reltargetpath) |
relbase= relpath('%s%s' % (targetpath.dirpath(), targetpath.sep), |
self.base.strpath + '/') |
page = wrap_page(self.project, title, targetpath, tag, nav, self.base, |
self.pageclass) |
|
|
content = page.unicode() |
targetpath.ensure() |
targetpath.write(content.encode("utf8")) |
|
class SourcePageBuilder(AbstractPageBuilder): |
""" builds the html for a source docs page """ |
def __init__(self, base, linker, projroot, project, capture=None, |
pageclass=LayoutPage): |
self.base = base |
self.linker = linker |
self.projroot = projroot |
self.project = project |
self.capture = capture |
self.pageclass = pageclass |
|
def build_navigation(self, fspath): |
nav = H.Navigation(class_='sidebar') |
relpath = fspath.relto(self.projroot) |
path = relpath.split(os.path.sep) |
indent = 0 |
|
if relpath != '': |
for i in xrange(len(path)): |
dirpath = os.path.sep.join(path[:i]) |
abspath = self.projroot.join(dirpath).strpath |
if i == 0: |
text = self.projroot.basename |
else: |
text = path[i-1] |
nav.append(H.NavigationItem(self.linker, abspath, text, |
indent, False)) |
indent += 1 |
|
if fspath.check(dir=True): |
|
dirpath = fspath |
nav.append(H.NavigationItem(self.linker, dirpath.strpath, |
dirpath.basename, indent, True)) |
indent += 1 |
elif fspath.strpath == self.projroot.strpath: |
dirpath = fspath |
else: |
|
dirpath = fspath.dirpath() |
diritems, fileitems = source_dirs_files(dirpath) |
for dir in diritems: |
nav.append(H.NavigationItem(self.linker, dir.strpath, dir.basename, |
indent, False)) |
for file in fileitems: |
selected = (fspath.check(file=True) and |
file.basename == fspath.basename) |
nav.append(H.NavigationItem(self.linker, file.strpath, |
file.basename, indent, selected)) |
return nav |
|
re = py.std.re |
_reg_body = re.compile(r'<body[^>]*>(.*)</body>', re.S) |
def build_python_page(self, fspath): |
|
|
enc = source_html.get_module_encoding(fspath.strpath) |
source = fspath.read() |
sep = get_linesep(source) |
colored = [enumerate_and_color(source.split(sep), 0, enc)] |
tag = H.PythonSource(colored) |
nav = self.build_navigation(fspath) |
return tag, nav |
|
def build_dir_page(self, fspath): |
dirs, files = source_dirs_files(fspath) |
dirs = [(p.basename, self.linker.get_lazyhref(str(p))) for p in dirs] |
files = [(p.basename, self.linker.get_lazyhref(str(p))) for p in files] |
tag = H.DirList(dirs, files) |
nav = self.build_navigation(fspath) |
return tag, nav |
|
def build_nonpython_page(self, fspath): |
try: |
tag = H.NonPythonSource(unicode(fspath.read(), 'utf-8')) |
except UnicodeError: |
tag = H.NonPythonSource('no source available (binary file?)') |
nav = self.build_navigation(fspath) |
return tag, nav |
|
def build_pages(self, base): |
for fspath in [base] + list(base.visit()): |
if fspath.ext in ['.pyc', '.pyo']: |
continue |
if self.capture: |
self.capture.err.writeorg('.') |
relfspath = fspath.relto(base) |
if relfspath.find('%s.' % (os.path.sep,)) > -1: |
|
continue |
elif fspath.check(dir=True): |
if relfspath != '': |
relfspath += os.path.sep |
reloutputpath = 'source%s%sindex.html' % (os.path.sep, |
relfspath) |
else: |
reloutputpath = "source%s%s.html" % (os.path.sep, relfspath) |
reloutputpath = reloutputpath.replace(os.path.sep, '/') |
outputpath = self.base.join(reloutputpath) |
self.linker.set_link(str(fspath), reloutputpath) |
self.build_page(fspath, outputpath, base) |
|
def build_page(self, fspath, outputpath, base): |
""" build syntax-colored source views """ |
if fspath.check(ext='.py'): |
try: |
tag, nav = self.build_python_page(fspath) |
except (KeyboardInterrupt, SystemError): |
raise |
except: |
raise |
exc, e, tb = py.std.sys.exc_info() |
print '%s - %s' % (exc, e) |
print |
print ''.join(py.std.traceback.format_tb(tb)) |
print '-' * 79 |
del tb |
tag, nav = self.build_nonpython_page(fspath) |
elif fspath.check(dir=True): |
tag, nav = self.build_dir_page(fspath) |
else: |
tag, nav = self.build_nonpython_page(fspath) |
title = 'sources for %s' % (fspath.basename,) |
rev = self.get_revision(fspath) |
if rev: |
title += ' [rev. %s]' % (rev,) |
reltargetpath = outputpath.relto(self.base).replace(os.path.sep, |
'/') |
self.write_page(title, reltargetpath, tag, nav) |
|
_revcache = {} |
def get_revision(self, path): |
return get_package_revision(self.projroot) |
strpath = path.strpath |
if strpath in self._revcache: |
return self._revcache[strpath] |
wc = py.path.svnwc(path) |
if wc.check(versioned=True): |
rev = wc.info().created_rev |
else: |
rev = None |
self._revcache[strpath] = rev |
return rev |
|
class ApiPageBuilder(AbstractPageBuilder): |
""" builds the html for an api docs page """ |
def __init__(self, base, linker, dsa, projroot, namespace_tree, project, |
capture=None, pageclass=LayoutPage): |
self.base = base |
self.linker = linker |
self.dsa = dsa |
self.projroot = projroot |
self.projpath = py.path.local(projroot) |
self.namespace_tree = namespace_tree |
self.project = project |
self.capture = capture |
self.pageclass = pageclass |
|
pkgname = self.dsa.get_module_name().split('/')[-1] |
self.pkg = __import__(pkgname) |
|
def build_callable_view(self, dotted_name): |
""" build the html for a class method """ |
|
func = get_obj(self.dsa, self.pkg, dotted_name) |
docstring = func.__doc__ |
if docstring: |
docstring = deindent(docstring) |
localname = func.__name__ |
argdesc = get_param_htmldesc(self.linker, func) |
valuedesc = self.build_callable_signature_description(dotted_name) |
|
sourcefile = inspect.getsourcefile(func) |
callable_source = self.dsa.get_function_source(dotted_name) |
|
is_in_pkg = self.is_in_pkg(sourcefile) |
href = None |
text = 'could not get to source file' |
colored = [] |
if sourcefile and callable_source: |
enc = source_html.get_module_encoding(sourcefile) |
tokenizer = source_color.Tokenizer(source_color.PythonSchema) |
firstlineno = func.func_code.co_firstlineno |
sep = get_linesep(callable_source) |
org = callable_source.split(sep) |
colored = [enumerate_and_color(org, firstlineno, enc)] |
relpath = get_rel_sourcepath(self.projroot, sourcefile, sourcefile) |
text = 'source: %s' % (relpath,) |
if is_in_pkg: |
href = self.linker.get_lazyhref(sourcefile) |
|
csource = H.SourceSnippet(text, href, colored) |
cslinks = self.build_callsites(dotted_name) |
snippet = H.FunctionDescription(localname, argdesc, docstring, |
valuedesc, csource, cslinks) |
return snippet |
|
def build_class_view(self, dotted_name): |
""" build the html for a class """ |
cls = get_obj(self.dsa, self.pkg, dotted_name) |
|
try: |
sourcefile = inspect.getsourcefile(cls) |
except TypeError: |
sourcefile = None |
|
docstring = cls.__doc__ |
if docstring: |
docstring = deindent(docstring) |
if not hasattr(cls, '__name__'): |
clsname = 'instance of %s' % (cls.__class__.__name__,) |
else: |
clsname = cls.__name__ |
bases = self.build_bases(dotted_name) |
properties = self.build_properties(cls) |
methods = self.build_methods(dotted_name) |
|
if sourcefile is None: |
sourcelink = H.div('no source available') |
else: |
if sourcefile[-1] in ['o', 'c']: |
sourcefile = sourcefile[:-1] |
sourcelink = H.div(H.a('view source', |
href=self.linker.get_lazyhref(sourcefile))) |
|
snippet = H.ClassDescription( |
|
H.ClassDef(clsname, bases, docstring, sourcelink, |
properties, methods), |
) |
|
return snippet |
|
def build_bases(self, dotted_name): |
ret = [] |
bases = self.dsa.get_possible_base_classes(dotted_name) |
for base in bases: |
try: |
obj = self.dsa.get_obj(base.name) |
except KeyError: |
ret.append((base.name, None)) |
else: |
href = self.linker.get_lazyhref(base.name) |
ret.append((base.name, href)) |
return ret |
|
def build_properties(self, cls): |
properties = [] |
for attr in dir(cls): |
val = getattr(cls, attr) |
if show_property(attr) and not callable(val): |
if isinstance(val, property): |
val = '<property object (dynamically calculated value)>' |
properties.append((attr, val)) |
properties.sort(lambda x,y : cmp(x[0], y[0])) |
return properties |
|
def build_methods(self, dotted_name): |
ret = [] |
methods = self.dsa.get_class_methods(dotted_name) |
|
methods = ([m for m in methods if not m.startswith('_')] + |
[m for m in methods if m.startswith('_')]) |
|
if '__init__' in methods: |
methods.remove('__init__') |
methods.insert(0, '__init__') |
for method in methods: |
ret += self.build_callable_view('%s.%s' % (dotted_name, |
method)) |
return ret |
|
def build_namespace_view(self, namespace_dotted_name, item_dotted_names): |
""" build the html for a namespace (module) """ |
obj = get_obj(self.dsa, self.pkg, namespace_dotted_name) |
docstring = obj.__doc__ |
snippet = H.NamespaceDescription( |
H.NamespaceDef(namespace_dotted_name), |
H.Docstring(docstring or '*no docstring available*') |
) |
for dotted_name in sorted(item_dotted_names): |
itemname = dotted_name.split('.')[-1] |
if (not is_navigateable(itemname) or |
self.is_hidden_from_nav(dotted_name)): |
continue |
snippet.append( |
H.NamespaceItem( |
H.a(itemname, |
href=self.linker.get_lazyhref(dotted_name) |
) |
) |
) |
return snippet |
|
def build_class_pages(self, classes_dotted_names): |
passed = [] |
for dotted_name in sorted(classes_dotted_names): |
if self.capture: |
self.capture.err.writeorg('.') |
parent_dotted_name, _ = split_of_last_part(dotted_name) |
try: |
sibling_dotted_names = self.namespace_tree[parent_dotted_name] |
except KeyError: |
|
sibling_dotted_names = [] |
tag = H.Content(self.build_class_view(dotted_name)) |
nav = self.build_navigation(dotted_name, False) |
reltargetpath = "api/%s.html" % (dotted_name,) |
self.linker.set_link(dotted_name, reltargetpath) |
title = '%s API' % (dotted_name,) |
rev = self.get_revision(dotted_name) |
if rev: |
title += ' [rev. %s]' % (rev,) |
self.write_page(title, reltargetpath, tag, nav) |
return passed |
|
def build_function_pages(self, method_dotted_names): |
passed = [] |
for dotted_name in sorted(method_dotted_names): |
if self.capture: |
self.capture.err.writeorg('.') |
|
parent_dotted_name, _ = split_of_last_part(dotted_name) |
sibling_dotted_names = self.namespace_tree[parent_dotted_name] |
tag = H.Content(self.build_callable_view(dotted_name)) |
nav = self.build_navigation(dotted_name, False) |
reltargetpath = "api/%s.html" % (dotted_name,) |
self.linker.set_link(dotted_name, reltargetpath) |
title = '%s API' % (dotted_name,) |
rev = self.get_revision(dotted_name) |
if rev: |
title += ' [rev. %s]' % (rev,) |
self.write_page(title, reltargetpath, tag, nav) |
return passed |
|
def build_namespace_pages(self): |
passed = [] |
module_name = self.dsa.get_module_name().split('/')[-1] |
|
names = self.namespace_tree.keys() |
names.sort() |
function_names = self.dsa.get_function_names() |
class_names = self.dsa.get_class_names() |
for dotted_name in sorted(names): |
if self.capture: |
self.capture.err.writeorg('.') |
if dotted_name in function_names or dotted_name in class_names: |
continue |
subitem_dotted_names = self.namespace_tree[dotted_name] |
tag = H.Content(self.build_namespace_view(dotted_name, |
subitem_dotted_names)) |
nav = self.build_navigation(dotted_name, True) |
if dotted_name == '': |
reltargetpath = 'api/index.html' |
else: |
reltargetpath = 'api/%s.html' % (dotted_name,) |
self.linker.set_link(dotted_name, reltargetpath) |
title_name = dotted_name |
if dotted_name == '': |
title_name = self.dsa.get_module_name() |
title = 'index of %s' % (title_name,) |
rev = self.get_revision(dotted_name) |
if rev: |
title += ' [rev. %s]' % (rev,) |
self.write_page(title, reltargetpath, tag, nav) |
return passed |
|
def build_navigation(self, dotted_name, build_children=True): |
navitems = [] |
|
|
module_name = self.dsa.get_module_name().split('/')[-1] |
navitems.append(H.NavigationItem(self.linker, '', module_name, 0, |
True)) |
def build_nav_level(dotted_name, depth=1): |
navitems = [] |
path = dotted_name.split('.')[:depth] |
siblings = self.namespace_tree.get('.'.join(path[:-1])) |
for dn in sorted(siblings): |
selected = dn == '.'.join(path) |
sibpath = dn.split('.') |
sibname = sibpath[-1] |
if not is_navigateable(sibname): |
continue |
if self.is_hidden_from_nav(dn): |
continue |
navitems.append(H.NavigationItem(self.linker, dn, sibname, |
depth, selected)) |
if selected: |
lastlevel = dn.count('.') == dotted_name.count('.') |
if not lastlevel: |
navitems += build_nav_level(dotted_name, depth+1) |
elif lastlevel and build_children: |
|
navitems += build_nav_level('%s.' % (dotted_name,), |
depth+1) |
|
return navitems |
|
navitems += build_nav_level(dotted_name) |
return H.Navigation(class_='sidebar', *navitems) |
|
def build_callable_signature_description(self, dotted_name): |
args, retval = self.dsa.get_function_signature(dotted_name) |
valuedesc = H.ValueDescList() |
for name, _type in args: |
valuedesc.append(self.build_sig_value_description(name, _type)) |
if retval: |
retval = self.process_type_link(retval) |
ret = H.div(H.div('arguments:'), valuedesc, H.div('return value:'), |
retval or 'None') |
return ret |
|
def build_sig_value_description(self, name, _type): |
l = self.process_type_link(_type) |
items = [] |
next = "%s: " % name |
for item in l: |
if isinstance(item, str): |
next += item |
else: |
if next: |
items.append(next) |
next = "" |
items.append(item) |
if next: |
items.append(next) |
return H.ValueDescItem(*items) |
|
def process_type_link(self, _type): |
|
lst = [] |
data = self.dsa.get_type_desc(_type) |
if not data: |
for i in _type.striter(): |
if isinstance(i, str): |
lst.append(i) |
else: |
lst += self.process_type_link(i) |
return lst |
name, _desc_type, is_degenerated = data |
if not is_degenerated: |
linktarget = self.linker.get_lazyhref(name) |
lst.append(H.a(str(_type), href=linktarget)) |
else: |
raise IOError('do not think we ever get here?') |
|
lst.append(name) |
return lst |
|
def is_in_pkg(self, sourcefile): |
return py.path.local(sourcefile).relto(self.projpath) |
|
_processed_callsites = {} |
def build_callsites(self, dotted_name): |
callstack = self.dsa.get_function_callpoints(dotted_name) |
cslinks = [] |
for i, (cs, _) in enumerate(callstack): |
if REDUCE_CALLSITES: |
key = (cs[0].filename, cs[0].lineno) |
if key in self._processed_callsites: |
|
|
continue |
self._processed_callsites[key] = 1 |
link = self.build_callsite(dotted_name, cs, i) |
cslinks.append(link) |
return cslinks |
|
def build_callsite(self, dotted_name, call_site, index): |
tbtag = H.Content(self.gen_traceback(dotted_name, reversed(call_site))) |
parent_dotted_name, _ = split_of_last_part(dotted_name) |
nav = self.build_navigation(parent_dotted_name, False) |
id = 'callsite_%s_%s' % (dotted_name, index) |
reltargetpath = "api/%s.html" % (id,) |
self.linker.set_link(id, reltargetpath) |
href = self.linker.get_lazyhref(id) |
self.write_page('call site %s for %s' % (index, dotted_name), |
reltargetpath, tbtag, nav) |
sourcefile = call_site[0].filename |
sourcepath = get_rel_sourcepath(self.projpath, sourcefile, sourcefile) |
return H.CallStackLink(sourcepath, call_site[0].lineno + 1, href) |
|
_reg_source = py.std.re.compile(r'([^>]*)<(.*)>') |
def gen_traceback(self, dotted_name, call_site): |
tbtag = H.CallStackDescription() |
for frame in call_site: |
lineno = frame.lineno - frame.firstlineno |
source = frame.source |
sourcefile = frame.filename |
|
tokenizer = source_color.Tokenizer(source_color.PythonSchema) |
mangled = [] |
|
source = str(source) |
sep = get_linesep(source) |
for i, sline in enumerate(source.split(sep)): |
if i == lineno: |
l = '-> %s' % (sline,) |
else: |
l = ' %s' % (sline,) |
mangled.append(l) |
if sourcefile: |
relpath = get_rel_sourcepath(self.projpath, sourcefile, |
sourcefile) |
linktext = '%s - line %s' % (relpath, frame.lineno + 1) |
|
|
is_code_source = self._reg_source.match(sourcefile) |
if (not is_code_source and self.is_in_pkg(sourcefile) and |
py.path.local(sourcefile).check()): |
enc = source_html.get_module_encoding(sourcefile) |
href = self.linker.get_lazyhref(sourcefile) |
sourcelink = H.a(linktext, href=href) |
else: |
enc = 'latin-1' |
sourcelink = H.div(linktext) |
colored = [enumerate_and_color(mangled, |
frame.firstlineno, enc)] |
else: |
sourcelink = H.div('source unknown (%s)' % (sourcefile,)) |
colored = mangled[:] |
tbtag.append(sourcelink) |
tbtag.append(H.div(*colored)) |
return tbtag |
|
def is_hidden_from_nav(self, dotted_name): |
obj = get_obj(self.dsa, self.pkg, dotted_name) |
return getattr(obj, '__apigen_hide_from_nav__', False) |
|
_revcache = {} |
def get_proj_revision(self): |
if '' in self._revcache: |
return self._revcache[''] |
wc = py.path.svnwc(self.projpath) |
if wc.check(versioned=True): |
rev = wc.info().created_rev |
else: |
rev = None |
self._revcache[''] = rev |
return rev |
|
def get_revision(self, dotted_name): |
return get_package_revision(self.projroot) |
if dotted_name in self._revcache: |
return self._revcache[dotted_name] |
obj = get_obj(self.dsa, self.pkg, dotted_name) |
rev = None |
try: |
sourcefile = inspect.getsourcefile(obj) |
except TypeError: |
pass |
else: |
if sourcefile is not None: |
if sourcefile[-1] in ['o', 'c']: |
sourcefile = sourcefile[:-1] |
wc = py.path.svnwc(sourcefile) |
if wc.check(versioned=True): |
rev = wc.info().created_rev |
rev = rev or self.get_proj_revision() |
self._revcache[dotted_name] = rev |
return rev |
|
|