source: tagsplugin/tags/0.3.1/tractags/api.py

Last change on this file was 1815, checked in by muness, 17 years ago

updated version of sorted to resolve more 2.3 issues.

File size: 15.6 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):
53        lst = list(iterable)
54        lst.sort()
55        return 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            predicate is called with (name, tags)
89
90            (The names and tags arguments are purely an optimisation
91            opportunity for the underlying TaggingSystem)
92            """
93
94    def get_name_tags(self, name):
95        """ Get tags for a name. """
96        raise NotImplementedError
97
98    def add_tags(self, req, name, tags):
99        """ Tag name in tagspace with tags. """
100        raise NotImplementedError
101
102    def replace_tags(self, req, name, tags):
103        """ Replace existing tags on name with tags. """
104        self.remove_all_tags(req, name)
105        self.add_tags(req, name, tags)
106
107    def remove_tags(self, req, name, tags):
108        """ Remove tags from a name in a tagspace. """
109        raise NotImplementedError
110
111    def remove_all_tags(self, req, name):
112        """ Remove all tags from a name in a tagspace. """
113        self.remove_tags(req, name, self.get_name_tags(name))
114
115    def name_details(self, name):
116        """ Return a tuple of (href, htmllink, title). eg.
117            ("/ticket/1", "<a href="/ticket/1">#1</a>", "Broken links") """
118        raise NotImplementedError
119
120class DefaultTaggingSystem(TaggingSystem):
121    """ Default tagging system. Handles any number of namespaces registered via
122        ITagSpaceUser. """
123
124    def walk_tagged_names(self, names, tags, predicate):
125        db = self.env.get_db_cnx()
126        cursor = db.cursor()
127
128        args = [self.tagspace]
129        sql = 'SELECT DISTINCT name, tag FROM tags WHERE tagspace=%s'
130        if names:
131            sql += ' AND name IN (' + ', '.join(['%s' for n in names]) + ')'
132            args += names
133        if tags:
134            sql += ' AND name in (SELECT name FROM tags WHERE tag in (' + ', '.join(['%s' for t in tags]) + '))'
135            args += tags
136        sql += " ORDER BY name"
137        cursor.execute(sql, args)
138
139        tags = set(tags)
140        current_name = None
141        name_tags = set()
142        for name, tag in cursor:
143            if current_name != name:
144                if current_name is not None:
145                    if predicate(current_name, name_tags):
146                        yield (current_name, name_tags)
147                name_tags = set([tag])
148                current_name = name
149            else:
150                name_tags.add(tag)
151        if current_name is not None and predicate(current_name, name_tags):
152            yield (current_name, name_tags)
153
154    def get_name_tags(self, name):
155        db = self.env.get_db_cnx()
156        cursor = db.cursor()
157        cursor.execute('SELECT tag FROM tags WHERE tagspace=%s AND name=%s', (self.tagspace, name))
158        return set([row[0] for row in cursor])
159
160    def add_tags(self, req, name, tags):
161        db = self.env.get_db_cnx()
162        cursor = db.cursor()
163        for tag in tags:
164            cursor.execute('INSERT INTO tags (tagspace, name, tag) VALUES (%s, %s, %s)', (self.tagspace, name, tag))
165        db.commit()
166
167    def remove_tags(self, req, name, tags):
168        db = self.env.get_db_cnx()
169        cursor = db.cursor()
170        sql = "DELETE FROM tags WHERE tagspace = %s AND name = %s AND tag " \
171              "IN (" + ', '.join(["%s" for t in tags]) + ")"
172        cursor.execute(sql, (self.tagspace, name) + tuple(tags))
173        db.commit()
174
175    def remove_all_tags(self, req, name):
176        db = self.env.get_db_cnx()
177        cursor = db.cursor()
178        cursor.execute('DELETE FROM tags WHERE tagspace=%s AND name=%s', (self.tagspace, name))
179        db.commit()
180       
181    def name_details(self, name):
182        from trac.wiki.formatter import wiki_to_oneliner
183        return (getattr(self.env.href, self.tagspace),
184                wiki_to_oneliner('[%s:"%s" %s]' % (self.tagspace, name, name), self.env), '')
185
186class TagspaceProxy:
187    """ A convenience for performing operations on a specific tagspace,
188        including get_tags() and get_tagged_names(). Both of these functions
189        will only search that tagspace, and will return values stripped of
190        tagspace information. """
191    def __init__(self, engine, tagspace):
192        self.engine = engine
193        self.tagspace = tagspace
194        self.tagsystem = engine._get_tagsystem(tagspace)
195
196    def get_tags(self, *args, **kwargs):
197        result = self.engine.get_tags(tagspaces=[self.tagspace], *args, **kwargs)
198        if isinstance(result, set):
199            return result
200        else:
201            out = {}
202            for tag, names in result.iteritems():
203                out[tag] = set([name for _, name in names])
204            return out
205
206    def get_tagged_names(self, *args, **kwargs):
207        return self.engine.get_tagged_names(tagspaces=[self.tagspace], *args, **kwargs)[self.tagspace]
208
209    def __getattr__(self, attr):
210        return getattr(self.tagsystem, attr)
211
212class TagspaceDirector(object):
213    """ A convenience similar to env.href, proxying to the correct TagSystem by
214        attribute. """
215    def __init__(self, engine):
216        self.engine = engine
217
218    def __getattr__(self, tagspace):
219        return self.tagspace(tagspace)
220
221    def tagspace(self, tagspace):
222        return TagspaceProxy(self.engine, tagspace)
223
224class TagEngine(Component):
225    """ The core of the Trac tag API. This interface can be used to register
226        tagspaces (ITagSpaceUser or register_tagspace()), add other tagging
227        systems (ITaggingSystemProvider), and to control tags in a tagspace.
228    """
229
230    _tagspace_re = re.compile(r'''^[a-zA-Z_][a-zA-Z0-9_]*$''')
231
232    implements(ITaggingSystemProvider, IEnvironmentSetupParticipant)
233
234    tag_users = ExtensionPoint(ITagSpaceUser)
235    tagging_systems = ExtensionPoint(ITaggingSystemProvider)
236
237    SCHEMA = [
238        Table('tags', key = ('tagspace', 'name', 'tag'))[
239              Column('tagspace'),
240              Column('name'),
241              Column('tag'),
242              Index(['tagspace', 'name']),
243              Index(['tagspace', 'tag']),]
244        ]
245
246    def __init__(self):
247        self.tagspace = TagspaceDirector(self)
248        self._tagsystem_cache = {}
249        self._tag_link_cache = {}
250
251    def _get_tagspaces(self):
252        """ Get iterable of available tagspaces. """
253        out = []
254        for tagsystem in self.tagging_systems:
255            for tagspace in tagsystem.get_tagspaces_provided():
256                out.append(tagspace)
257        return out
258    tagspaces = property(_get_tagspaces)
259
260    def _get_tagsystem(self, tagspace):
261        """ Returns a TaggingSystem proxy object with tagspace as the default
262            tagspace. """
263        try:
264            return self._tagsystem_cache[tagspace]
265        except KeyError:
266            for tagsystem in self.tagging_systems:
267                if tagspace in tagsystem.get_tagspaces_provided():
268                   self._tagsystem_cache[tagspace] = tagsystem.get_tagging_system(tagspace)
269                   return self._tagsystem_cache[tagspace]
270        raise TracError("No such tagspace '%s'" % tagspace)
271
272    # Public methods
273    def walk_tagged_names(self, names=[], tags=[], tagspaces=[], predicate=lambda tagspace, name, tags: True):
274        """ Generator returning (tagspace, name, tags) for all names in the
275            given tagspaces. Objects must have at least one of tags, be in
276            names and must meet the predicate. """
277        tagspaces = tagspaces or self.tagspaces
278        for tagspace in tagspaces:
279            tagsystem = self._get_tagsystem(tagspace)
280            for name, name_tags in tagsystem.walk_tagged_names(names=names, tags=tags, predicate=lambda n, t: predicate(tagspace, n, t)):
281                yield (tagspace, name, name_tags)
282
283    def get_tags(self, names=[], tagspaces=[], operation='union', detailed=False):
284        """ Get tags with the given names from the given tagspaces.
285            'operation' is the union or intersection of all tags on
286            names. If detailed, return a set of
287            {tag:set([(tagspace, name), ...])}, otherwise return a set of
288            tags. """
289        assert type(names) in (list, tuple, set)
290        tagspaces = tagspaces or self.tagspaces
291        seed_set = True
292        all_tags = set()
293        tagged_names = {}
294        for tagspace, name, tags in self.walk_tagged_names(names=names, tagspaces=tagspaces):
295            for tag in tags:
296                tagged_names.setdefault(tag, set()).add((tagspace, name))
297            if operation == 'intersection':
298                if seed_set:
299                    seed_set = False
300                    all_tags.update(tags)
301                else:
302                    all_tags.intersection_update(tags)
303                    if not all_tags:
304                        return detailed and {} or set()
305            else:
306                all_tags.update(tags)
307        if detailed:
308            out_tags = {}
309            for tag in all_tags:
310                out_tags[tag] = tagged_names[tag]
311            return out_tags
312        else:
313            return all_tags
314
315    def get_tagged_names(self, tags=[], tagspaces=[], operation='intersection', detailed=False):
316        """ Get names with the given tags from tagspaces. 'operation' is the set
317            operatin to perform on the sets of names tagged with each of the
318            search tags, and can be either 'intersection' or 'union'.
319
320            If detailed=True return a dictionary of
321            {tagspace:{name:set([tag, ...])}} otherwise return a dictionary of
322            {tagspace:set([name, ...])}. """
323        assert type(tags) in (list, tuple, set)
324        tagspaces = tagspaces or self.tagspaces
325        tags = set(tags)
326        if detailed:
327            output = dict([(ts, {}) for ts in tagspaces])
328        else:
329            output = dict([(ts, set()) for ts in tagspaces])
330        for tagspace, name, name_tags in self.walk_tagged_names(tags=tags, tagspaces=tagspaces):
331            if operation == 'intersection' and tags.intersection(name_tags) != tags:
332                continue
333            if detailed:
334                output[tagspace][name] = name_tags
335            else:
336                output[tagspace].add(name)
337        return output
338
339    def get_tag_link(self, tag):
340        """ Return (href, title) to information about tag. This first checks for
341            a Wiki page named <tag>, then uses /tags/<tag>. """
342        if tag in self._tag_link_cache:
343            return self._tag_link_cache[tag]
344        from tractags.wiki import WikiTaggingSystem
345        page, title = WikiTaggingSystem(self.env).page_info(tag)
346        if page.exists:
347            result = (self.env.href.wiki(tag), title)
348        else:
349            result = (self.env.href.tags(tag), "Objects tagged ''%s''" % tag)
350        self._tag_link_cache[tag] = result
351        return result
352   
353
354    def name_details(self, tagspace, name):
355        """ Return a tuple of (href, htmllink, title). eg.
356            ("/ticket/1", "<a href="/ticket/1">#1</a>", "Broken links") """
357        return self._get_tagsystem(tagspace).name_details(name)
358
359    # ITaggingSystemProvider methods
360    def get_tagspaces_provided(self):
361        for user in self.tag_users:
362            for tagspace in user.tagspaces_used():
363                yield tagspace
364
365    def get_tagging_system(self, tagspace):
366        for taguser in self.tag_users:
367            if tagspace in taguser.tagspaces_used():
368                return DefaultTaggingSystem(self.env, tagspace)
369        raise TracError("No such tagspace '%s'" % tagspace)
370
371    # IEnvironmentSetupParticipant methods
372    def environment_created(self):
373        self._upgrade_db(self.env.get_db_cnx())
374
375    def environment_needs_upgrade(self, db):
376        cursor = db.cursor()
377        if self._need_migration(db):
378            return True
379        try:
380            cursor.execute("select count(*) from tags")
381            cursor.fetchone()
382            return False
383        except:
384            db.rollback()
385            return True
386
387    def upgrade_environment(self, db):
388        self._upgrade_db(db)
389
390    def _need_migration(self, db):
391        cursor = db.cursor()
392        try:
393            cursor.execute("select count(*) from wiki_namespace")
394            cursor.fetchone()
395            self.env.log.debug("tractags needs to migrate old data")
396            return True
397        except:
398            db.rollback()
399            return False
400
401    def _upgrade_db(self, db):
402        try:
403            try:
404                from trac.db import DatabaseManager
405                db_backend, _ = DatabaseManager(self.env)._get_connector()
406            except ImportError:
407                db_backend = self.env.get_db_cnx()
408
409            cursor = db.cursor()
410            for table in self.SCHEMA:
411                for stmt in db_backend.to_sql(table):
412                    self.env.log.debug(stmt)
413                    cursor.execute(stmt)
414            db.commit()
415
416            # Migrate old data
417            if self._need_migration(db):
418                cursor = db.cursor()
419                cursor.execute("INSERT INTO tags (tagspace, name, tag) SELECT 'wiki', name, namespace FROM wiki_namespace")
420                cursor.execute("DROP TABLE wiki_namespace")
421                db.commit()
422        except Exception, e:
423            db.rollback()
424            self.env.log.error(e, exc_info=1)
425            raise TracError(str(e))
Note: See TracBrowser for help on using the repository browser.