source: subversion/applications/utils/change_tags/change_tags.py @ 34561

Last change on this file since 34561 was 13120, checked in by crschmidt, 11 years ago

improved object counting for reporting of status/progress.

File size: 19.2 KB
Line 
1#!/usr/bin/python
2
3"""change_tags.py, an OSM tool.
4
5   This application is designed to convert tags, based on a downloaded .osm
6   file. To use, open the .py file and edit the contents of the 'converter'
7   function to change the tags in the way you want to, then comment out the
8   'converter = False' line immediately after the function body. Once you have
9   done that, Typically, usage would be:
10
11       python tag_changer.py --dry-run -f file.osm --verbose
12
13   followed by  manual inspection of the output XML. At the end, it will report
14   how many total nodes/elements there are, and how many would be changed by
15   the conversion.
16
17   You can also write your own functions in another module, and provide:
18
19     --module mod_name --function func_name
20
21   Once you've done a dry run, call:
22
23       python tag_changer.py -f file.osm -u <username> -p <password>
24
25   This should iterate through and perform the updates against the API,
26   reporting skipped nodes or errors at the end.
27
28   If you are working only with your own data, you should usually use
29   
30       --only-mine='Display Name'
31
32   Which will skip any updates which do not have you as the last author.
33"""   
34
35__author__  = "Christopher Schmidt <crschmidt@crschmidt.net>"
36__version__ = "0.2"
37__revision__ = "$Id$"
38
39import traceback 
40
41import dbm
42import md5
43import time
44import sys, re, xml.sax
45from xml.sax.handler import ContentHandler
46
47import xml.dom.minidom as minidom
48
49requires = []
50
51try:
52    from xml.etree.cElementTree import Element, SubElement, tostring
53except ImportError:
54    requires.append("Requires xml.etree.cElementTree. Try Python2.5?")
55
56try:
57    import httplib2
58except ImportError, E:
59    requires.append("No httplib2! Try easy_install httplib2 (%s)" % E)
60
61def converter(tags, type):
62    """Pass in tags dict and object type. Function must return True (object
63    changed, upload to server) or False (nothing changed, don't reupload).
64    Change the tags dict in place.
65   
66    By default, this function simply returns 'False' (nothing changed);
67    however, it has some examples of what can be done as alternatives.
68
69    This is the function you should edit in order to change the tags you
70    wish to change.
71
72    By default, adds a created_by tag identifying the source of the change.
73    """
74   
75#    return True
76   
77    changed = False
78   
79    if type == "node":
80        return False 
81   
82    # Fix a typo
83    if 'name' in tags and 'Playgroung' in tags['name']:
84        tags['name'] = tags['name'].replace("Playgroung", "Playground")
85        changed = True   
86   
87    # adjust a tag based on a name
88    if 'name' in tags and ('park' in tags['name'].lower() or
89       'playground' in tags['name'].lower()):
90        tags['leisure'] = 'park'
91        changed = True
92   
93    # change a key in a tag
94#    if 'leisure' in tags and tags['leisure'] == 'recreation_ground':
95#        del tags['leisure']
96#        if not 'landuse' in tags:
97#            tags['landuse'] = 'recreation_ground'
98#        else:
99#            tags['landuse'] = "%s; recreation_ground" % tags['landuse']
100#        changed = True
101
102    tags['created_by'] = 'change_tags.py %s' % __version__
103
104    return changed   
105
106# Comment this line out once you have edited the converter above.
107#converter = False
108
109#### DO NOT CHANGE CODE AFTER THIS LINE #####
110
111
112def indent(elem, level=0):
113    """Used for pretty printing XML."""
114    i = "\n" + level*"  "
115    if len(elem):
116        if not elem.text or not elem.text.strip():
117            elem.text = i + "  "
118        for e in elem:
119            indent(e, level+1)
120            if not e.tail or not e.tail.strip():
121                e.tail = i + "  "
122        if not e.tail or not e.tail.strip():
123            e.tail = i
124    else:
125        if level and (not elem.tail or not elem.tail.strip()):
126            elem.tail = i
127
128class changeTags (ContentHandler):
129    """A class implementing an XML Content handler which uses a passed converter function to change
130       data on the OSM server based on a local XML file."""
131    def __init__ (self, converter, db=None, user=None, password=None, dry_run=None, noisy_errors=None, only_mine=None, verbose=None, changes=None, api=None, check_api=None, obj_counts = {}):
132        ContentHandler.__init__(self)
133       
134        self.converter = converter
135        self.user = user
136        self.password = password
137        self.dry_run = dry_run
138        self.noisy_errors = noisy_errors
139        self.verbose = verbose
140        self.only_mine = only_mine
141        self.change_count = changes
142        self.db = db
143        self.api = api
144        self.check_api_first = check_api 
145
146        self.last_report_time = time.time() 
147        self.last_report_object = 0
148        self.changes = {'node': 0, 'way': 0} 
149        self.already_changed = {'node': 0, 'way': 0} 
150        self.read = {'node': 0, 'way': 0, 'tag': 0} 
151        self.total_read = {'node': obj_counts.get('node', 0), 'way': obj_counts.get('way', 0)}
152        self.errors = []
153        self.skipped = []
154       
155        if not self.dry_run:
156            if self.user and self.password:
157                self.h = httplib2.Http()
158                self.h.add_credentials(self.user, self.password)
159            else:
160                raise Exception("Username and password required.")
161        else:
162            self.h = httplib2.Http()
163
164   
165    def current_from_api(self):
166        """Update the self.current object by fetching it from the API, and
167        doing a quick/simple reparse with minidom."""
168       
169        url = "%s/%s/%s" % (self.api, self.current['type'], self.current['id'])
170        resp, content = self.h.request(url, "GET")
171        if int(resp.status) == 410:
172            raise Exception("Object has been deleted.")
173        elif True or int(resp.status) == 200:
174            doc = minidom.parseString(content)
175            item = doc.getElementsByTagName(self.current['type'])[0]
176            self.current['user'] = item.getAttribute("user")     
177            if self.current['type'] == "node":
178                self.current['lon'] = item.getAttribute("lon")   
179                self.current['lat'] = item.getAttribute("lat")   
180            elif self.current['type'] == "way":
181                nodes = []
182                for i in item.getElementsByTagName("nd"):
183                    nodes.append(i.getAttribute("ref"))
184                self.current['nodes'] = nodes
185            tags = {}
186            for i in item.getElementsByTagName("tag"):
187                tags[i.getAttribute("k")] = i.getAttribute("v")
188            self.current['tags'] = tags   
189               
190        else:
191            raise Exception("Couldn't update from API server.")
192
193    def progress(self):
194        upload = self.changes['node'] + self.changes['way'] + \
195            self.already_changed['node'] + self.already_changed['way']
196       
197        t = time.time() - self.last_report_time
198       
199        if t > 10:
200           
201            obj_count = "Nodes: %s/%s, Ways: %s/%s" % (self.read['node'], self.total_read['node'], self.read['way'], self.total_read['way'])
202
203            c = upload - self.last_report_object
204            rate = float(c/t)
205           
206            if self.change_count:
207                if rate:
208                    remain = float(self.change_count - upload) / rate
209                    remain_string = "%i:%02i" % (remain / 60, remain % 60)
210                else:
211                    remain_string = "??"
212                print "%s Upload: %.2f%% (%s/%s) complete; %.3f/s  %s remain" % (obj_count, ((float(upload)/self.change_count) * 100), upload, self.change_count, rate, remain_string)
213                #else:
214                #    print "%s Upload: 0/%s complete" % (obj_count, self.change_count)
215            else:
216                print "%s, %s complete" % (obj_count, upload)
217            self.last_report_object = upload
218            self.last_report_time = time.time() 
219
220    def upload(self):
221        """Upload the way.""" 
222        if self.check_api_first:
223            try:
224                self.current_from_api()
225                run = self.converter(self.current['tags'], self.current['type'])
226                if not run: 
227                    raise Exception("Server version changed; no change needed.") 
228            except Exception, E:
229                error = {'item': self.current, 'code': -5, 'data': str(E)}
230                if self.noisy_errors: print "Error occurred! %s" % error
231                self.errors.append(error)
232                return
233        if self.only_mine and self.current['user'] != self.only_mine:
234            self.skipped.append({'id': self.current['id'], 
235                    'type':self.current['type'], 
236                    'reason': "User %s doesn't match." % self.current['user']})
237            return
238        db_key = "%s:%s" % (self.current['type'], self.current['id']) 
239        if self.db and self.db.has_key(db_key):
240            self.already_changed[self.current['type']] += 1
241            return
242       
243        url = "%s/%s/%s" % (self.api, self.current['type'], self.current['id'])
244        xml = self.makeXML()
245       
246        if self.dry_run:
247            if self.verbose:
248                print "URL:  %s" % url
249                print "XML:\n%s" % xml
250            self.changes[self.current['type']] += 1
251       
252        else:
253            try:
254                if self.verbose: # self.verbose:
255                    print "Opening URL %s" % url
256               
257                resp, content = self.h.request(url, "PUT", body=xml)
258                if int(resp.status) != 200:
259                    error = {'item': self.current, 'code': resp.status, 'data': content}
260                    if self.noisy_errors: print "Error occurred! %s" % error
261                    self.errors.append(error)
262                else:
263                    self.db[db_key] = "1" 
264                    self.changes[self.current['type']] += 1
265
266            except Exception, E:
267                error = {'item': self.current, 'code': -1, 'data': str(E)}
268                if self.noisy_errors: print "Error occurred! %s" % error
269                self.errors.append(error)
270
271    def startElement (self, name, attr):
272        """Handle creating the self.current node, and pulling tags/nd refs."""
273        if name == 'node':
274            self.current = {'type': 'node', 'id': attr['id'],
275                'lon':attr["lon"], 'lat':attr["lat"], 'tags': {}}
276        elif name == 'way':
277            self.current = {'type': 'way', 'id': attr['id'], 'nodes':[], 'tags': {}}
278        elif name =='nd' and self.current:
279            self.current['nodes'].append(attr["ref"])
280        elif name == 'tag' and self.current:
281            self.current['tags'][attr['k']] = attr['v']
282        if 'user' in attr and self.current:
283            self.current['user'] = attr['user']
284       
285        if name in ['node', 'way', 'tag']:
286            self.read[name] += 1
287
288    def makeXML(self):
289        if self.current['type'] == "way":
290            osm = Element('osm', {'version': '0.5'})
291
292            parent = SubElement(osm, 'way', {'id': self.current['id']})
293            for n in self.current['nodes']:
294                SubElement(parent, "nd", {'ref': n})
295            keys = self.current['tags'].keys()
296            keys.sort()
297            for key in keys:
298                SubElement(parent, "tag", {'k': key, 'v': self.current['tags'][key]})
299       
300        if self.current['type'] == "node":
301            osm = Element('osm', {'version': '0.5'})
302
303            parent = SubElement(osm, 'node', 
304                {'id': self.current['id'], 
305                 'lat': self.current['lat'], 
306                 'lon': self.current['lon']})
307           
308            keys = self.current['tags'].keys()
309            keys.sort()
310            for key in keys:
311                SubElement(parent, "tag", {'k': key, 'v': self.current['tags'][key]})
312       
313        indent(osm)
314        return tostring(osm)
315    def endElement (self, name):
316        """Switch on node type, and serialize to XML for upload or print."""
317        if name in ['way', 'node']:
318            new_tags = self.converter(self.current['tags'], self.current['type'])
319            if new_tags:
320                self.upload()
321        self.progress()
322       
323
324if __name__ == "__main__":
325    if not converter:
326        if requires:
327            __doc__ = "%s\nRequired dependancies unavailable: \n %s" % (__doc__, ("\n  ".join(requires)))
328        print __doc__
329        sys.exit(1)
330   
331    if requires:
332        print "Required dependancies unavailable: \n  %s" % ("\n  ".join(requires)) 
333        sys.exit(2)
334
335    from optparse import OptionParser, OptionGroup
336    parser = OptionParser(usage="%prog [options] \n  Change tags on the OSM server based on applying Python functions to a file."  )
337
338    parser.add_option("-f", "--file", 
339        help="source file.", dest="file")
340   
341    parser.add_option("-m", "--module", 
342        help="module file for converter. Default is internal", dest="module")
343    parser.add_option("--function", 
344        help="function for converter (in module). Default is same as module name", dest="function")
345   
346    parser.add_option("--doc", 
347        help="print documentation", action="store_true", dest="doc")
348   
349    parser.add_option("-d", "--dry-run", 
350        action="store_true", default=False, 
351        help="print URLs and XML for changed items, rather than actually changing them.", 
352        dest="dry_run")
353    parser.add_option("--verbose", 
354        help="be verbose", 
355        dest="verbose", 
356        default=False, 
357        action="store_true")
358    parser.add_option('-o', "--only-mine", 
359        dest="only_mine", 
360        help="Provide a username/displayname which will be used to check if an edit should be perormed.") 
361    parser.add_option('-c', "--check-api", 
362        dest="check_api", action="store_true",
363        help="For each item to be uploaded, get the current data from the API first. (Safer)") 
364   
365    group = OptionGroup(parser, "API Options", "OSM API Configuration")
366
367    group.add_option("-u", "--username", help="username for OSM API", 
368        dest="username")
369    group.add_option("-p", "--password", 
370        help="api password (will prompt if not provided and required)", 
371        dest="password")
372    group.add_option("-a", "--api", 
373        help="api url; default: http://api.openstreetmap.org/0.5", 
374        dest="api",
375        default="http://api.openstreetmap.org/api/0.5")
376    parser.add_option_group(group)
377   
378    group = OptionGroup(parser, "Advanced Options", "Don't use these unless you know what you're doing.")
379
380    group.add_option("-e", "--noisy-errors", 
381        dest="noisy_errors", 
382        default=False, 
383        action="store_true")
384    group.add_option("-n", "--no-status", 
385        dest="no_status", 
386        action="store_true", 
387        default=False, 
388        help="Don't store status db for recovery of upload (faster; riskier)")
389    group.add_option('--profile', 
390        dest='profile', action="store_true", 
391        help="Report profiler stats", 
392        default=False)
393   
394    parser.add_option_group(group)
395
396    options, args = parser.parse_args()
397
398    if options.doc:
399        parser.print_help()
400        print __doc__
401        sys.exit()
402
403    f = None
404    if options.file:
405        f = open(options.file)
406        if not options.no_status:
407            db = dbm.open("%s.db" % options.file, "c")
408        else:
409            db = None 
410    else:
411        raise Exception("No input file")
412   
413    if options.module:
414        m = __import__(options.module)
415        func = options.function or options.module
416        if not hasattr(m, func):
417            print "Module %s has no function %s: Check your module." % (
418                options.module, func)
419        converter = getattr(m,func)
420
421    if not options.dry_run and options.username and not options.password:
422        import getpass
423        options.password = getpass.getpass("Password: ") 
424   
425    changes = 0
426    read = {}
427    if not options.dry_run:
428        try:
429            f_dry = open("%s.dry_run" % options.file)
430        except Exception, E:
431            print "You haven't run a dry run to see what the results will be! (%s)" % E
432            sys.exit(2)
433        data = f_dry.read().strip()
434        hash, changes, read = data.split("\n|||\n")
435        if hash != converter.func_code.co_code:
436            print "The converter function changed, and you haven't run another dry run. Do that first."
437            sys.exit(3)
438        node_changes, way_changes = map(int, changes.split("|"))
439        node_read, way_read = map(int, read.split("|"))
440        changes = node_changes + way_changes
441        read = {'node': node_read, 'way': way_read} 
442        if changes > 1000:
443            print "You are changing more than 1000 objects. Ask crschmidt for the special password."
444            pw = raw_input("Secret Phrase: ")
445            if md5.md5(pw).hexdigest() != '1271ed5ef305aadabc605b1609e24c52': 
446                print "That's too much at once. You might break something. %s" % changes
447                sys.exit(4)
448           
449           
450    osmParser = changeTags(
451            converter,
452            db=db,
453            user=options.username, 
454            password=options.password, 
455            dry_run=options.dry_run, 
456            noisy_errors = options.noisy_errors, 
457            only_mine=options.only_mine,
458            verbose=options.verbose,
459            changes=changes,
460            api=options.api,
461            check_api=options.check_api,
462            obj_counts=read)
463   
464    prof = None
465   
466    if options.profile:
467        import hotshot, hotshot.stats
468        prof = hotshot.Profile("tagChanger.prof")
469    failed = False 
470    try:
471       
472        if prof:
473            prof.runcall(xml.sax.parse, f, osmParser)
474        else:   
475            xml.sax.parse( f, osmParser )
476
477    except KeyboardInterrupt:
478        print "\nStopping at %s %s due to interrupt"  % \
479              (osmParser.current['type'], osmParser.current['id'])
480        failed = True     
481   
482    except Exception, E:
483        if osmParser.current:
484            print "\nStopping at %s %s due to exception: \n%s"  % \
485                  (osmParser.current['type'], osmParser.current['id'], E)
486                 
487        else:
488            print "Stopping due to Exception: \n" % E
489        failed = True   
490
491    print "Total Read: %s nodes, %s ways, %s tags"  % (
492           osmParser.read['node'], osmParser.read['way'], osmParser.read['tag'])
493   
494    print "Total Changed: %s nodes, %s ways"  % (
495           osmParser.changes['node'], osmParser.changes['way'])
496
497    print "Previously Changed: %s nodes, %s ways"  % \
498          (osmParser.already_changed['node'], osmParser.already_changed['way'])
499   
500    if not failed and options.dry_run and options.file:
501        f = open("%s.dry_run" % options.file, "w")
502        f.write("%s\n|||\n%i|%i\n|||\n%i|%i" % \
503               (converter.func_code.co_code, 
504                osmParser.changes['node'], osmParser.changes['way'], 
505                osmParser.read['node'], osmParser.read['way']
506                ))
507        f.close()
508   
509    if len(osmParser.errors):
510        print "The following %s errors occurred:" % len(osmParser.errors)
511   
512        for e in osmParser.errors:
513             print "%s: %s. Code: %s. Error Text: %s" % \
514                (e['item']['type'],e['item']['id'], e['code'], e['data'])
515   
516    if len(osmParser.skipped):
517        print "The following %s items were skipped:" % len(osmParser.skipped)
518   
519        for e in osmParser.skipped:
520             print "%s: %s. Reason: %s" % (e['type'],e['id'], e['reason'])
521   
522    if prof:
523        stats = hotshot.stats.load("tagChanger.prof")
524        stats.strip_dirs()
525        stats.sort_stats('time', 'calls')
526        stats.print_stats(20)
Note: See TracBrowser for help on using the repository browser.