[Epydoc-commits] SF.net SVN: epydoc: [1531] trunk/epydoc/src
Brought to you by:
edloper
|
From: <dva...@us...> - 2007-02-18 23:07:29
|
Revision: 1531
http://svn.sourceforge.net/epydoc/?rev=1531&view=rev
Author: dvarrazzo
Date: 2007-02-18 15:07:25 -0800 (Sun, 18 Feb 2007)
Log Message:
-----------
- Added interpreted text directives to refer to external API.
- Epydoc HTML writer generates an index file suitable to be referred to.
- Added a script wrapping a Docutils HTML writer with external API reference
support.
Modified Paths:
--------------
trunk/epydoc/src/epydoc/cli.py
trunk/epydoc/src/epydoc/docwriter/html.py
trunk/epydoc/src/epydoc/markup/restructuredtext.py
Added Paths:
-----------
trunk/epydoc/src/epydoc/docwriter/xlink.py
trunk/epydoc/src/scripts/apirst2html.py
Modified: trunk/epydoc/src/epydoc/cli.py
===================================================================
--- trunk/epydoc/src/epydoc/cli.py 2007-02-18 14:05:45 UTC (rev 1530)
+++ trunk/epydoc/src/epydoc/cli.py 2007-02-18 23:07:25 UTC (rev 1531)
@@ -76,6 +76,12 @@
import ConfigParser
from epydoc.docwriter.html_css import STYLESHEETS as CSS_STYLESHEETS
+# This module is only available if Docutils are in the system
+try:
+ from epydoc.docwriter import xlink
+except:
+ xlink = None
+
INHERITANCE_STYLES = ('grouped', 'listed', 'included')
GRAPH_TYPES = ('classtree', 'callgraph', 'umlclasstree')
ACTIONS = ('html', 'text', 'latex', 'dvi', 'ps', 'pdf', 'check')
@@ -129,7 +135,8 @@
debug=epydoc.DEBUG, profile=False, graphs=[],
list_classes_separately=False, graph_font=None, graph_font_size=None,
include_source_code=True, pstat_files=[], simple_term=False, fail_on=None,
- exclude=[], exclude_parse=[], exclude_introspect=[])
+ exclude=[], exclude_parse=[], exclude_introspect=[],
+ external_api=[],external_api_file=[],external_api_root=[])
def parse_arguments():
# Construct the option parser.
@@ -212,7 +219,7 @@
"list of available help topics")
- generation_group = OptionGroup(optparser, 'Generation options')
+ generation_group = OptionGroup(optparser, 'Generation Options')
optparser.add_option_group(generation_group)
generation_group.add_option("--docformat",
@@ -279,7 +286,7 @@
help=("Include a page with the process log (epydoc-log.html)"))
- output_group = OptionGroup(optparser, 'Output options')
+ output_group = OptionGroup(optparser, 'Output Options')
optparser.add_option_group(output_group)
output_group.add_option("--name",
@@ -325,7 +332,19 @@
"its own section, instead of listing them under their "
"containing module."))
- graph_group = OptionGroup(optparser, 'Graph options')
+ # The group of external API options.
+ # Skip if the module couldn't be imported (usually missing docutils)
+ if xlink is not None:
+ link_group = OptionGroup(optparser,
+ xlink.ApiLinkReader.settings_spec[0])
+ optparser.add_option_group(link_group)
+
+ for help, names, opts in xlink.ApiLinkReader.settings_spec[2]:
+ opts = opts.copy()
+ opts['help'] = help
+ link_group.add_option(*names, **opts)
+
+ graph_group = OptionGroup(optparser, 'Graph Options')
optparser.add_option_group(graph_group)
graph_group.add_option('--graph',
@@ -363,7 +382,7 @@
"will be written to profile.out."))
- return_group = OptionGroup(optparser, 'Return value options')
+ return_group = OptionGroup(optparser, 'Return Value Options')
optparser.add_option_group(return_group)
return_group.add_option("--fail-on-error",
@@ -547,6 +566,14 @@
elif optname in ('separate-classes', 'separate_classes'):
options.list_classes_separately = _str_to_bool(val, optname)
+ # External API
+ elif optname in ('external-api', 'external_api'):
+ options.external_api.extend(val.replace(',', ' ').split())
+ elif optname in ('external-api-file', 'external_api_file'):
+ options.external_api_file.append(val)
+ elif optname in ('external-api-root', 'external_api_root'):
+ options.external_api_root.append(val)
+
# Graph options
elif optname == 'graph':
graphtypes = val.replace(',', '').split()
@@ -662,6 +689,14 @@
from epydoc import docstringparser
docstringparser.DEFAULT_DOCFORMAT = options.docformat
+ # Configure the external API linking
+ if xlink is not None:
+ try:
+ xlink.ApiLinkReader.read_configuration(options, problematic=False)
+ except Exception, exc:
+ log.error("Error while configuring external API linking: %s: %s"
+ % (exc.__class__.__name__, exc))
+
# Set the dot path
if options.dotpath:
from epydoc.docwriter import dotgraph
Modified: trunk/epydoc/src/epydoc/docwriter/html.py
===================================================================
--- trunk/epydoc/src/epydoc/docwriter/html.py 2007-02-18 14:05:45 UTC (rev 1530)
+++ trunk/epydoc/src/epydoc/docwriter/html.py 2007-02-18 23:07:25 UTC (rev 1531)
@@ -430,7 +430,7 @@
if isinstance(doc, ModuleDoc) and is_src_filename(doc.filename):
self.modules_with_sourcecode.add(doc)
self._num_files = (len(self.class_list) + 2*len(self.module_list) +
- 12 + len(self.METADATA_INDICES))
+ 13 + len(self.METADATA_INDICES))
if self._incl_sourcecode:
self._num_files += len(self.modules_with_sourcecode)
if self._split_ident_index:
@@ -647,6 +647,9 @@
# Write the auto-redirect page.
self._write(self.write_redirect_page, directory, 'redirect.html')
+ # Write the mapping object name -> URL
+ self._write(self.write_api_list, directory, 'api-objects.txt')
+
# Write the index.html files.
# (this must be done last, since it might copy another file)
self._files_written += 1
@@ -2953,6 +2956,35 @@
# \------------------------------------------------------------/
#////////////////////////////////////////////////////////////
+ #{ URLs list
+ #////////////////////////////////////////////////////////////
+
+ def write_api_list(self, out):
+ """
+ Write a list of mapping name->url for all the documented objects.
+ """
+ # Construct a list of all the module & class pages that we're
+ # documenting. The redirect_url javascript will scan through
+ # this list, looking for a page name that matches the
+ # requested dotted name.
+ skip = (ModuleDoc, ClassDoc, type(UNKNOWN))
+ for val_doc in self.module_list:
+ self.write_url_record(out, val_doc)
+ for var in val_doc.variables.itervalues():
+ if not isinstance(var.value, skip):
+ self.write_url_record(out, var)
+
+ for val_doc in self.class_list:
+ self.write_url_record(out, val_doc)
+ for var in val_doc.variables.itervalues():
+ self.write_url_record(out, var)
+
+ def write_url_record(self, out, obj):
+ url = self.url(obj)
+ if url is not None:
+ out("%s\t%s\n" % (obj.canonical_name, url))
+
+ #////////////////////////////////////////////////////////////
#{ Helper functions
#////////////////////////////////////////////////////////////
Added: trunk/epydoc/src/epydoc/docwriter/xlink.py
===================================================================
--- trunk/epydoc/src/epydoc/docwriter/xlink.py (rev 0)
+++ trunk/epydoc/src/epydoc/docwriter/xlink.py 2007-02-18 23:07:25 UTC (rev 1531)
@@ -0,0 +1,459 @@
+"""
+A Docutils_ interpreted text role for cross-API reference support.
+
+This module allows a Docutils_ document to refer to elements defined in
+external API documentation. It is possible to refer to many external API
+from the same document.
+
+Each API documentation is assigned a new interpreted text role: using such
+interpreted text, an user can specify an object name inside an API
+documentation. The system will convert such text into an url and generate a
+reference to it. For example, if the API ``db`` is defined, being a database
+package, then a certain method may be referred as::
+
+ :db:`Connection.cursor()`
+
+To define a new API, an *index file* must be provided. This file contains
+a mapping from the object name to the URL part required to resolve such object.
+
+Index file
+----------
+
+Each line in the the index file describes an object.
+
+Each line contains the fully qualified name of the object and the URL at which
+the documentation is located. The fields are separated by a ``<tab>``
+character.
+
+The URL's in the file are relative from the documentation root: the system can
+be configured to add a prefix in front of each returned URL.
+
+Allowed names
+-------------
+
+When a name is used in an API text role, it is split over any *separator*.
+The separators defined are '``.``', '``::``', '``->``'. All the text from the
+first noise char (neither a separator nor alphanumeric or '``_``') is
+discarded. The same algorithm is applied when the index file is read.
+
+First the sequence of name parts is looked for in the provided index file.
+If no matching name is found, a partial match against the trailing part of the
+names in the index is performed. If no object is found, or if the trailing part
+of the name may refer to many objects, a warning is issued and no reference
+is created.
+
+Configuration
+-------------
+
+This module provides the class `ApiLinkReader` a replacement for the Docutils
+standalone reader. Such reader specifies the settings required for the
+API canonical roles configuration. The same command line options are exposed by
+Epydoc.
+
+The script ``apirst2html.py`` is a frontend for the `ApiLinkReader` reader.
+
+API Linking Options::
+
+ --external-api=NAME
+ Define a new API document. A new interpreted text
+ role NAME will be added.
+ --external-api-file=NAME:FILENAME
+ Use records in FILENAME to resolve objects in the API
+ named NAME.
+ --external-api-root=NAME:STRING
+ Use STRING as prefix for the URL generated from the
+ API NAME.
+
+.. _Docutils: http://docutils.sourceforge.net/
+"""
+
+# $Id$
+__version__ = "$Revision$"[11:-2]
+__author__ = "Daniele Varrazzo"
+__copyright__ = "Copyright (C) 2007 by Daniele Varrazzo"
+__docformat__ = 'reStructuredText en'
+
+import re
+import sys
+
+from docutils.parsers.rst import roles
+from docutils import nodes, utils
+
+class UrlGenerator:
+ """
+ Generate URL from an object name.
+ """
+ class IndexAmbiguous(IndexError):
+ """
+ The name looked for is ambiguous
+ """
+
+ def get_url(self, name):
+ """Look for a name and return the matching URL documentation.
+
+ First look for a fully qualified name. If not found, try with partial
+ name.
+
+ If no url exists for the given object, return `None`.
+
+ :Parameters:
+ `name` : `str`
+ the name to look for
+
+ :return: the URL that can be used to reach the `name` documentation.
+ `None` if no such URL exists.
+ :rtype: `str`
+
+ :Exceptions:
+ - `IndexError`: no object found with `name`
+ - `DocUrlGenerator.IndexAmbiguous` : more than one object found with
+ a non-fully qualified name; notice that this is an ``IndexError``
+ subclass
+ """
+ raise NotImplementedError
+
+ def get_canonical_name(self, name):
+ """
+ Convert an object name into a canonical name.
+
+ the canonical name of an object is a tuple of strings containing its
+ name fragments, splitted on any allowed separator ('``.``', '``::``',
+ '``->``').
+
+ Noise such parenthesis to indicate a function is discarded.
+
+ :Parameters:
+ `name` : `str`
+ an object name, such as ``os.path.prefix()`` or ``lib::foo::bar``
+
+ :return: the fully qualified name such ``('os', 'path', 'prefix')`` and
+ ``('lib', 'foo', 'bar')``
+ :rtype: `tuple` of `str`
+ """
+ rv = []
+ for m in self._SEP_RE.finditer(name):
+ groups = m.groups()
+ if groups[0] is not None:
+ rv.append(groups[0])
+ elif groups[2] is not None:
+ break
+
+ return tuple(rv)
+
+ _SEP_RE = re.compile(r"""(?x)
+ # Tokenize the input into keyword, separator, noise
+ ([a-zA-Z0-9_]+) | # A keyword is a alphanum word
+ ( \. | \:\: | \-\> ) | # These are the allowed separators
+ (.) # If it doesn't fit, it's noise.
+ # Matching a single noise char is enough, because it
+ # is used to break the tokenization as soon as some noise
+ # is found.
+ """)
+
+
+class VoidUrlGenerator(UrlGenerator):
+ """
+ Don't actually know any url, but don't report any error.
+
+ Useful if an index file is not available, but a document linking to it
+ is to be generated, and warnings are to be avoided.
+
+ Don't report any object as missing, Don't return any url anyway.
+ """
+ def get_url(self, name):
+ return None
+
+
+class DocUrlGenerator(UrlGenerator):
+ """
+ Read a *documentation index* and generate URL's for it.
+ """
+ def __init__(self):
+ self._exact_matches = {}
+ """
+ A map from an object fully qualified name to its URL.
+
+ Values are both the name as tuple of fragments and as read from the
+ records (see `load_records()`), mostly to help `_partial_names` to
+ perform lookup for unambiguous names.
+ """
+
+ self._partial_names= {}
+ """
+ A map from partial names to the fully qualified names they may refer.
+
+ The keys are the possible left sub-tuples of fully qualified names,
+ the values are list of strings as provided by the index.
+
+ If the list for a given tuple contains a single item, the partial
+ match is not ambuguous. In this case the string can be looked up in
+ `_exact_matches`.
+
+ If the name fragment is ambiguous, a warning may be issued to the user.
+ The items can be used to provide an informative message to the user,
+ to help him qualifying the name in a unambiguous manner.
+ """
+
+ self.prefix = ''
+ """
+ Prefix portion for the URL's returned by `get_url()`.
+ """
+
+ def get_url(self, name):
+ cname = self.get_canonical_name(name)
+ url = self._exact_matches.get(cname, None)
+ if url is None:
+
+ # go for a partial match
+ vals = self._partial_names.get(cname)
+ if len(vals) == 1:
+ url = self._exact_matches[vals[0]]
+
+ elif not vals:
+ raise IndexError(
+ "no object named '%s' found" % (name))
+
+ else:
+ raise self.IndexAmbiguous(
+ "found %d objects that '%s' may refer to: %s"
+ % (len(vals), name, ", ".join(["'%s'" % n for n in vals])))
+
+ return self.prefix + url
+
+ #{ Content loading
+ # ---------------
+
+ def clear(self):
+ """
+ Clear the current class content.
+ """
+ self._exact_matches.clear()
+ self._partial_names.clear()
+
+ def load_index(self, f):
+ """
+ Read the content of an index file.
+
+ Populate the internal maps with the file content using `load_records()`.
+
+ :Parameters:
+ f : `str` or file
+ a file name or file-like object fron which read the index.
+ """
+ if isinstance(f, basestring):
+ f = file(f)
+
+ self.load_records(self._iter_tuples(f))
+
+ def _iter_tuples(self, f):
+ """Iterate on a file returning 2-tuples."""
+ for row in f:
+ yield row.rstrip().split('\t', 1)
+
+ def load_records(self, records):
+ """
+ Read a sequence of pairs name -> url and populate the internal maps.
+
+ :Parameters:
+ records : iterable
+ the sequence of pairs (*name*, *url*) to add to the maps.
+ """
+ for name, url in records:
+ cname = self.get_canonical_name(name)
+ if not cname:
+ # Have to decide how to warn.
+ raise NotImplementedError("WARNING NAME NOT VALID")
+
+ self._exact_matches[name] = url
+ self._exact_matches[cname] = url
+
+ # Link the different ambiguous fragments to the url
+ for i in range(1, len(cname)):
+ self._partial_names.setdefault(cname[i:], []).append(name)
+
+#{ API register
+# ------------
+
+api_register = {}
+"""
+Mapping from the API name to the `UrlGenerator` to be used.
+"""
+
+def register_api(name, generator=None):
+ """Register the API `name` into the `api_register`.
+
+ A registered API is available to the markup as the interpreted text
+ role ``name``.
+
+ If a `generator` is not provided, register a `VoidUrlGenerator` instance:
+ in this case no warning will be issued for missing names, but no URL will
+ be generated and all the dotted names will simply be rendered as literals.
+
+ :Parameters:
+ `name` : `str`
+ the name of the generator to be registered
+ `generator` : `UrlGenerator`
+ the object to register to translate names into URLs.
+ """
+ if generator is None:
+ generator = VoidUrlGenerator()
+
+ api_register[name] = generator
+
+def set_api_file(name, file):
+ """Set an URL generator populated with data from `file`.
+
+ Use `file` to populate a new `DocUrlGenerator` instance and register it
+ as `name`.
+
+ :Parameters:
+ `name` : `str`
+ the name of the generator to be registered
+ `file` : `str` or file
+ the file to parse populate the URL generator
+ """
+ generator = DocUrlGenerator()
+ generator.load_index(file)
+ register_api(name, generator)
+
+def set_api_root(name, prefix):
+ """Set the root for the URLs returned by a registered URL generator.
+
+ :Parameters:
+ `name` : `str`
+ the name of the generator to be updated
+ `prefix` : `str`
+ the prefix for the generated URL's
+
+ :Exceptions:
+ - `IndexError`: `name` is not a registered generator
+ """
+ api_register[name].prefix = prefix
+
+def create_api_role(name, problematic):
+ """
+ Create and register a new role to create links for an API documentation.
+
+ Create a role called `name`, which will use the ``name`` registered
+ URL resolver to create a link for an object.
+ """
+ def resolve_api_name(n, rawtext, text, lineno, inliner,
+ options={}, content=[]):
+
+ # node in monotype font
+ text = utils.unescape(text)
+ node = nodes.literal(rawtext, text, **options)
+
+ # Get the resolver from the register and create an url from it.
+ try:
+ url = api_register[n].get_url(text)
+ except IndexError, exc:
+ msg = inliner.reporter.warning(str(exc), line=lineno)
+ if problematic:
+ prb = inliner.problematic(rawtext, text, msg)
+ return [prb], [msg]
+ else:
+ return [node], []
+
+ if url is not None:
+ node = nodes.reference(rawtext, '', node, refuri=url, **options)
+ return [node], []
+
+ roles.register_local_role(name, resolve_api_name)
+
+
+#{ Command line parsing
+# --------------------
+
+#from docutils import SettingsSpec
+from optparse import OptionValueError
+
+def split_name(value):
+ """
+ Split an option in form ``NAME:VALUE`` and check if ``NAME`` exists.
+ """
+ parts = value.split(':', 1)
+ if len(parts) != 2:
+ raise OptionValueError(
+ "option value must be specified as NAME:VALUE; got '%s' instead"
+ % value)
+
+ name, val = parts
+
+ if name not in api_register:
+ raise OptionValueError(
+ "the name '%s' has not been registered; use --external-api"
+ % name)
+
+ return (name, val)
+
+from docutils.readers.standalone import Reader
+
+class ApiLinkReader(Reader):
+ """
+ A Docutils standalone reader allowing external documentation links.
+
+ The reader configure the url resolvers at the time `read()` is invoked the
+ first time.
+ """
+ #: The option parser configuration.
+ settings_spec = (
+ 'API Linking Options',
+ None,
+ ((
+ 'Define a new API document. A new interpreted text role NAME will be '
+ 'added.',
+ ['--external-api'],
+ {'metavar': 'NAME', 'action': 'append'}
+ ), (
+ 'Use records in FILENAME to resolve objects in the API named NAME.',
+ ['--external-api-file'],
+ {'metavar': 'NAME:FILENAME', 'action': 'append'}
+ ), (
+ 'Use STRING as prefix for the URL generated from the API NAME.',
+ ['--external-api-root'],
+ {'metavar': 'NAME:STRING', 'action': 'append'}
+ ),)) + Reader.settings_spec
+
+ def read(self, source, parser, settings):
+ self.read_configuration(settings, problematic=True)
+ return Reader.read(self, source, parser, settings)
+
+ def read_configuration(self, settings, problematic=True):
+ """
+ Read the configuration for the configured URL resolver.
+
+ Register a new role for each configured API.
+
+ :Parameters:
+ `settings`
+ the settings structure containing the options to read.
+ `problematic` : `bool`
+ if True, the registered role will create problematic nodes in
+ case of failed references. If False, a warning will be raised
+ anyway, but the output will appear as an ordinary literal.
+ """
+ # Read config only once
+ if hasattr(self, '_conf'):
+ return
+ ApiLinkReader._conf = True
+
+ try:
+ if settings.external_api is not None:
+ for name in settings.external_api:
+ register_api(name)
+ create_api_role(name, problematic=problematic)
+
+ if settings.external_api_file is not None:
+ for name, file in map(split_name, settings.external_api_file):
+ set_api_file(name, file)
+
+ if settings.external_api_root is not None:
+ for name, root in map(split_name, settings.external_api_root):
+ set_api_root(name, root)
+
+ except OptionValueError, exc:
+ print >>sys.stderr, "%s: %s" % (exc.__class__.__name__, exc)
+ sys.exit(2)
+
+ read_configuration = classmethod(read_configuration)
Property changes on: trunk/epydoc/src/epydoc/docwriter/xlink.py
___________________________________________________________________
Name: svn:keywords
+ Id Revision
Name: svn:eol-style
+ native
Modified: trunk/epydoc/src/epydoc/markup/restructuredtext.py
===================================================================
--- trunk/epydoc/src/epydoc/markup/restructuredtext.py 2007-02-18 14:05:45 UTC (rev 1530)
+++ trunk/epydoc/src/epydoc/markup/restructuredtext.py 2007-02-18 23:07:25 UTC (rev 1531)
@@ -88,6 +88,7 @@
from epydoc.markup import *
from epydoc.apidoc import ModuleDoc, ClassDoc
from epydoc.docwriter.dotgraph import *
+from epydoc.docwriter.xlink import ApiLinkReader
from epydoc.markup.doctest import doctest_to_html, doctest_to_latex, \
HTMLDoctestColorizer
@@ -207,7 +208,7 @@
def __repr__(self): return '<ParsedRstDocstring: ...>'
-class _EpydocReader(StandaloneReader):
+class _EpydocReader(ApiLinkReader):
"""
A reader that captures all errors that are generated by parsing,
and appends them to a list.
@@ -220,18 +221,18 @@
version = [int(v) for v in docutils.__version__.split('.')]
version += [ 0 ] * (3 - len(version))
if version < [0,4,0]:
- default_transforms = list(StandaloneReader.default_transforms)
+ default_transforms = list(ApiLinkReader.default_transforms)
try: default_transforms.remove(docutils.transforms.frontmatter.DocInfo)
except ValueError: pass
else:
def get_transforms(self):
- return [t for t in StandaloneReader.get_transforms(self)
+ return [t for t in ApiLinkReader.get_transforms(self)
if t != docutils.transforms.frontmatter.DocInfo]
del version
def __init__(self, errors):
self._errors = errors
- StandaloneReader.__init__(self)
+ ApiLinkReader.__init__(self)
def new_document(self):
document = new_document(self.source.source_path, self.settings)
Added: trunk/epydoc/src/scripts/apirst2html.py
===================================================================
--- trunk/epydoc/src/scripts/apirst2html.py (rev 0)
+++ trunk/epydoc/src/scripts/apirst2html.py 2007-02-18 23:07:25 UTC (rev 1531)
@@ -0,0 +1,41 @@
+#!/usr/bin/env python
+# -*- coding: iso-8859-1 -*-
+
+"""An HTML writer supporting link to external documentation.
+
+This module is a frontend for the Docutils_ HTML writer. It allows a document
+to reference objects documented in the API documentation generated by
+extraction tools such as Doxygen_ or Epydoc_.
+
+.. _Docutils: http://docutils.sourceforge.net/
+.. _Doxygen: http://www.doxygen.org/
+.. _Epydoc: http://epydoc.sourceforge.net/
+"""
+
+# $Id$
+__version__ = "$Revision$"[11:-2]
+__author__ = "Daniele Varrazzo"
+__copyright__ = "Copyright (C) 2007 by Daniele Varrazzo"
+__docformat__ = 'reStructuredText en'
+
+try:
+ import locale
+ locale.setlocale(locale.LC_ALL, '')
+except:
+ pass
+
+# We have to do some path magic to prevent Python from getting
+# confused about the difference between the ``epydoc.py`` script, and the
+# real ``epydoc`` package. So remove ``sys.path[0]``, which contains the
+# directory of the script.
+import sys, os.path
+script_path = os.path.abspath(sys.path[0])
+sys.path = [p for p in sys.path if os.path.abspath(p) != script_path]
+
+import epydoc.docwriter.xlink as xlink
+
+from docutils.core import publish_cmdline, default_description
+description = ('Generates (X)HTML documents with API documentation links. '
+ + default_description)
+publish_cmdline(reader=xlink.ApiLinkReader(), writer_name='html',
+ description=description)
Property changes on: trunk/epydoc/src/scripts/apirst2html.py
___________________________________________________________________
Name: svn:executable
+ *
Name: svn:keywords
+ Id Revision
Name: svn:eol-style
+ native
This was sent by the SourceForge.net collaborative development platform, the world's largest Open Source development site.
|