1# Argument Clinic
2# Copyright 2012-2013 by Larry Hastings.
3# Licensed to the PSF under a contributor agreement.
4
5from test import support, test_tools
6from unittest import TestCase
7import collections
8import inspect
9import os.path
10import sys
11import unittest
12
13test_tools.skip_if_missing('clinic')
14with test_tools.imports_under_tool('clinic'):
15    import clinic
16    from clinic import DSLParser
17
18
19class FakeConverter:
20    def __init__(self, name, args):
21        self.name = name
22        self.args = args
23
24
25class FakeConverterFactory:
26    def __init__(self, name):
27        self.name = name
28
29    def __call__(self, name, default, **kwargs):
30        return FakeConverter(self.name, kwargs)
31
32
33class FakeConvertersDict:
34    def __init__(self):
35        self.used_converters = {}
36
37    def get(self, name, default):
38        return self.used_converters.setdefault(name, FakeConverterFactory(name))
39
40c = clinic.Clinic(language='C', filename = "file")
41
42class FakeClinic:
43    def __init__(self):
44        self.converters = FakeConvertersDict()
45        self.legacy_converters = FakeConvertersDict()
46        self.language = clinic.CLanguage(None)
47        self.filename = None
48        self.destination_buffers = {}
49        self.block_parser = clinic.BlockParser('', self.language)
50        self.modules = collections.OrderedDict()
51        self.classes = collections.OrderedDict()
52        clinic.clinic = self
53        self.name = "FakeClinic"
54        self.line_prefix = self.line_suffix = ''
55        self.destinations = {}
56        self.add_destination("block", "buffer")
57        self.add_destination("file", "buffer")
58        self.add_destination("suppress", "suppress")
59        d = self.destinations.get
60        self.field_destinations = collections.OrderedDict((
61            ('docstring_prototype', d('suppress')),
62            ('docstring_definition', d('block')),
63            ('methoddef_define', d('block')),
64            ('impl_prototype', d('block')),
65            ('parser_prototype', d('suppress')),
66            ('parser_definition', d('block')),
67            ('impl_definition', d('block')),
68        ))
69
70    def get_destination(self, name):
71        d = self.destinations.get(name)
72        if not d:
73            sys.exit("Destination does not exist: " + repr(name))
74        return d
75
76    def add_destination(self, name, type, *args):
77        if name in self.destinations:
78            sys.exit("Destination already exists: " + repr(name))
79        self.destinations[name] = clinic.Destination(name, type, self, *args)
80
81    def is_directive(self, name):
82        return name == "module"
83
84    def directive(self, name, args):
85        self.called_directives[name] = args
86
87    _module_and_class = clinic.Clinic._module_and_class
88
89class ClinicWholeFileTest(TestCase):
90    def test_eol(self):
91        # regression test:
92        # clinic's block parser didn't recognize
93        # the "end line" for the block if it
94        # didn't end in "\n" (as in, the last)
95        # byte of the file was '/'.
96        # so it would spit out an end line for you.
97        # and since you really already had one,
98        # the last line of the block got corrupted.
99        c = clinic.Clinic(clinic.CLanguage(None), filename="file")
100        raw = "/*[clinic]\nfoo\n[clinic]*/"
101        cooked = c.parse(raw).splitlines()
102        end_line = cooked[2].rstrip()
103        # this test is redundant, it's just here explicitly to catch
104        # the regression test so we don't forget what it looked like
105        self.assertNotEqual(end_line, "[clinic]*/[clinic]*/")
106        self.assertEqual(end_line, "[clinic]*/")
107
108
109
110class ClinicGroupPermuterTest(TestCase):
111    def _test(self, l, m, r, output):
112        computed = clinic.permute_optional_groups(l, m, r)
113        self.assertEqual(output, computed)
114
115    def test_range(self):
116        self._test([['start']], ['stop'], [['step']],
117          (
118            ('stop',),
119            ('start', 'stop',),
120            ('start', 'stop', 'step',),
121          ))
122
123    def test_add_window(self):
124        self._test([['x', 'y']], ['ch'], [['attr']],
125          (
126            ('ch',),
127            ('ch', 'attr'),
128            ('x', 'y', 'ch',),
129            ('x', 'y', 'ch', 'attr'),
130          ))
131
132    def test_ludicrous(self):
133        self._test([['a1', 'a2', 'a3'], ['b1', 'b2']], ['c1'], [['d1', 'd2'], ['e1', 'e2', 'e3']],
134          (
135          ('c1',),
136          ('b1', 'b2', 'c1'),
137          ('b1', 'b2', 'c1', 'd1', 'd2'),
138          ('a1', 'a2', 'a3', 'b1', 'b2', 'c1'),
139          ('a1', 'a2', 'a3', 'b1', 'b2', 'c1', 'd1', 'd2'),
140          ('a1', 'a2', 'a3', 'b1', 'b2', 'c1', 'd1', 'd2', 'e1', 'e2', 'e3'),
141          ))
142
143    def test_right_only(self):
144        self._test([], [], [['a'],['b'],['c']],
145          (
146          (),
147          ('a',),
148          ('a', 'b'),
149          ('a', 'b', 'c')
150          ))
151
152    def test_have_left_options_but_required_is_empty(self):
153        def fn():
154            clinic.permute_optional_groups(['a'], [], [])
155        self.assertRaises(AssertionError, fn)
156
157
158class ClinicLinearFormatTest(TestCase):
159    def _test(self, input, output, **kwargs):
160        computed = clinic.linear_format(input, **kwargs)
161        self.assertEqual(output, computed)
162
163    def test_empty_strings(self):
164        self._test('', '')
165
166    def test_solo_newline(self):
167        self._test('\n', '\n')
168
169    def test_no_substitution(self):
170        self._test("""
171          abc
172          """, """
173          abc
174          """)
175
176    def test_empty_substitution(self):
177        self._test("""
178          abc
179          {name}
180          def
181          """, """
182          abc
183          def
184          """, name='')
185
186    def test_single_line_substitution(self):
187        self._test("""
188          abc
189          {name}
190          def
191          """, """
192          abc
193          GARGLE
194          def
195          """, name='GARGLE')
196
197    def test_multiline_substitution(self):
198        self._test("""
199          abc
200          {name}
201          def
202          """, """
203          abc
204          bingle
205          bungle
206
207          def
208          """, name='bingle\nbungle\n')
209
210class InertParser:
211    def __init__(self, clinic):
212        pass
213
214    def parse(self, block):
215        pass
216
217class CopyParser:
218    def __init__(self, clinic):
219        pass
220
221    def parse(self, block):
222        block.output = block.input
223
224
225class ClinicBlockParserTest(TestCase):
226    def _test(self, input, output):
227        language = clinic.CLanguage(None)
228
229        blocks = list(clinic.BlockParser(input, language))
230        writer = clinic.BlockPrinter(language)
231        for block in blocks:
232            writer.print_block(block)
233        output = writer.f.getvalue()
234        assert output == input, "output != input!\n\noutput " + repr(output) + "\n\n input " + repr(input)
235
236    def round_trip(self, input):
237        return self._test(input, input)
238
239    def test_round_trip_1(self):
240        self.round_trip("""
241    verbatim text here
242    lah dee dah
243""")
244    def test_round_trip_2(self):
245        self.round_trip("""
246    verbatim text here
247    lah dee dah
248/*[inert]
249abc
250[inert]*/
251def
252/*[inert checksum: 7b18d017f89f61cf17d47f92749ea6930a3f1deb]*/
253xyz
254""")
255
256    def _test_clinic(self, input, output):
257        language = clinic.CLanguage(None)
258        c = clinic.Clinic(language, filename="file")
259        c.parsers['inert'] = InertParser(c)
260        c.parsers['copy'] = CopyParser(c)
261        computed = c.parse(input)
262        self.assertEqual(output, computed)
263
264    def test_clinic_1(self):
265        self._test_clinic("""
266    verbatim text here
267    lah dee dah
268/*[copy input]
269def
270[copy start generated code]*/
271abc
272/*[copy end generated code: output=03cfd743661f0797 input=7b18d017f89f61cf]*/
273xyz
274""", """
275    verbatim text here
276    lah dee dah
277/*[copy input]
278def
279[copy start generated code]*/
280def
281/*[copy end generated code: output=7b18d017f89f61cf input=7b18d017f89f61cf]*/
282xyz
283""")
284
285
286class ClinicParserTest(TestCase):
287    def test_trivial(self):
288        parser = DSLParser(FakeClinic())
289        block = clinic.Block("module os\nos.access")
290        parser.parse(block)
291        module, function = block.signatures
292        self.assertEqual("access", function.name)
293        self.assertEqual("os", module.name)
294
295    def test_ignore_line(self):
296        block = self.parse("#\nmodule os\nos.access")
297        module, function = block.signatures
298        self.assertEqual("access", function.name)
299        self.assertEqual("os", module.name)
300
301    def test_param(self):
302        function = self.parse_function("module os\nos.access\n   path: int")
303        self.assertEqual("access", function.name)
304        self.assertEqual(2, len(function.parameters))
305        p = function.parameters['path']
306        self.assertEqual('path', p.name)
307        self.assertIsInstance(p.converter, clinic.int_converter)
308
309    def test_param_default(self):
310        function = self.parse_function("module os\nos.access\n    follow_symlinks: bool = True")
311        p = function.parameters['follow_symlinks']
312        self.assertEqual(True, p.default)
313
314    def test_param_with_continuations(self):
315        function = self.parse_function("module os\nos.access\n    follow_symlinks: \\\n   bool \\\n   =\\\n    True")
316        p = function.parameters['follow_symlinks']
317        self.assertEqual(True, p.default)
318
319    def test_param_default_expression(self):
320        function = self.parse_function("module os\nos.access\n    follow_symlinks: int(c_default='MAXSIZE') = sys.maxsize")
321        p = function.parameters['follow_symlinks']
322        self.assertEqual(sys.maxsize, p.default)
323        self.assertEqual("MAXSIZE", p.converter.c_default)
324
325        s = self.parse_function_should_fail("module os\nos.access\n    follow_symlinks: int = sys.maxsize")
326        self.assertEqual(s, "Error on line 0:\nWhen you specify a named constant ('sys.maxsize') as your default value,\nyou MUST specify a valid c_default.\n")
327
328    def test_param_no_docstring(self):
329        function = self.parse_function("""
330module os
331os.access
332    follow_symlinks: bool = True
333    something_else: str = ''""")
334        p = function.parameters['follow_symlinks']
335        self.assertEqual(3, len(function.parameters))
336        self.assertIsInstance(function.parameters['something_else'].converter, clinic.str_converter)
337
338    def test_param_default_parameters_out_of_order(self):
339        s = self.parse_function_should_fail("""
340module os
341os.access
342    follow_symlinks: bool = True
343    something_else: str""")
344        self.assertEqual(s, """Error on line 0:
345Can't have a parameter without a default ('something_else')
346after a parameter with a default!
347""")
348
349    def disabled_test_converter_arguments(self):
350        function = self.parse_function("module os\nos.access\n    path: path_t(allow_fd=1)")
351        p = function.parameters['path']
352        self.assertEqual(1, p.converter.args['allow_fd'])
353
354    def test_function_docstring(self):
355        function = self.parse_function("""
356module os
357os.stat as os_stat_fn
358
359   path: str
360       Path to be examined
361
362Perform a stat system call on the given path.""")
363        self.assertEqual("""
364stat($module, /, path)
365--
366
367Perform a stat system call on the given path.
368
369  path
370    Path to be examined
371""".strip(), function.docstring)
372
373    def test_explicit_parameters_in_docstring(self):
374        function = self.parse_function("""
375module foo
376foo.bar
377  x: int
378     Documentation for x.
379  y: int
380
381This is the documentation for foo.
382
383Okay, we're done here.
384""")
385        self.assertEqual("""
386bar($module, /, x, y)
387--
388
389This is the documentation for foo.
390
391  x
392    Documentation for x.
393
394Okay, we're done here.
395""".strip(), function.docstring)
396
397    def test_parser_regression_special_character_in_parameter_column_of_docstring_first_line(self):
398        function = self.parse_function("""
399module os
400os.stat
401    path: str
402This/used to break Clinic!
403""")
404        self.assertEqual("stat($module, /, path)\n--\n\nThis/used to break Clinic!", function.docstring)
405
406    def test_c_name(self):
407        function = self.parse_function("module os\nos.stat as os_stat_fn")
408        self.assertEqual("os_stat_fn", function.c_basename)
409
410    def test_return_converter(self):
411        function = self.parse_function("module os\nos.stat -> int")
412        self.assertIsInstance(function.return_converter, clinic.int_return_converter)
413
414    def test_star(self):
415        function = self.parse_function("module os\nos.access\n    *\n    follow_symlinks: bool = True")
416        p = function.parameters['follow_symlinks']
417        self.assertEqual(inspect.Parameter.KEYWORD_ONLY, p.kind)
418        self.assertEqual(0, p.group)
419
420    def test_group(self):
421        function = self.parse_function("module window\nwindow.border\n [\n ls : int\n ]\n /\n")
422        p = function.parameters['ls']
423        self.assertEqual(1, p.group)
424
425    def test_left_group(self):
426        function = self.parse_function("""
427module curses
428curses.addch
429   [
430   y: int
431     Y-coordinate.
432   x: int
433     X-coordinate.
434   ]
435   ch: char
436     Character to add.
437   [
438   attr: long
439     Attributes for the character.
440   ]
441   /
442""")
443        for name, group in (
444            ('y', -1), ('x', -1),
445            ('ch', 0),
446            ('attr', 1),
447            ):
448            p = function.parameters[name]
449            self.assertEqual(p.group, group)
450            self.assertEqual(p.kind, inspect.Parameter.POSITIONAL_ONLY)
451        self.assertEqual(function.docstring.strip(), """
452addch([y, x,] ch, [attr])
453
454
455  y
456    Y-coordinate.
457  x
458    X-coordinate.
459  ch
460    Character to add.
461  attr
462    Attributes for the character.
463            """.strip())
464
465    def test_nested_groups(self):
466        function = self.parse_function("""
467module curses
468curses.imaginary
469   [
470   [
471   y1: int
472     Y-coordinate.
473   y2: int
474     Y-coordinate.
475   ]
476   x1: int
477     X-coordinate.
478   x2: int
479     X-coordinate.
480   ]
481   ch: char
482     Character to add.
483   [
484   attr1: long
485     Attributes for the character.
486   attr2: long
487     Attributes for the character.
488   attr3: long
489     Attributes for the character.
490   [
491   attr4: long
492     Attributes for the character.
493   attr5: long
494     Attributes for the character.
495   attr6: long
496     Attributes for the character.
497   ]
498   ]
499   /
500""")
501        for name, group in (
502            ('y1', -2), ('y2', -2),
503            ('x1', -1), ('x2', -1),
504            ('ch', 0),
505            ('attr1', 1), ('attr2', 1), ('attr3', 1),
506            ('attr4', 2), ('attr5', 2), ('attr6', 2),
507            ):
508            p = function.parameters[name]
509            self.assertEqual(p.group, group)
510            self.assertEqual(p.kind, inspect.Parameter.POSITIONAL_ONLY)
511
512        self.assertEqual(function.docstring.strip(), """
513imaginary([[y1, y2,] x1, x2,] ch, [attr1, attr2, attr3, [attr4, attr5,
514          attr6]])
515
516
517  y1
518    Y-coordinate.
519  y2
520    Y-coordinate.
521  x1
522    X-coordinate.
523  x2
524    X-coordinate.
525  ch
526    Character to add.
527  attr1
528    Attributes for the character.
529  attr2
530    Attributes for the character.
531  attr3
532    Attributes for the character.
533  attr4
534    Attributes for the character.
535  attr5
536    Attributes for the character.
537  attr6
538    Attributes for the character.
539                """.strip())
540
541    def parse_function_should_fail(self, s):
542        with support.captured_stdout() as stdout:
543            with self.assertRaises(SystemExit):
544                self.parse_function(s)
545        return stdout.getvalue()
546
547    def test_disallowed_grouping__two_top_groups_on_left(self):
548        s = self.parse_function_should_fail("""
549module foo
550foo.two_top_groups_on_left
551    [
552    group1 : int
553    ]
554    [
555    group2 : int
556    ]
557    param: int
558            """)
559        self.assertEqual(s,
560            ('Error on line 0:\n'
561            'Function two_top_groups_on_left has an unsupported group configuration. (Unexpected state 2.b)\n'))
562
563    def test_disallowed_grouping__two_top_groups_on_right(self):
564        self.parse_function_should_fail("""
565module foo
566foo.two_top_groups_on_right
567    param: int
568    [
569    group1 : int
570    ]
571    [
572    group2 : int
573    ]
574            """)
575
576    def test_disallowed_grouping__parameter_after_group_on_right(self):
577        self.parse_function_should_fail("""
578module foo
579foo.parameter_after_group_on_right
580    param: int
581    [
582    [
583    group1 : int
584    ]
585    group2 : int
586    ]
587            """)
588
589    def test_disallowed_grouping__group_after_parameter_on_left(self):
590        self.parse_function_should_fail("""
591module foo
592foo.group_after_parameter_on_left
593    [
594    group2 : int
595    [
596    group1 : int
597    ]
598    ]
599    param: int
600            """)
601
602    def test_disallowed_grouping__empty_group_on_left(self):
603        self.parse_function_should_fail("""
604module foo
605foo.empty_group
606    [
607    [
608    ]
609    group2 : int
610    ]
611    param: int
612            """)
613
614    def test_disallowed_grouping__empty_group_on_right(self):
615        self.parse_function_should_fail("""
616module foo
617foo.empty_group
618    param: int
619    [
620    [
621    ]
622    group2 : int
623    ]
624            """)
625
626    def test_no_parameters(self):
627        function = self.parse_function("""
628module foo
629foo.bar
630
631Docstring
632
633""")
634        self.assertEqual("bar($module, /)\n--\n\nDocstring", function.docstring)
635        self.assertEqual(1, len(function.parameters)) # self!
636
637    def test_init_with_no_parameters(self):
638        function = self.parse_function("""
639module foo
640class foo.Bar "unused" "notneeded"
641foo.Bar.__init__
642
643Docstring
644
645""", signatures_in_block=3, function_index=2)
646        # self is not in the signature
647        self.assertEqual("Bar()\n--\n\nDocstring", function.docstring)
648        # but it *is* a parameter
649        self.assertEqual(1, len(function.parameters))
650
651    def test_illegal_module_line(self):
652        self.parse_function_should_fail("""
653module foo
654foo.bar => int
655    /
656""")
657
658    def test_illegal_c_basename(self):
659        self.parse_function_should_fail("""
660module foo
661foo.bar as 935
662    /
663""")
664
665    def test_single_star(self):
666        self.parse_function_should_fail("""
667module foo
668foo.bar
669    *
670    *
671""")
672
673    def test_parameters_required_after_star_without_initial_parameters_or_docstring(self):
674        self.parse_function_should_fail("""
675module foo
676foo.bar
677    *
678""")
679
680    def test_parameters_required_after_star_without_initial_parameters_with_docstring(self):
681        self.parse_function_should_fail("""
682module foo
683foo.bar
684    *
685Docstring here.
686""")
687
688    def test_parameters_required_after_star_with_initial_parameters_without_docstring(self):
689        self.parse_function_should_fail("""
690module foo
691foo.bar
692    this: int
693    *
694""")
695
696    def test_parameters_required_after_star_with_initial_parameters_and_docstring(self):
697        self.parse_function_should_fail("""
698module foo
699foo.bar
700    this: int
701    *
702Docstring.
703""")
704
705    def test_single_slash(self):
706        self.parse_function_should_fail("""
707module foo
708foo.bar
709    /
710    /
711""")
712
713    def test_mix_star_and_slash(self):
714        self.parse_function_should_fail("""
715module foo
716foo.bar
717   x: int
718   y: int
719   *
720   z: int
721   /
722""")
723
724    def test_parameters_not_permitted_after_slash_for_now(self):
725        self.parse_function_should_fail("""
726module foo
727foo.bar
728    /
729    x: int
730""")
731
732    def test_function_not_at_column_0(self):
733        function = self.parse_function("""
734  module foo
735  foo.bar
736    x: int
737      Nested docstring here, goeth.
738    *
739    y: str
740  Not at column 0!
741""")
742        self.assertEqual("""
743bar($module, /, x, *, y)
744--
745
746Not at column 0!
747
748  x
749    Nested docstring here, goeth.
750""".strip(), function.docstring)
751
752    def test_directive(self):
753        c = FakeClinic()
754        parser = DSLParser(c)
755        parser.flag = False
756        parser.directives['setflag'] = lambda : setattr(parser, 'flag', True)
757        block = clinic.Block("setflag")
758        parser.parse(block)
759        self.assertTrue(parser.flag)
760
761    def test_legacy_converters(self):
762        block = self.parse('module os\nos.access\n   path: "s"')
763        module, function = block.signatures
764        self.assertIsInstance((function.parameters['path']).converter, clinic.str_converter)
765
766    def parse(self, text):
767        c = FakeClinic()
768        parser = DSLParser(c)
769        block = clinic.Block(text)
770        parser.parse(block)
771        return block
772
773    def parse_function(self, text, signatures_in_block=2, function_index=1):
774        block = self.parse(text)
775        s = block.signatures
776        self.assertEqual(len(s), signatures_in_block)
777        assert isinstance(s[0], clinic.Module)
778        assert isinstance(s[function_index], clinic.Function)
779        return s[function_index]
780
781    def test_scaffolding(self):
782        # test repr on special values
783        self.assertEqual(repr(clinic.unspecified), '<Unspecified>')
784        self.assertEqual(repr(clinic.NULL), '<Null>')
785
786        # test that fail fails
787        with support.captured_stdout() as stdout:
788            with self.assertRaises(SystemExit):
789                clinic.fail('The igloos are melting!', filename='clown.txt', line_number=69)
790        self.assertEqual(stdout.getvalue(), 'Error in file "clown.txt" on line 69:\nThe igloos are melting!\n')
791
792
793class ClinicExternalTest(TestCase):
794    maxDiff = None
795
796    def test_external(self):
797        # bpo-42398: Test that the destination file is left unchanged if the
798        # content does not change. Moreover, check also that the file
799        # modification time does not change in this case.
800        source = support.findfile('clinic.test')
801        with open(source, 'r', encoding='utf-8') as f:
802            orig_contents = f.read()
803
804        with support.temp_dir() as tmp_dir:
805            testfile = os.path.join(tmp_dir, 'clinic.test.c')
806            with open(testfile, 'w', encoding='utf-8') as f:
807                f.write(orig_contents)
808            old_mtime_ns = os.stat(testfile).st_mtime_ns
809
810            clinic.parse_file(testfile)
811
812            with open(testfile, 'r', encoding='utf-8') as f:
813                new_contents = f.read()
814            new_mtime_ns = os.stat(testfile).st_mtime_ns
815
816        self.assertEqual(new_contents, orig_contents)
817        # Don't change the file modification time
818        # if the content does not change
819        self.assertEqual(new_mtime_ns, old_mtime_ns)
820
821
822if __name__ == "__main__":
823    unittest.main()
824