pytest-dsl 0.3.1__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@ from pytest_dsl.core.context import TestContext
11
11
  import pytest_dsl.keywords
12
12
  from pytest_dsl.core.yaml_vars import yaml_vars
13
13
  from pytest_dsl.core.variable_utils import VariableReplacer
14
+ from pytest_dsl.remote.keyword_client import remote_keyword_manager
14
15
 
15
16
 
16
17
  class DSLExecutor:
@@ -27,7 +28,7 @@ class DSLExecutor:
27
28
  self.test_context.executor = self # 让 test_context 能够访问到 executor
28
29
  self.variable_replacer = VariableReplacer(self.variables, self.test_context)
29
30
  self.imported_files = set() # 跟踪已导入的文件,避免循环导入
30
-
31
+
31
32
  def set_current_data(self, data):
32
33
  """设置当前测试数据集"""
33
34
  if data:
@@ -35,30 +36,30 @@ class DSLExecutor:
35
36
  # 同时将数据添加到测试上下文
36
37
  for key, value in data.items():
37
38
  self.test_context.set(key, value)
38
-
39
+
39
40
  def _load_test_data(self, data_source):
40
41
  """加载测试数据
41
-
42
+
42
43
  :param data_source: 数据源配置,包含 file 和 format 字段
43
44
  :return: 包含测试数据的列表
44
45
  """
45
46
  if not data_source:
46
47
  return [{}] # 如果没有数据源,返回一个空的数据集
47
-
48
+
48
49
  file_path = data_source['file']
49
50
  format_type = data_source['format']
50
-
51
+
51
52
  if not os.path.exists(file_path):
52
53
  raise Exception(f"数据文件不存在: {file_path}")
53
-
54
+
54
55
  if format_type.lower() == 'csv':
55
56
  return self._load_csv_data(file_path)
56
57
  else:
57
58
  raise Exception(f"不支持的数据格式: {format_type}")
58
-
59
+
59
60
  def _load_csv_data(self, file_path):
60
61
  """加载CSV格式的测试数据
61
-
62
+
62
63
  :param file_path: CSV文件路径
63
64
  :return: 包含测试数据的列表
64
65
  """
@@ -68,11 +69,11 @@ class DSLExecutor:
68
69
  for row in reader:
69
70
  data_sets.append(row)
70
71
  return data_sets
71
-
72
+
72
73
  def eval_expression(self, expr_node):
73
74
  """
74
75
  对表达式节点进行求值,返回表达式的值。
75
-
76
+
76
77
  :param expr_node: AST中的表达式节点
77
78
  :return: 表达式求值后的结果
78
79
  :raises Exception: 当遇到未定义变量或无法求值的类型时抛出异常
@@ -101,7 +102,7 @@ class DSLExecutor:
101
102
  return self._eval_arithmetic_expr(expr_node)
102
103
  else:
103
104
  raise Exception(f"无法求值的表达式类型: {expr_node.type}")
104
-
105
+
105
106
  def _eval_expression_value(self, value):
106
107
  """处理表达式值的具体逻辑"""
107
108
  if isinstance(value, Node):
@@ -110,7 +111,7 @@ class DSLExecutor:
110
111
  # 如果是ID类型的变量名
111
112
  if value in self.variable_replacer.local_variables:
112
113
  return self.variable_replacer.local_variables[value]
113
-
114
+
114
115
  # 定义变量引用模式
115
116
  pattern = r'\$\{([a-zA-Z_\u4e00-\u9fa5][a-zA-Z0-9_\u4e00-\u9fa5]*)\}'
116
117
  # 检查整个字符串是否完全匹配单一变量引用模式
@@ -122,24 +123,24 @@ class DSLExecutor:
122
123
  # 如果不是单一变量,则替换字符串中的所有变量引用
123
124
  return self.variable_replacer.replace_in_string(value)
124
125
  return value
125
-
126
+
126
127
  def _eval_comparison_expr(self, expr_node):
127
128
  """
128
129
  对比较表达式进行求值
129
-
130
+
130
131
  :param expr_node: 比较表达式节点
131
132
  :return: 比较结果(布尔值)
132
133
  """
133
134
  left_value = self.eval_expression(expr_node.children[0])
134
135
  right_value = self.eval_expression(expr_node.children[1])
135
136
  operator = expr_node.value # 操作符: >, <, >=, <=, ==, !=
136
-
137
+
137
138
  # 尝试类型转换
138
139
  if isinstance(left_value, str) and str(left_value).isdigit():
139
140
  left_value = int(left_value)
140
141
  if isinstance(right_value, str) and str(right_value).isdigit():
141
142
  right_value = int(right_value)
142
-
143
+
143
144
  # 根据操作符执行相应的比较操作
144
145
  if operator == '>':
145
146
  return left_value > right_value
@@ -155,31 +156,31 @@ class DSLExecutor:
155
156
  return left_value != right_value
156
157
  else:
157
158
  raise Exception(f"未知的比较操作符: {operator}")
158
-
159
+
159
160
  def _eval_arithmetic_expr(self, expr_node):
160
161
  """
161
162
  对算术表达式进行求值
162
-
163
+
163
164
  :param expr_node: 算术表达式节点
164
165
  :return: 计算结果
165
166
  """
166
167
  left_value = self.eval_expression(expr_node.children[0])
167
168
  right_value = self.eval_expression(expr_node.children[1])
168
169
  operator = expr_node.value # 操作符: +, -, *, /
169
-
170
+
170
171
  # 尝试类型转换 - 如果是字符串数字则转为数字
171
172
  if isinstance(left_value, str) and str(left_value).replace('.', '', 1).isdigit():
172
173
  left_value = float(left_value)
173
174
  # 如果是整数则转为整数
174
175
  if left_value.is_integer():
175
176
  left_value = int(left_value)
176
-
177
+
177
178
  if isinstance(right_value, str) and str(right_value).replace('.', '', 1).isdigit():
178
179
  right_value = float(right_value)
179
180
  # 如果是整数则转为整数
180
181
  if right_value.is_integer():
181
182
  right_value = int(right_value)
182
-
183
+
183
184
  # 进行相应的算术运算
184
185
  if operator == '+':
185
186
  # 对于字符串,+是连接操作
@@ -202,18 +203,46 @@ class DSLExecutor:
202
203
  return left_value / right_value
203
204
  else:
204
205
  raise Exception(f"未知的算术操作符: {operator}")
205
-
206
+
206
207
  def _get_variable(self, var_name):
207
208
  """获取变量值,优先从本地变量获取,如果不存在则尝试从全局上下文获取"""
208
209
  return self.variable_replacer.get_variable(var_name)
209
-
210
+
210
211
  def _replace_variables_in_string(self, value):
211
212
  """替换字符串中的变量引用"""
212
213
  return self.variable_replacer.replace_in_string(value)
213
-
214
+
215
+ def _handle_remote_import(self, node):
216
+ """处理远程关键字导入
217
+
218
+ Args:
219
+ node: RemoteImport节点
220
+ """
221
+ remote_info = node.value
222
+ url = self._replace_variables_in_string(remote_info['url'])
223
+ alias = remote_info['alias']
224
+
225
+ print(f"正在连接远程关键字服务器: {url}, 别名: {alias}")
226
+
227
+ # 注册远程服务器
228
+ success = remote_keyword_manager.register_remote_server(url, alias)
229
+
230
+ if not success:
231
+ print(f"无法连接到远程关键字服务器: {url}")
232
+ raise Exception(f"无法连接到远程关键字服务器: {url}")
233
+
234
+ print(f"已成功连接到远程关键字服务器: {url}, 别名: {alias}")
235
+
236
+ allure.attach(
237
+ f"已连接到远程关键字服务器: {url}\n"
238
+ f"别名: {alias}",
239
+ name="远程关键字导入",
240
+ attachment_type=allure.attachment_type.TEXT
241
+ )
242
+
214
243
  def _handle_custom_keywords_in_file(self, node):
215
244
  """处理文件中的自定义关键字定义
216
-
245
+
217
246
  Args:
218
247
  node: Start节点
219
248
  """
@@ -233,7 +262,7 @@ class DSLExecutor:
233
262
  self.test_context.clear()
234
263
  metadata = {}
235
264
  teardown_node = None
236
-
265
+
237
266
  # 先处理元数据和找到teardown节点
238
267
  for child in node.children:
239
268
  if child.type == 'Metadata':
@@ -242,14 +271,17 @@ class DSLExecutor:
242
271
  # 处理导入指令
243
272
  if item.type == '@import':
244
273
  self._handle_import(item.value)
274
+ # 处理远程关键字导入
275
+ elif item.type == 'RemoteImport':
276
+ self._handle_remote_import(item)
245
277
  elif child.type == 'Teardown':
246
278
  teardown_node = child
247
-
279
+
248
280
  # 在_execute_test_iteration之前添加
249
281
  self._handle_custom_keywords_in_file(node)
250
282
  # 执行测试
251
283
  self._execute_test_iteration(metadata, node, teardown_node)
252
-
284
+
253
285
  except Exception as e:
254
286
  # 如果是断言错误,直接抛出
255
287
  if isinstance(e, AssertionError):
@@ -267,14 +299,14 @@ class DSLExecutor:
267
299
 
268
300
  def _handle_import(self, file_path):
269
301
  """处理导入指令
270
-
302
+
271
303
  Args:
272
304
  file_path: 资源文件路径
273
305
  """
274
306
  # 防止循环导入
275
307
  if file_path in self.imported_files:
276
308
  return
277
-
309
+
278
310
  try:
279
311
  # 导入自定义关键字文件
280
312
  from pytest_dsl.core.custom_keyword_manager import custom_keyword_manager
@@ -297,12 +329,12 @@ class DSLExecutor:
297
329
  if '@tags' in metadata:
298
330
  for tag in metadata['@tags']:
299
331
  allure.dynamic.tag(tag.value)
300
-
332
+
301
333
  # 执行所有非teardown节点
302
334
  for child in node.children:
303
335
  if child.type != 'Teardown' and child.type != 'Metadata':
304
336
  self.execute(child)
305
-
337
+
306
338
  # 执行teardown
307
339
  if teardown_node:
308
340
  with allure.step("执行清理操作"):
@@ -320,7 +352,7 @@ class DSLExecutor:
320
352
  # 否则清空变量(用于正常DSL执行)
321
353
  import os
322
354
  keep_variables = os.environ.get('PYTEST_DSL_KEEP_VARIABLES', '0') == '1'
323
-
355
+
324
356
  if not keep_variables:
325
357
  self.variables.clear()
326
358
  # 同时清空测试上下文
@@ -336,7 +368,7 @@ class DSLExecutor:
336
368
  """处理赋值语句"""
337
369
  var_name = node.value
338
370
  expr_value = self.eval_expression(node.children[0])
339
-
371
+
340
372
  # 检查变量名是否以g_开头,如果是则设置为全局变量
341
373
  if var_name.startswith('g_'):
342
374
  global_context.set_variable(var_name, expr_value)
@@ -361,7 +393,7 @@ class DSLExecutor:
361
393
  var_name = node.value
362
394
  keyword_call_node = node.children[0]
363
395
  result = self.execute(keyword_call_node)
364
-
396
+
365
397
  if result is not None:
366
398
  # 检查变量名是否以g_开头,如果是则设置为全局变量
367
399
  if var_name.startswith('g_'):
@@ -389,7 +421,7 @@ class DSLExecutor:
389
421
  var_name = node.value
390
422
  start = self.eval_expression(node.children[0])
391
423
  end = self.eval_expression(node.children[1])
392
-
424
+
393
425
  for i in range(int(start), int(end)):
394
426
  # 存储在本地变量字典和测试上下文中
395
427
  self.variable_replacer.local_variables[var_name] = i
@@ -403,9 +435,9 @@ class DSLExecutor:
403
435
  keyword_info = keyword_manager.get_keyword_info(keyword_name)
404
436
  if not keyword_info:
405
437
  raise Exception(f"未注册的关键字: {keyword_name}")
406
-
438
+
407
439
  kwargs = self._prepare_keyword_params(node, keyword_info)
408
-
440
+
409
441
  try:
410
442
  # 由于KeywordManager中的wrapper已经添加了allure.step和日志,这里不再重复添加
411
443
  result = keyword_manager.execute(keyword_name, **kwargs)
@@ -418,7 +450,7 @@ class DSLExecutor:
418
450
  """准备关键字调用参数"""
419
451
  mapping = keyword_info.get('mapping', {})
420
452
  kwargs = {'context': self.test_context} # 默认传入context参数
421
-
453
+
422
454
  # 检查是否有参数列表
423
455
  if node.children[0]:
424
456
  for param in node.children[0]:
@@ -427,7 +459,7 @@ class DSLExecutor:
427
459
  # 对参数值进行变量替换
428
460
  param_value = self.eval_expression(param.children[0])
429
461
  kwargs[english_param_name] = param_value
430
-
462
+
431
463
  return kwargs
432
464
 
433
465
  @allure.step("执行清理操作")
@@ -438,10 +470,10 @@ class DSLExecutor:
438
470
  @allure.step("执行返回语句")
439
471
  def _handle_return(self, node):
440
472
  """处理return语句
441
-
473
+
442
474
  Args:
443
475
  node: Return节点
444
-
476
+
445
477
  Returns:
446
478
  表达式求值结果
447
479
  """
@@ -451,12 +483,12 @@ class DSLExecutor:
451
483
  @allure.step("执行条件语句")
452
484
  def _handle_if_statement(self, node):
453
485
  """处理if-else语句
454
-
486
+
455
487
  Args:
456
488
  node: IfStatement节点,包含条件表达式、if分支和可选的else分支
457
489
  """
458
490
  condition = self.eval_expression(node.children[0])
459
-
491
+
460
492
  # 将条件转换为布尔值进行评估
461
493
  if condition:
462
494
  # 执行if分支
@@ -466,10 +498,88 @@ class DSLExecutor:
466
498
  # 如果存在else分支且条件为假,则执行else分支
467
499
  with allure.step("执行else分支"):
468
500
  return self.execute(node.children[2])
469
-
501
+
470
502
  # 如果条件为假且没有else分支,则不执行任何操作
471
503
  return None
472
504
 
505
+ def _execute_remote_keyword_call(self, node):
506
+ """执行远程关键字调用
507
+
508
+ Args:
509
+ node: RemoteKeywordCall节点
510
+
511
+ Returns:
512
+ 执行结果
513
+ """
514
+ call_info = node.value
515
+ alias = call_info['alias']
516
+ keyword_name = call_info['keyword']
517
+
518
+ # 准备参数
519
+ params = []
520
+ if node.children and node.children[0]:
521
+ params = node.children[0]
522
+
523
+ kwargs = {}
524
+ for param in params:
525
+ param_name = param.value
526
+ param_value = self.eval_expression(param.children[0])
527
+ kwargs[param_name] = param_value
528
+
529
+ # 添加测试上下文
530
+ kwargs['context'] = self.test_context
531
+
532
+ with allure.step(f"执行远程关键字: {alias}|{keyword_name}"):
533
+ try:
534
+ # 执行远程关键字
535
+ result = remote_keyword_manager.execute_remote_keyword(alias, keyword_name, **kwargs)
536
+ allure.attach(
537
+ f"远程关键字参数: {kwargs}\n"
538
+ f"远程关键字结果: {result}",
539
+ name="远程关键字执行详情",
540
+ attachment_type=allure.attachment_type.TEXT
541
+ )
542
+ return result
543
+ except Exception as e:
544
+ # 记录错误并重新抛出
545
+ allure.attach(
546
+ f"远程关键字执行失败: {str(e)}",
547
+ name="远程关键字错误",
548
+ attachment_type=allure.attachment_type.TEXT
549
+ )
550
+ raise
551
+
552
+ def _handle_assignment_remote_keyword_call(self, node):
553
+ """处理远程关键字调用赋值
554
+
555
+ Args:
556
+ node: AssignmentRemoteKeywordCall节点
557
+ """
558
+ var_name = node.value
559
+ remote_keyword_call_node = node.children[0]
560
+ result = self.execute(remote_keyword_call_node)
561
+
562
+ if result is not None:
563
+ # 检查变量名是否以g_开头,如果是则设置为全局变量
564
+ if var_name.startswith('g_'):
565
+ global_context.set_variable(var_name, result)
566
+ allure.attach(
567
+ f"全局变量: {var_name}\n值: {result}",
568
+ name="全局变量赋值",
569
+ attachment_type=allure.attachment_type.TEXT
570
+ )
571
+ else:
572
+ # 存储在本地变量字典和测试上下文中
573
+ self.variable_replacer.local_variables[var_name] = result
574
+ self.test_context.set(var_name, result) # 同时添加到测试上下文
575
+ allure.attach(
576
+ f"变量: {var_name}\n值: {result}",
577
+ name="赋值详情",
578
+ attachment_type=allure.attachment_type.TEXT
579
+ )
580
+ else:
581
+ raise Exception(f"远程关键字没有返回结果")
582
+
473
583
  def execute(self, node):
474
584
  """执行AST节点"""
475
585
  handlers = {
@@ -483,9 +593,12 @@ class DSLExecutor:
483
593
  'Teardown': self._handle_teardown,
484
594
  'Return': self._handle_return,
485
595
  'IfStatement': self._handle_if_statement,
486
- 'CustomKeyword': lambda _: None # 添加对CustomKeyword节点的处理,只需注册不需执行
596
+ 'CustomKeyword': lambda _: None, # 添加对CustomKeyword节点的处理,只需注册不需执行
597
+ 'RemoteImport': self._handle_remote_import,
598
+ 'RemoteKeywordCall': self._execute_remote_keyword_call,
599
+ 'AssignmentRemoteKeywordCall': self._handle_assignment_remote_keyword_call
487
600
  }
488
-
601
+
489
602
  handler = handlers.get(node.type)
490
603
  if handler:
491
604
  return handler(node)
@@ -494,4 +607,4 @@ class DSLExecutor:
494
607
  def read_file(filename):
495
608
  """读取 DSL 文件内容"""
496
609
  with open(filename, 'r', encoding='utf-8') as f:
497
- return f.read()
610
+ return f.read()
pytest_dsl/core/lexer.py CHANGED
@@ -12,7 +12,8 @@ reserved = {
12
12
  'False': 'FALSE', # 添加布尔值支持
13
13
  'return': 'RETURN', # 添加return关键字支持
14
14
  'else': 'ELSE', # 添加else关键字支持
15
- 'if': 'IF' # 添加if关键字支持
15
+ 'if': 'IF', # 添加if关键字支持
16
+ 'as': 'AS' # 添加as关键字支持,用于远程关键字别名
16
17
  }
17
18
 
18
19
  # token 名称列表
@@ -38,6 +39,7 @@ tokens = [
38
39
  'DATA_KEYWORD', # Add new token for @data keyword
39
40
  'KEYWORD_KEYWORD', # 添加@keyword关键字
40
41
  'IMPORT_KEYWORD', # 添加@import关键字
42
+ 'REMOTE_KEYWORD', # 添加@remote关键字
41
43
  'GT', # 大于 >
42
44
  'LT', # 小于 <
43
45
  'GE', # 大于等于 >=
@@ -48,6 +50,7 @@ tokens = [
48
50
  'MINUS', # 减法 -
49
51
  'TIMES', # 乘法 *
50
52
  'DIVIDE', # 除法 /
53
+ 'PIPE', # 管道符 |,用于远程关键字调用
51
54
  ] + list(reserved.values())
52
55
 
53
56
  # 正则表达式定义 token
@@ -72,6 +75,14 @@ t_DIVIDE = r'/'
72
75
  # 增加PLACEHOLDER规则,匹配 ${变量名} 格式
73
76
  t_PLACEHOLDER = r'\$\{[a-zA-Z_\u4e00-\u9fa5][a-zA-Z0-9_\u4e00-\u9fa5]*\}'
74
77
 
78
+ # 添加管道符的正则表达式定义
79
+ t_PIPE = r'\|'
80
+
81
+ # 添加@remote关键字的token规则
82
+ def t_REMOTE_KEYWORD(t):
83
+ r'@remote'
84
+ return t
85
+
75
86
 
76
87
  def t_DATE(t):
77
88
  r'\d{4}-\d{2}-\d{2}(\s+\d{2}:\d{2}:\d{2})?'
pytest_dsl/core/parser.py CHANGED
@@ -68,7 +68,8 @@ def p_metadata_item(p):
68
68
  | AUTHOR_KEYWORD COLON metadata_value
69
69
  | DATE_KEYWORD COLON DATE
70
70
  | DATA_KEYWORD COLON data_source
71
- | IMPORT_KEYWORD COLON STRING'''
71
+ | IMPORT_KEYWORD COLON STRING
72
+ | REMOTE_KEYWORD COLON STRING AS ID'''
72
73
  if p[1] == '@tags':
73
74
  p[0] = Node(p[1], value=p[4])
74
75
  elif p[1] == '@data':
@@ -76,7 +77,12 @@ def p_metadata_item(p):
76
77
  data_info = p[3] # 这是一个包含 file 和 format 的字典
77
78
  p[0] = Node(p[1], value=data_info, children=None)
78
79
  elif p[1] == '@import':
80
+ # 检查是否是远程导入格式
79
81
  p[0] = Node(p[1], value=p[3])
82
+ elif p[1] == '@remote':
83
+ # 对于远程关键字导入,存储URL和别名
84
+ print(f"解析远程关键字导入: URL={p[3]}, 别名={p[5]}")
85
+ p[0] = Node('RemoteImport', value={'url': p[3], 'alias': p[5]})
80
86
  else:
81
87
  p[0] = Node(p[1], value=p[3])
82
88
 
@@ -114,6 +120,7 @@ def p_statements(p):
114
120
  def p_statement(p):
115
121
  '''statement : assignment
116
122
  | keyword_call
123
+ | remote_keyword_call
117
124
  | loop
118
125
  | custom_keyword
119
126
  | return_statement
@@ -123,9 +130,12 @@ def p_statement(p):
123
130
 
124
131
  def p_assignment(p):
125
132
  '''assignment : ID EQUALS expression
126
- | ID EQUALS keyword_call'''
133
+ | ID EQUALS keyword_call
134
+ | ID EQUALS remote_keyword_call'''
127
135
  if isinstance(p[3], Node) and p[3].type == 'KeywordCall':
128
136
  p[0] = Node('AssignmentKeywordCall', [p[3]], p[1])
137
+ elif isinstance(p[3], Node) and p[3].type == 'RemoteKeywordCall':
138
+ p[0] = Node('AssignmentRemoteKeywordCall', [p[3]], p[1])
129
139
  else:
130
140
  p[0] = Node('Assignment', value=p[1], children=[p[3]])
131
141
 
@@ -284,7 +294,7 @@ def p_comparison_expr(p):
284
294
  | expr_atom LE expr_atom
285
295
  | expr_atom EQ expr_atom
286
296
  | expr_atom NE expr_atom'''
287
-
297
+
288
298
  # 根据规则索引判断使用的是哪个操作符
289
299
  if p.slice[2].type == 'GT':
290
300
  operator = '>'
@@ -301,7 +311,7 @@ def p_comparison_expr(p):
301
311
  else:
302
312
  print(f"警告: 无法识别的操作符类型 {p.slice[2].type}")
303
313
  operator = None
304
-
314
+
305
315
  p[0] = Node('ComparisonExpr', [p[1], p[3]], operator)
306
316
 
307
317
 
@@ -310,7 +320,7 @@ def p_arithmetic_expr(p):
310
320
  | expression MINUS expression
311
321
  | expression TIMES expression
312
322
  | expression DIVIDE expression'''
313
-
323
+
314
324
  # 根据规则索引判断使用的是哪个操作符
315
325
  if p.slice[2].type == 'PLUS':
316
326
  operator = '+'
@@ -323,7 +333,7 @@ def p_arithmetic_expr(p):
323
333
  else:
324
334
  print(f"警告: 无法识别的操作符类型 {p.slice[2].type}")
325
335
  operator = None
326
-
336
+
327
337
  p[0] = Node('ArithmeticExpr', [p[1], p[3]], operator)
328
338
 
329
339
 
@@ -337,3 +347,12 @@ def p_error(p):
337
347
 
338
348
  def get_parser(debug=False):
339
349
  return yacc.yacc(debug=debug)
350
+
351
+ # 定义远程关键字调用的语法规则
352
+ def p_remote_keyword_call(p):
353
+ '''remote_keyword_call : ID PIPE LBRACKET ID RBRACKET COMMA parameter_list
354
+ | ID PIPE LBRACKET ID RBRACKET'''
355
+ if len(p) == 8:
356
+ p[0] = Node('RemoteKeywordCall', [p[7]], {'alias': p[1], 'keyword': p[4]})
357
+ else:
358
+ p[0] = Node('RemoteKeywordCall', [[]], {'alias': p[1], 'keyword': p[4]})