source: tagsplugin/tags/0.3rc2/tractags/api.py

Last change on this file was 1794, checked in by Alec Thomas, 17 years ago
  • Put one instance of sorted() in tractags.api. Also use the set from there as well.
  • Macro documentation is now fully Wiki formatted.
  • Default arguments can be provided for pretty much all aspects of the plugin. See here for docs.
  • Deprecated old config entries, use new <element>.args form.
File size: 15.3 KB
Line 
1"""
2
3Implementation of a generic tagging API for Trac. The API lets plugins register
4use of a set of namespaces (tagspaces) then access and manipulate the tags in
5that tagspace.
6
7For integration of external programs, the API also allows other tag systems to
8be accessed transparently (see the ITaggingSystemProvider interface and the
9corresponding TaggingSystem class).
10
11Taggable names are contained in a tagspace and can be associated with any
12number of tags. eg. ('wiki', 'WikiStart', 'start') represents a 'start' tag to
13the 'WikiStart' page in the 'wiki' tagspace.
14
15For a component to register a new tagspace for use it must implement the
16ITagSpaceUser interface.
17
18To access tags for a tagspace use the following mechanism (using the 'wiki'
19tagspace in this example):
20
21{{{
22#!python
23from tractags.api import TagEngine
24
25tags = TagEngine(env).tagspace.wiki
26# Display all names and the tags associated with each name
27for name in tags.get_tagged_names():
28    print name, list(tags.get_name_tags(name))
29# Display all tags and the names associated with each tag
30for tag in tags.get_tags():
31    print tag, list(tags.get_tagged_names(tag))
32# Add a start tag to WikiStart
33tags.add_tags(None, 'WikiStart', ['start'])
34}}}
35
36"""
37
38from trac.core import *
39from trac.env import IEnvironmentSetupParticipant
40from trac.db import Table, Column, Index
41import sys
42import re
43
44try:
45    set = set
46except:
47    from sets import Set as set
48
49try:
50    sorted = sorted
51except NameError:
52    def sorted(iterable, key=None):
53        lst = key and [(key(i), i) for i in iterable] or list(iterable)
54        lst.sort()
55        return [i for __, i in lst]
56
57class ITagSpaceUser(Interface):
58    """ Register that this component uses a set of tagspaces. If a tagspace is
59        not registered, it can not be used. """
60    def tagspaces_used():
61        """ Return an iterator of tagspaces used by this plugin. """
62
63class ITaggingSystemProvider(Interface):
64    """ An implementation of a tag system. This allows other non-Trac-native
65        tag systems to be accessed through one API. """
66
67    def get_tagspaces_provided():
68        """ Iterable of tagspaces provided by this tag system. """
69
70    def get_tagging_system(tagspace):
71        """ Return the TaggingSystem responsible for tagspace. """
72
73class TaggingSystem(object):
74    """ An implementation of a tagging system. """
75    def __init__(self, env, tagspace):
76        self.env = env
77        self.tagspace = tagspace
78
79    def _apply_tagged_names_operation(self, names, tags, operation):
80        """ Given a dictionary of tag:names, return the appropriate set of
81            names with the operation applied. """
82
83    def walk_tagged_names(self, names, tags, predicate):
84        """ Generator returning a tuple of (name, tags) for each tagged name
85            in this tagspace that meets the predicate and is in the set
86            of names and has any of the given tags.
87
88            (The names and tags arguments are purely an optimisation
89            opportunity for the underlying TaggingSystem)
90            """
91
92    def get_name_tags(self, name):
93        """ Get tags for a name. """
94        raise NotImplementedError
95
96    def add_tags(self, req, name, tags):
97        """ Tag name in tagspace with tags. """
98        raise NotImplementedError
99
100    def replace_tags(self, req, name, tags):
101        """ Replace existing tags on name with tags. """
102        self.remove_all_tags(req, name)
103        self.add_tags(req, name, tags)
104
105    def remove_tags(self, req, name, tags):
106        """ Remove tags from a name in a tagspace. """
107        raise NotImplementedError
108
109    def remove_all_tags(self, req, name):
110        """ Remove all tags from a name in a tagspace. """
111        self.remove_tags(req, name, self.get_name_tags(name))
112
113    def name_details(self, name):
114        """ Return a tuple of (href, htmllink, title). eg.
115            ("/ticket/1", "<a href="/ticket/1">#1</a>", "Broken links") """
116        raise NotImplementedError
117
118class DefaultTaggingSystem(TaggingSystem):
119    """ Default tagging system. Handles any number of namespaces registered via
120        ITagSpaceUser. """
121
122    def walk_tagged_names(self, names, tags, predicate):
123        db = self.env.get_db_cnx()
124        cursor = db.cursor()
125
126        args = [self.tagspace]
127        sql = 'SELECT DISTINCT name, tag FROM tags WHERE tagspace=%s'
128        if names:
129            sql += ' AND name IN (' + ', '.join(['%s' for n in names]) + ')'
130            args += names
131        if tags:
132            sql += ' AND name in (SELECT name FROM tags WHERE tag in (' + ', '.join(['%s' for t in tags]) + '))'
133            args += tags
134        sql += " ORDER BY name"
135        cursor.execute(sql, args)
136
137        tags = set(tags)
138        current_name = None
139        name_tags = set()
140        for name, tag in cursor:
141            if current_name != name:
142                if current_name is not None:
143                    if predicate(current_name, name_tags):
144                        yield (current_name, name_tags)
145                name_tags = set([tag])
146                current_name = name
147            else:
148                name_tags.add(tag)
149        if current_name is not None and predicate(current_name, name_tags):
150            yield (current_name, name_tags)
151
152    def get_name_tags(self, name):
153        db = self.env.get_db_cnx()
154        cursor = db.cursor()
155        cursor.execute('SELECT tag FROM tags WHERE tagspace=%s AND name=%s', (self.tagspace, name))
156        return set([row[0] for row in cursor])
157
158    def add_tags(self, req, name, tags):
159        db = self.env.get_db_cnx()
160        cursor = db.cursor()
161        for tag in tags:
162            cursor.execute('INSERT INTO tags (tagspace, name, tag) VALUES (%s, %s, %s)', (self.tagspace, name, tag))
163        db.commit()
164
165    def remove_tags(self, req, name, tags):
166        db = self.env.get_db_cnx()
167        cursor = db.cursor()
168        sql = "DELETE FROM tags WHERE tagspace = %s AND name = %s AND tag " \
169              "IN (" + ', '.join(["%s" for t in tags]) + ")"
170        cursor.execute(sql, (self.tagspace, name) + tuple(tags))
171        db.commit()
172
173    def remove_all_tags(self, req, name):
174        db = self.env.get_db_cnx()
175        cursor = db.cursor()
176        cursor.execute('DELETE FROM tags WHERE tagspace=%s AND name=%s', (self.tagspace, name))
177        db.commit()
178       
179    def name_details(self, name):
180        from trac.wiki.formatter import wiki_to_oneliner
181        return (getattr(self.env.href, self.tagspace),
182                wiki_to_oneliner('[%s:%s %s]' % (self.tagspace, name, name),
183                                  self.env), '')
184
185class TagspaceProxy:
186    """ A convenience for performing operations on a specific tagspace,
187        including get_tags() and get_tagged_names(). Both of these functions
188        will only search that tagspace, and will return values stripped of
189        tagspace information. """
190    def __init__(self, engine, tagspace):
191        self.engine = engine
192        self.tagspace = tagspace
193        self.tagsystem = engine._get_tagsystem(tagspace)
194
195    def get_tags(self, *args, **kwargs):
196        result = self.engine.get_tags(tagspaces=[self.tagspace], *args, **kwargs)
197        if isinstance(result, set):
198            return result
199        else:
200            out = {}
201            for tag, names in result.iteritems():
202                out[tag] = set([name for _, name in names])
203            return out
204
205    def get_tagged_names(self, *args, **kwargs):
206        return self.engine.get_tagged_names(tagspaces=[self.tagspace], *args, **kwargs)[self.tagspace]
207
208    def __getattr__(self, attr):
209        return getattr(self.tagsystem, attr)
210
211class TagspaceDirector(object):
212    """ A convenience similar to env.href, proxying to the correct TagSystem by
213        attribute. """
214    def __init__(self, engine):
215        self.engine = engine
216
217    def __getattr__(self, tagspace):
218        return self.tagspace(tagspace)
219
220    def tagspace(self, tagspace):
221        return TagspaceProxy(self.engine, tagspace)
222
223class TagEngine(Component):
224    """ The core of the Trac tag API. This interface can be used to register
225        tagspaces (ITagSpaceUser or register_tagspace()), add other tagging
226        systems (ITaggingSystemProvider), and to control tags in a tagspace.
227    """
228
229    _tagspace_re = re.compile(r'''^[a-zA-Z_][a-zA-Z0-9_]*$''')
230
231    implements(ITaggingSystemProvider, IEnvironmentSetupParticipant)
232
233    tag_users = ExtensionPoint(ITagSpaceUser)
234    tagging_systems = ExtensionPoint(ITaggingSystemProvider)
235
236    SCHEMA = [
237        Table('tags', key = ('tagspace', 'name', 'tag'))[
238              Column('tagspace'),
239              Column('name'),
240              Column('tag'),
241              Index(['tagspace', 'name']),
242              Index(['tagspace', 'tag']),]
243        ]
244
245    def __init__(self):
246        self.tagspace = TagspaceDirector(self)
247        self._tagsystem_cache = {}
248
249    def _get_tagspaces(self):
250        """ Get iterable of available tagspaces. """
251        out = []
252        for tagsystem in self.tagging_systems:
253            for tagspace in tagsystem.get_tagspaces_provided():
254                out.append(tagspace)
255        return out
256    tagspaces = property(_get_tagspaces)
257
258    def _get_tagsystem(self, tagspace):
259        """ Returns a TaggingSystem proxy object with tagspace as the default
260            tagspace. """
261        try:
262            return self._tagsystem_cache[tagspace]
263        except KeyError:
264            for tagsystem in self.tagging_systems:
265                if tagspace in tagsystem.get_tagspaces_provided():
266                   self._tagsystem_cache[tagspace] = tagsystem.get_tagging_system(tagspace)
267                   return self._tagsystem_cache[tagspace]
268        raise TracError("No such tagspace '%s'" % tagspace)
269
270    # Public methods
271    def walk_tagged_names(self, names=[], tags=[], tagspaces=[], predicate=lambda tagspace, name, tags: True):
272        """ Generator returning (tagspace, name, tags) for all names in the
273            given tagspaces. Objects must have at least one of tags, be in
274            names and must meet the predicate. """
275        tagspaces = tagspaces or self.tagspaces
276        for tagspace in tagspaces:
277            tagsystem = self._get_tagsystem(tagspace)
278            for name, name_tags in tagsystem.walk_tagged_names(names=names, tags=tags, predicate=lambda n, t: predicate(tagspace, n, t)):
279                yield (tagspace, name, name_tags)
280
281    def get_tags(self, names=[], tagspaces=[], operation='union', detailed=False):
282        """ Get tags with the given names from the given tagspaces.
283            'operation' is the union or intersection of all tags on
284            names. If detailed, return a set of
285            {tag:set([(tagspace, name), ...])}, otherwise return a set of
286            tags. """
287        assert type(names) in (list, tuple, set)
288        tagspaces = tagspaces or self.tagspaces
289        seed_set = True
290        all_tags = set()
291        tagged_names = {}
292        for tagspace, name, tags in self.walk_tagged_names(names=names, tagspaces=tagspaces):
293            for tag in tags:
294                tagged_names.setdefault(tag, set()).add((tagspace, name))
295            if operation == 'intersection':
296                if seed_set:
297                    seed_set = False
298                    all_tags.update(tags)
299                else:
300                    all_tags.intersection_update(tags)
301                    if not all_tags:
302                        return detailed and {} or set()
303            else:
304                all_tags.update(tags)
305        if detailed:
306            out_tags = {}
307            for tag in all_tags:
308                out_tags[tag] = tagged_names[tag]
309            return out_tags
310        else:
311            return all_tags
312
313    def get_tagged_names(self, tags=[], tagspaces=[], operation='intersection', detailed=False):
314        """ Get names with the given tags from tagspaces. 'operation' is the set
315            operatin to perform on the sets of names tagged with each of the
316            search tags, and can be either 'intersection' or 'union'.
317
318            If detailed=True return a dictionary of
319            {tagspace:{name:set([tag, ...])}} otherwise return a dictionary of
320            {tagspace:set([name, ...])}. """
321        assert type(tags) in (list, tuple, set)
322        tagspaces = tagspaces or self.tagspaces
323        tags = set(tags)
324        if detailed:
325            output = dict([(ts, {}) for ts in tagspaces])
326        else:
327            output = dict([(ts, set()) for ts in tagspaces])
328        for tagspace, name, name_tags in self.walk_tagged_names(tags=tags, tagspaces=tagspaces):
329            if operation == 'intersection' and tags.intersection(name_tags) != tags:
330                continue
331            if detailed:
332                output[tagspace][name] = name_tags
333            else:
334                output[tagspace].add(name)
335        return output
336
337    def get_tag_link(self, tag):
338        """ Return (href, title) to information about tag. This first checks for
339            a Wiki page named <tag>, then uses /tags/<tag>. """
340        from tractags.wiki import WikiTaggingSystem
341        page, title = WikiTaggingSystem(self.env).page_info(tag)
342        if page.exists:
343            return (self.env.href.wiki(tag), title)
344        else:
345            return (self.env.href.tags(tag), "Objects tagged ''%s''" % tag)
346
347    def name_details(self, tagspace, name):
348        """ Return a tuple of (href, htmllink, title). eg.
349            ("/ticket/1", "<a href="/ticket/1">#1</a>", "Broken links") """
350        return self._get_tagsystem(tagspace).name_details(name)
351
352    # ITaggingSystemProvider methods
353    def get_tagspaces_provided(self):
354        for user in self.tag_users:
355            for tagspace in user.tagspaces_used():
356                yield tagspace
357
358    def get_tagging_system(self, tagspace):
359        for taguser in self.tag_users:
360            if tagspace in taguser.tagspaces_used():
361                return DefaultTaggingSystem(self.env, tagspace)
362        raise TracError("No such tagspace '%s'" % tagspace)
363
364    # IEnvironmentSetupParticipant methods
365    def environment_created(self):
366        self._upgrade_db(self.env.get_db_cnx())
367
368    def environment_needs_upgrade(self, db):
369        cursor = db.cursor()
370        if self._need_migration(db):
371            return True
372        try:
373            cursor.execute("select count(*) from tags")
374            cursor.fetchone()
375            return False
376        except:
377            db.rollback()
378            return True
379
380    def upgrade_environment(self, db):
381        self._upgrade_db(db)
382
383    def _need_migration(self, db):
384        cursor = db.cursor()
385        try:
386            cursor.execute("select count(*) from wiki_namespace")
387            cursor.fetchone()
388            self.env.log.debug("tractags needs to migrate old data")
389            return True
390        except:
391            db.rollback()
392            return False
393
394    def _upgrade_db(self, db):
395        try:
396            try:
397                from trac.db import DatabaseManager
398                db_backend, _ = DatabaseManager(self.env)._get_connector()
399            except ImportError:
400                db_backend = self.env.get_db_cnx()
401
402            cursor = db.cursor()
403            for table in self.SCHEMA:
404                for stmt in db_backend.to_sql(table):
405                    self.env.log.debug(stmt)
406                    cursor.execute(stmt)
407
408            # Migrate old data
409            if self._need_migration(db):
410                cursor.execute("INSERT INTO tags (tagspace, name, tag) SELECT 'wiki', name, namespace FROM wiki_namespace")
411                cursor.execute("DROP TABLE wiki_namespace")
412        except Exception, e:
413            db.rollback()
414            raise TracError(str(e))
415
416        db.commit()
417
Note: See TracBrowser for help on using the repository browser.