Integration and coverage tests. (Fixes issue #2)
[python-bulletml.git] / bulletml / parser.py
1 """BulletML parser.
2
3 This is based on the format described at
4 http://www.asahi-net.or.jp/~cs8k-cyu/bulletml/bulletml_ref_e.html.
5
6 Unless you are adding support for new tags, the only class you should
7 care about in here is BulletML.
8 """
9
10 from __future__ import division
11
12 import math
13
14 from xml.etree.ElementTree import ElementTree
15
16 # Python 3 moved this for no really good reason.
17 try:
18 from sys import intern
19 except ImportError:
20 pass
21
22 try:
23 from io import StringIO
24 except ImportError:
25 try:
26 from cStringIO import StringIO
27 except ImportError:
28 from StringIO import StringIO
29
30 from bulletml.errors import Error
31 from bulletml.expr import NumberDef, INumberDef
32
33
34 __all__ = ["ParseError", "BulletML"]
35
36 class ParseError(Error):
37 """Raised when an error occurs parsing the XML structure."""
38 pass
39
40 def realtag(element):
41 """Strip namespace poop off the front of a tag."""
42 try:
43 return element.tag.rsplit('}', 1)[1]
44 except ValueError:
45 return element.tag
46
47 class ParamList(object):
48 """List of parameter definitions."""
49
50 def __init__(self, params=()):
51 self.params = list(params)
52
53 @classmethod
54 def FromXML(cls, doc, element):
55 """Construct using an ElementTree-style element."""
56 return cls([NumberDef(subelem.text) for subelem in element
57 if realtag(subelem) == "param"])
58
59 def __call__(self, params, rank):
60 return [param(params, rank) for param in self.params]
61
62 def __repr__(self):
63 return "%s(%r)" % (type(self).__name__, self.params)
64
65 class Direction(object):
66 """Raw direction value."""
67
68 VALID_TYPES = ["relative", "absolute", "aim", "sequence"]
69
70 def __init__(self, type, value):
71 if type not in self.VALID_TYPES:
72 raise ValueError("invalid type %r" % type)
73 self.type = intern(type)
74 self.value = value
75
76 def __getstate__(self):
77 return [('type', self.type), ('value', self.value.expr)]
78
79 def __setstate__(self, state):
80 state = dict(state)
81 self.__init__(state["type"], NumberDef(state["value"]))
82
83 @classmethod
84 def FromXML(cls, doc, element, default="absolute"):
85 """Construct using an ElementTree-style element."""
86 return cls(element.get("type", default), NumberDef(element.text))
87
88 def __call__(self, params, rank):
89 return (math.radians(self.value(params, rank)), self.type)
90
91 def __repr__(self):
92 return "%s(%r, type=%r)" % (
93 type(self).__name__, self.value, self.type)
94
95 class ChangeDirection(object):
96 """Direction change over time."""
97
98 def __init__(self, term, direction):
99 self.term = term
100 self.direction = direction
101
102 def __getstate__(self):
103 return [('frames', self.term.expr),
104 ('type', self.direction.type),
105 ('value', self.direction.value.expr)]
106
107 def __setstate__(self, state):
108 state = dict(state)
109 self.__init__(INumberDef(state["frames"]),
110 Direction(state["type"], NumberDef(state["value"])))
111
112 @classmethod
113 def FromXML(cls, doc, element):
114 """Construct using an ElementTree-style element."""
115 for subelem in element.getchildren():
116 tag = realtag(subelem)
117 if tag == "direction":
118 direction = Direction.FromXML(doc, subelem)
119 elif tag == "term":
120 term = INumberDef(subelem.text)
121 try:
122 return cls(term, direction)
123 except UnboundLocalError as exc:
124 raise ParseError(str(exc))
125
126 def __call__(self, params, rank):
127 return self.term(params, rank), self.direction(params, rank)
128
129 def __repr__(self):
130 return "%s(term=%r, direction=%r)" % (
131 type(self).__name__, self.term, self.direction)
132
133 class Speed(object):
134 """Raw speed value."""
135
136 VALID_TYPES = ["relative", "absolute", "sequence"]
137
138 def __init__(self, type, value):
139 if type not in self.VALID_TYPES:
140 raise ValueError("invalid type %r" % type)
141 self.type = intern(type)
142 self.value = value
143
144 def __getstate__(self):
145 return [('type', self.type), ('value', self.value.expr)]
146
147 def __setstate__(self, state):
148 state = dict(state)
149 self.__init__(state["type"], NumberDef(state["value"]))
150
151 @classmethod
152 def FromXML(cls, doc, element):
153 """Construct using an ElementTree-style element."""
154 return cls(element.get("type", "absolute"), NumberDef(element.text))
155
156 def __call__(self, params, rank):
157 return (self.value(params, rank), self.type)
158
159 def __repr__(self):
160 return "%s(%r, type=%r)" % (type(self).__name__, self.value, self.type)
161
162 class ChangeSpeed(object):
163 """Speed change over time."""
164
165 def __init__(self, term, speed):
166 self.term = term
167 self.speed = speed
168
169 def __getstate__(self):
170 return [('frames', self.term.expr),
171 ('type', self.speed.type),
172 ('value', self.speed.value.expr)]
173
174 def __setstate__(self, state):
175 state = dict(state)
176 self.__init__(INumberDef(state["frames"]),
177 Speed(state["type"], NumberDef(state["value"])))
178
179 @classmethod
180 def FromXML(cls, doc, element):
181 """Construct using an ElementTree-style element."""
182 for subelem in element.getchildren():
183 tag = realtag(subelem)
184 if tag == "speed":
185 speed = Speed.FromXML(doc, subelem)
186 elif tag == "term":
187 term = INumberDef(subelem.text)
188 try:
189 return cls(term, speed)
190 except UnboundLocalError as exc:
191 raise ParseError(str(exc))
192
193 def __call__(self, params, rank):
194 return self.term(params, rank), self.speed(params, rank)
195
196 def __repr__(self):
197 return "%s(term=%r, speed=%r)" % (
198 type(self).__name__, self.term, self.speed)
199
200 class Wait(object):
201 """Wait for some frames."""
202
203 def __init__(self, frames):
204 self.frames = frames
205
206 def __getstate__(self):
207 return dict(frames=self.frames.expr)
208
209 def __setstate__(self, state):
210 self.__init__(INumberDef(state["frames"]))
211
212 @classmethod
213 def FromXML(cls, doc, element):
214 """Construct using an ElementTree-style element."""
215 return cls(INumberDef(element.text))
216
217 def __call__(self, params, rank):
218 return self.frames(params, rank)
219
220 def __repr__(self):
221 return "%s(%r)" % (type(self).__name__, self.frames)
222
223 class Tag(object):
224 """Set a bullet tag."""
225
226 def __init__(self, tag):
227 self.tag = tag
228
229 def __getstate__(self):
230 return dict(tag=self.tag)
231
232 def __setstate__(self, state):
233 self.__init__(state["tag"])
234
235 @classmethod
236 def FromXML(cls, doc, element):
237 """Construct using an ElementTree-style element."""
238 return cls(element.text)
239
240 class Untag(object):
241 """Unset a bullet tag."""
242
243 def __init__(self, tag):
244 self.tag = tag
245
246 def __getstate__(self):
247 return dict(tag=self.tag)
248
249 def __setstate__(self, state):
250 self.__init__(state["tag"])
251
252 @classmethod
253 def FromXML(cls, doc, element):
254 """Construct using an ElementTree-style element."""
255 return cls(element.text)
256
257 class Vanish(object):
258 """Make the owner disappear."""
259
260 def __init__(self):
261 pass
262
263 @classmethod
264 def FromXML(cls, doc, element):
265 """Construct using an ElementTree-style element."""
266 return cls()
267
268 def __repr__(self):
269 return "%s()" % (type(self).__name__)
270
271 class Repeat(object):
272 """Repeat an action definition."""
273
274 def __init__(self, times, action):
275 self.times = times
276 self.action = action
277
278 def __getstate__(self):
279 return [('times', self.times.expr), ('action', self.action)]
280
281 def __setstate__(self, state):
282 state = dict(state)
283 self.__init__(INumberDef(state["times"]), state["action"])
284
285 @classmethod
286 def FromXML(cls, doc, element):
287 """Construct using an ElementTree-style element."""
288 for subelem in element.getchildren():
289 tag = realtag(subelem)
290 if tag == "times":
291 times = INumberDef(subelem.text)
292 elif tag == "action":
293 action = ActionDef.FromXML(doc, subelem)
294 elif tag == "actionRef":
295 action = ActionRef.FromXML(doc, subelem)
296 try:
297 return cls(times, action)
298 except UnboundLocalError as exc:
299 raise ParseError(str(exc))
300
301 def __call__(self, params, rank):
302 return self.times(params, rank), self.action(params, rank)
303
304 def __repr__(self):
305 return "%s(%r, %r)" % (type(self).__name__, self.times, self.action)
306
307 class Accel(object):
308 """Accelerate over some time."""
309
310 horizontal = None
311 vertical = None
312
313 def __init__(self, term, horizontal=None, vertical=None):
314 self.term = term
315 self.horizontal = horizontal
316 self.vertical = vertical
317
318 def __getstate__(self):
319 state = [('frames', self.term.expr)]
320 if self.horizontal:
321 state.append(('horizontal', self.horizontal))
322 if self.vertical:
323 state.append(('vertical', self.vertical))
324 return state
325
326 def __setstate__(self, state):
327 state = dict(state)
328 self.__init__(INumberDef(state["frames"]), state.get("horizontal"),
329 state.get("vertical"))
330
331 @classmethod
332 def FromXML(cls, doc, element):
333 """Construct using an ElementTree-style element."""
334 horizontal = None
335 vertical = None
336
337 for subelem in element.getchildren():
338 tag = realtag(subelem)
339 if tag == "term":
340 term = INumberDef(subelem.text)
341 elif tag == "horizontal":
342 horizontal = Speed.FromXML(doc, subelem)
343 elif tag == "vertical":
344 vertical = Speed.FromXML(doc, subelem)
345
346 try:
347 return cls(term, horizontal, vertical)
348 except AttributeError:
349 raise ParseError
350
351 def __call__(self, params, rank):
352 frames = self.term(params, rank)
353 horizontal = self.horizontal and self.horizontal(params, rank)
354 vertical = self.vertical and self.vertical(params, rank)
355 return frames, horizontal, vertical
356
357 def __repr__(self):
358 return "%s(%r, horizontal=%r, vertical=%r)" % (
359 type(self).__name__, self.term, self.horizontal, self.vertical)
360
361 class BulletDef(object):
362 """Bullet definition."""
363
364 def __init__(self, actions=(), direction=None, speed=None, tags=()):
365 self.direction = direction
366 self.speed = speed
367 self.actions = list(actions)
368 self.tags = set(tags)
369
370 def __getstate__(self):
371 state = []
372 if self.direction:
373 state.append(("direction", self.direction))
374 if self.speed:
375 state.append(("speed", self.speed))
376 if self.actions:
377 state.append(("actions", self.actions))
378 if self.tags:
379 state.append(("tags", list(self.tags)))
380 return state
381
382 def __setstate__(self, state):
383 state = dict(state)
384 self.__init__(**state)
385
386 @classmethod
387 def FromXML(cls, doc, element):
388 """Construct using an ElementTree-style element."""
389 actions = []
390 speed = None
391 direction = None
392 tags = set()
393 for subelem in element.getchildren():
394 tag = realtag(subelem)
395 if tag == "direction":
396 direction = Direction.FromXML(doc, subelem)
397 elif tag == "speed":
398 speed = Speed.FromXML(doc, subelem)
399 elif tag == "action":
400 actions.append(ActionDef.FromXML(doc, subelem))
401 elif tag == "actionRef":
402 actions.append(ActionRef.FromXML(doc, subelem))
403 elif tag == "tag":
404 tags.add(subelem.text)
405 dfn = cls(actions, direction, speed, tags)
406 doc._bullets[element.get("label")] = dfn
407 return dfn
408
409 def __call__(self, params, rank):
410 actions = [action(params, rank) for action in self.actions]
411 return (
412 self.direction and self.direction(params, rank),
413 self.speed and self.speed(params, rank),
414 self.tags,
415 actions)
416
417 def __repr__(self):
418 return "%s(direction=%r, speed=%r, actions=%r)" % (
419 type(self).__name__, self.direction, self.speed, self.actions)
420
421 class BulletRef(object):
422 """Create a bullet by name with parameters."""
423
424 def __init__(self, bullet, params=None):
425 self.bullet = bullet
426 self.params = ParamList() if params is None else params
427
428 def __getstate__(self):
429 state = []
430 if self.params.params:
431 params = [param.expr for param in self.params.params]
432 state.append(("params", params))
433 state.append(('bullet', self.bullet))
434 return state
435
436 def __setstate__(self, state):
437 state = dict(state)
438 bullet = state["bullet"]
439 params = [NumberDef(param) for param in state.get("params", [])]
440 self.__init__(bullet, ParamList(params))
441
442 @classmethod
443 def FromXML(cls, doc, element):
444 """Construct using an ElementTree-style element."""
445 bullet = cls(element.get("label"), ParamList.FromXML(doc, element))
446 doc._bullet_refs.append(bullet)
447 return bullet
448
449 def __call__(self, params, rank):
450 return self.bullet(self.params(params, rank), rank)
451
452 def __repr__(self):
453 return "%s(params=%r, bullet=%r)" % (
454 type(self).__name__, self.params, self.bullet)
455
456 class ActionDef(object):
457 """Action definition.
458
459 To support parsing new actions, add tags to
460 ActionDef.CONSTRUCTORS. It maps tag names to classes with a
461 FromXML classmethod, which take the BulletML instance and
462 ElementTree element as arguments.
463 """
464
465 # This is self-referential, so it's filled in later.
466 CONSTRUCTORS = dict()
467
468 def __init__(self, actions):
469 self.actions = list(actions)
470
471 def __getstate__(self):
472 return dict(actions=self.actions)
473
474 def __setstate__(self, state):
475 state = dict(state)
476 self.__init__(state["actions"])
477
478 @classmethod
479 def FromXML(cls, doc, element):
480 """Construct using an ElementTree-style element."""
481 actions = []
482 for subelem in element.getchildren():
483 tag = realtag(subelem)
484 try:
485 ctr = cls.CONSTRUCTORS[tag]
486 except KeyError:
487 continue
488 else:
489 actions.append(ctr.FromXML(doc, subelem))
490 dfn = cls(actions)
491 doc._actions[element.get("label")] = dfn
492 return dfn
493
494 def __call__(self, params, rank):
495 return self.actions, params
496
497 def __repr__(self):
498 return "%s(%r)" % (type(self).__name__, self.actions)
499
500 class ActionRef(object):
501 """Run an action by name with parameters."""
502
503 def __init__(self, action, params=None):
504 self.action = action
505 self.params = params or ParamList()
506
507 def __getstate__(self):
508 state = []
509 if self.params.params:
510 params = [param.expr for param in self.params.params]
511 state.append(("params", params))
512 state.append(('action', self.action))
513 return state
514
515 def __setstate__(self, state):
516 state = dict(state)
517 action = state["action"]
518 params = [NumberDef(param) for param in state.get("params", [])]
519 self.__init__(action, ParamList(params))
520
521 @classmethod
522 def FromXML(cls, doc, element):
523 """Construct using an ElementTree-style element."""
524 action = cls(element.get("label"), ParamList.FromXML(doc, element))
525 doc._action_refs.append(action)
526 return action
527
528 def __call__(self, params, rank):
529 return self.action(self.params(params, rank), rank)
530
531 def __repr__(self):
532 return "%s(params=%r, action=%r)" % (
533 type(self).__name__, self.params, self.action)
534
535 class Offset(object):
536 """Provide an offset to a bullet's initial position."""
537
538 VALID_TYPES = ["relative", "absolute"]
539
540 def __init__(self, type, x, y):
541 if type not in self.VALID_TYPES:
542 raise ValueError("invalid type %r" % type)
543 self.type = intern(type)
544 self.x = x
545 self.y = y
546
547 def __getstate__(self):
548 state = [('type', self.type)]
549 if self.x:
550 state.append(('x', self.x.expr))
551 if self.y:
552 state.append(('y', self.y.expr))
553 return state
554
555 def __setstate__(self, state):
556 state = dict(state)
557 x = NumberDef(state["x"]) if "x" in state else None
558 y = NumberDef(state["y"]) if "y" in state else None
559 self.__init__(state["type"], x, y)
560
561 @classmethod
562 def FromXML(cls, doc, element):
563 """Construct using an ElementTree-style element."""
564 type = element.get("type", "relative")
565 x = None
566 y = None
567 for subelem in element:
568 tag = realtag(subelem)
569 if tag == "x":
570 x = NumberDef(subelem.text)
571 elif tag == "y":
572 y = NumberDef(subelem.text)
573 return cls(type, x, y)
574
575 def __call__(self, params, rank):
576 return (self.x(params, rank) if self.x else 0,
577 self.y(params, rank) if self.y else 0)
578
579 class FireDef(object):
580 """Fire definition (creates a bullet)."""
581
582 def __init__(
583 self, bullet, direction=None, speed=None, offset=None, tags=()):
584 self.bullet = bullet
585 self.direction = direction
586 self.speed = speed
587 self.offset = offset
588 self.tags = set(tags)
589
590 def __getstate__(self):
591 state = []
592 if self.direction:
593 state.append(("direction", self.direction))
594 if self.speed:
595 state.append(("speed", self.speed))
596 if self.offset:
597 state.append(("offset", self.offset))
598 if self.tags:
599 state.append(("tags", list(self.tags)))
600 try:
601 params = self.bullet.params
602 except AttributeError:
603 state.append(('bullet', self.bullet))
604 else:
605 if params.params:
606 state.append(('bullet', self.bullet))
607 else:
608 # Strip out empty BulletRefs.
609 state.append(('bullet', self.bullet.bullet))
610 return state
611
612 def __setstate__(self, state):
613 state = dict(state)
614 self.__init__(**state)
615
616 @classmethod
617 def FromXML(cls, doc, element):
618 """Construct using an ElementTree-style element."""
619 direction = None
620 speed = None
621 offset = None
622 tags = set()
623
624 for subelem in element.getchildren():
625 tag = realtag(subelem)
626 if tag == "direction":
627 direction = Direction.FromXML(doc, subelem, "aim")
628 elif tag == "speed":
629 speed = Speed.FromXML(doc, subelem)
630 elif tag == "bullet":
631 bullet = BulletDef.FromXML(doc, subelem)
632 elif tag == "bulletRef":
633 bullet = BulletRef.FromXML(doc, subelem)
634 elif tag == "offset":
635 offset = Offset.FromXML(doc, subelem)
636 elif tag == "tag":
637 tags.add(subelem.text)
638 try:
639 fire = cls(bullet, direction, speed, offset, tags)
640 except UnboundLocalError as exc:
641 raise ParseError(str(exc))
642 else:
643 doc._fires[element.get("label")] = fire
644 return fire
645
646 def __call__(self, params, rank):
647 direction, speed, tags, actions = self.bullet(params, rank)
648 if self.direction:
649 direction = self.direction(params, rank)
650 if self.speed:
651 speed = self.speed(params, rank)
652 tags = tags.union(self.tags)
653 return direction, speed, self.offset, tags, actions
654
655 def __repr__(self):
656 return "%s(direction=%r, speed=%r, bullet=%r)" % (
657 type(self).__name__, self.direction, self.speed, self.bullet)
658
659 class FireRef(object):
660 """Fire a bullet by name with parameters."""
661
662 def __init__(self, fire, params=None):
663 self.fire = fire
664 self.params = params or ParamList()
665
666 def __getstate__(self):
667 state = []
668 if self.params.params:
669 params = [param.expr for param in self.params.params]
670 state.append(("params", params))
671 state.append(('fire', self.fire))
672 return state
673
674 def __setstate__(self, state):
675 state = dict(state)
676 fire = state["fire"]
677 params = [NumberDef(param) for param in state.get("params", [])]
678 self.__init__(fire, ParamList(params))
679
680 @classmethod
681 def FromXML(cls, doc, element):
682 """Construct using an ElementTree-style element."""
683 fired = cls(element.get("label"), ParamList.FromXML(doc, element))
684 doc._fire_refs.append(fired)
685 return fired
686
687 def __call__(self, params, rank):
688 return self.fire(self.params(params, rank), rank)
689
690 def __repr__(self):
691 return "%s(params=%r, fire=%r)" % (
692 type(self).__name__, self.params, self.fire)
693
694 class BulletML(object):
695 """BulletML document.
696
697 A BulletML document is a collection of top-level actions and the
698 base game type.
699
700 You can add tags to the BulletML.CONSTRUCTORS dictionary to extend
701 its parsing. It maps tag names to classes with a FromXML
702 classmethod, which take the BulletML instance and ElementTree
703 element as arguments.
704
705 """
706
707 CONSTRUCTORS = dict(
708 bullet=BulletDef,
709 action=ActionDef,
710 fire=FireDef,
711 )
712
713 def __init__(self, type="none", actions=None):
714 self.type = intern(type)
715 self.actions = [] if actions is None else actions
716
717 def __getstate__(self):
718 return [('type', self.type), ('actions', self.actions)]
719
720 def __setstate__(self, state):
721 state = dict(state)
722 self.__init__(state["type"], actions=state.get("actions"))
723
724 @classmethod
725 def FromXML(cls, source):
726 """Return a BulletML instance based on XML."""
727 if not hasattr(source, 'read'):
728 source = StringIO(source)
729
730 tree = ElementTree()
731 root = tree.parse(source)
732
733 doc = cls(type=root.get("type", "none"))
734
735 doc._bullets = {}
736 doc._actions = {}
737 doc._fires = {}
738 doc._bullet_refs = []
739 doc._action_refs = []
740 doc._fire_refs = []
741
742 for element in root.getchildren():
743 tag = realtag(element)
744 if tag in doc.CONSTRUCTORS:
745 doc.CONSTRUCTORS[tag].FromXML(doc, element)
746
747 try:
748 for ref in doc._bullet_refs:
749 ref.bullet = doc._bullets[ref.bullet]
750 for ref in doc._fire_refs:
751 ref.fire = doc._fires[ref.fire]
752 for ref in doc._action_refs:
753 ref.action = doc._actions[ref.action]
754 except KeyError as exc:
755 raise ParseError("unknown reference %s" % exc)
756
757 doc.actions = [act for name, act in doc._actions.items()
758 if name and name.startswith("top")]
759
760 del(doc._bullet_refs)
761 del(doc._action_refs)
762 del(doc._fire_refs)
763 del(doc._bullets)
764 del(doc._actions)
765 del(doc._fires)
766
767 return doc
768
769 @classmethod
770 def FromYAML(cls, source):
771 """Create a BulletML instance based on YAML."""
772
773 # Late import to avoid a circular dependency.
774 try:
775 import bulletml.bulletyaml
776 import yaml
777 except ImportError:
778 raise ParseError("PyYAML is not available")
779 else:
780 try:
781 return yaml.load(source)
782 except Exception as exc:
783 raise ParseError(str(exc))
784
785 @classmethod
786 def FromDocument(cls, source):
787 """Create a BulletML instance based on a seekable file or string.
788
789 This attempts to autodetect if the stream is XML or YAML.
790 """
791 if not hasattr(source, 'read'):
792 source = StringIO(source)
793 start = source.read(1)
794 source.seek(0)
795 if start == "<":
796 return cls.FromXML(source)
797 elif start == "!" or start == "#":
798 return cls.FromYAML(source)
799 else:
800 raise ParseError("unknown initial character %r" % start)
801
802 def __repr__(self):
803 return "%s(type=%r, actions=%r)" % (
804 type(self).__name__, self.type, self.actions)
805
806 ActionDef.CONSTRUCTORS = dict(
807 repeat=Repeat,
808 fire=FireDef,
809 fireRef=FireRef,
810 changeSpeed=ChangeSpeed,
811 changeDirection=ChangeDirection,
812 accel=Accel,
813 wait=Wait,
814 vanish=Vanish,
815 tag=Tag,
816 untag=Untag,
817 action=ActionDef,
818 actionRef=ActionRef)