Add classes for Python definitions

This commit is contained in:
2026-04-12 18:49:23 +02:00
parent d8fd2b1948
commit d6a35ea222
5 changed files with 519 additions and 0 deletions
@@ -0,0 +1,213 @@
# Copyright 2026 jCloud Services GbR
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from typing import Union
import ast
from .arguments import PythonFunctionArgument, PythonASTArgumentsListParser
import keyword
from ...exceptions import InvalidPythonIdentifierError
__all__ = [
'PythonDefinition',
'PythonFunctionDefinition',
'PythonAsyncFunctionDefinition',
'PythonClassDefinition'
]
class PythonDefinition:
'''
A base class for Python definition (a class, function or asynchronous
function definition) classes.
:param name: The name of the definition.
:type name: str
:param doc: The docstring.
:type doc: Union[str, None]
:param decorators: The definition decorators.
:type decorators: list[str]
:raises InvalidPythonIdentifierError: If the name is not a valid
Python identifier.
'''
def __init__(self, name: str, doc: Union[str, None], decorators: list[str]) -> None:
if keyword.iskeyword(name) or not name.isidentifier() or not name:
raise InvalidPythonIdentifierError('invalid identifier', identifier = name)
self.name = name
self.doc = doc
self.decorators = decorators
def _arg_list(self) -> tuple:
return (
self.name,
self.doc,
self.decorators
)
def __repr__(self) -> str:
return type(self).__name__ + repr(self._arg_list())
@classmethod
def from_node(cls, node: ast.stmt) -> PythonDefinition:
'''
From an AST node.
:param node: The AST node.
:type node: ast.stmt
'''
return cls(
node.name,
ast.get_docstring(node),
[ast.unparse(d) for d in node.decorator_list]
)
def __eq__(self, value: object) -> bool:
if not isinstance(value, PythonDefinition):
return False
return self._arg_list() == value._arg_list()
class PythonFunctionDefinition(PythonDefinition):
'''
A PythonDefinition subclass representing a Python function
definition.
:param name: The name of the definition.
:type name: str
:param args: The arguments.
:type args: list[PythonFunctionArgument]
:param returns: The return type of the function or class.
:type returns: Union[str, None]
:param doc: The docstring.
:type doc: Union[str, None]
:param decorators: The definition decorators.
:type decorators: list[str]
:raises InvalidPythonIdentifierError: If the name is not a valid
Python identifier.
'''
def __init__(self, name: str, args: list[PythonFunctionArgument], returns: Union[str, None], doc: Union[str, None], decorators: list[str]) -> None:
super().__init__(name, doc, decorators)
self.args = args
self.returns = returns
def _arg_list(self) -> tuple:
return (
self.name,
self.args,
self.returns,
self.doc,
self.decorators
)
@classmethod
def from_node(cls, node: ast.stmt) -> PythonFunctionDefinition:
'''
From an AST node.
:param node: The AST node.
:type node: ast.stmt
'''
return cls(
node.name,
PythonASTArgumentsListParser(node.args).to_argument_list(),
ast.unparse(node.returns) if node.returns is not None else None,
ast.get_docstring(node),
[ast.unparse(d) for d in node.decorator_list]
)
class PythonAsyncFunctionDefinition(PythonFunctionDefinition):
'''
A PythonDefinition subclass representing an asynchronous Python function
definition.
:param name: The name of the definition.
:type name: str
:param args: The arguments.
:type args: list[PythonFunctionArgument]
:param returns: The return type of the function or class.
:type returns: Union[str, None]
:param doc: The docstring.
:type doc: Union[str, None]
:param decorators: The definition decorators.
:type decorators: list[str]
:raises InvalidPythonIdentifierError: If the name is not a valid
Python identifier.
'''
class PythonClassDefinition(PythonDefinition):
'''
A PythonDefinition subclass representing a Python class definition.
:param name: The name of the definition.
:type name: str
:param bases: The base classes.
:type bases: list[str]
:param doc: The docstring.
:type doc: Union[str, None]
:param decorators: The definition decorators.
:type decorators: list[str]
:param body:
:type body: list[PythonDefinition]
:raises InvalidPythonIdentifierError: If the name is not a valid
Python identifier.
'''
def __init__(self, name: str, bases: list[str], doc: Union[str, None], decorators: list[str], body: list[PythonDefinition]) -> None:
super().__init__(name, doc, decorators)
self.bases = bases
self.body = body
def _arg_list(self) -> tuple:
return (
self.name,
self.bases,
self.doc,
self.decorators,
self.body
)
@classmethod
def from_node(cls, node: ast.stmt) -> PythonClassDefinition:
'''
From an AST node.
:param node: The AST node.
:type node: ast.stmt
'''
return cls(
node.name,
[ast.unparse(b) for b in node.bases],
ast.get_docstring(node),
[ast.unparse(d) for d in node.decorator_list],
[
PythonFunctionDefinition.from_node(expr)
if isinstance(expr, ast.FunctionDef)
else PythonAsyncFunctionDefinition.from_node(expr)
if isinstance(expr, ast.AsyncFunctionDef)
else PythonClassDefinition.from_node(expr)
for expr in node.body
if isinstance(expr, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
]
)
@@ -0,0 +1,96 @@
# Copyright 2026 jCloud Services GbR
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from src.jcloud_docsgen.core.python.definitions import PythonFunctionDefinition, PythonClassDefinition
from src.jcloud_docsgen.core.python.arguments import PythonFunctionArgument, PythonArgumentKind
from src.jcloud_docsgen.exceptions import InvalidPythonIdentifierError
from tests.utils.ast_node import ast_node
import pytest
@pytest.mark.parametrize('definition,expected', [
(PythonClassDefinition('A', [], None, None, []), 'PythonClassDefinition(\'A\', [], None, None, [])'),
(PythonClassDefinition('A', [], None, 'Hello, World!', []), 'PythonClassDefinition(\'A\', [], None, \'Hello, World!\', [])'),
])
def test_PythonDefinition_string_representation(definition, expected):
assert repr(definition) == expected
@pytest.mark.parametrize('name,args,returns,doc,decorators,expected_exception,expected_exception_message', [
('', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier'),
('.', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: .'),
('#', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: #'),
('1a', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: 1a'),
('1_', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: 1_'),
('1,', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: 1,'),
(',', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: ,'),
(':', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: :'),
('-', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: -'),
(';', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: ;'),
('|', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: |'),
('<', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: <'),
('>', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: >'),
('a,', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: a,'),
(',a', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: ,a'),
('a:', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: a:'),
(':a', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: :a'),
('a-', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: a-'),
('-a', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: -a'),
('a;', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: a;'),
(';a', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: ;a'),
('a|', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: a|'),
('|a', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: |a'),
('a<', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: a<'),
('<a', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: <a'),
('a>', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: a>'),
('>a', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: >a'),
('class', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: class'),
('def', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: def'),
('lambda', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: lambda'),
('None', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: None'),
('True', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: True'),
])
def test_PythonFunctionDefinition_exceptions(name, args, returns, doc, decorators, expected_exception, expected_exception_message):
with pytest.raises(expected_exception) as exc_info:
PythonClassDefinition(name, args, returns, doc, decorators)
assert str(exc_info.value) == expected_exception_message
@pytest.mark.parametrize('node,expected', [
(ast_node('@decorator\nclass Class(BaseClass1, BaseClass2):\n\tdef __init__(self, value: object) -> None:\n\t\tself._value = value\n\n\t@property\n\tdef value(self) -> object:\n\t\treturn self._value'), PythonClassDefinition('Class', [
'BaseClass1',
'BaseClass2'
], None, ['decorator'], [
PythonFunctionDefinition('__init__', [
PythonFunctionArgument('self', PythonArgumentKind.NORMAL, None, None),
PythonFunctionArgument('value', PythonArgumentKind.NORMAL, None, 'object')
], 'None', None, []),
PythonFunctionDefinition('value', [
PythonFunctionArgument('self', PythonArgumentKind.NORMAL, None, None)
], 'object', None, ['property'])
])),
(ast_node('class Class: ...'), PythonClassDefinition('Class', [], None, [], [])),
(ast_node('class Class: \'\'\'docstring\'\'\''), PythonClassDefinition('Class', [], 'docstring', [], [])),
(ast_node('class Class:\n\tdef __init__(self): ...'), PythonClassDefinition('Class', [], None, [], [
PythonFunctionDefinition('__init__', [
PythonFunctionArgument('self', PythonArgumentKind.NORMAL, None, None)
], None, None, [])
])),
(ast_node('class Class:\n\t\'\'\'doc\'\'\'\n\tdef __init__(self): ...'), PythonClassDefinition('Class', [], 'doc', [], [
PythonFunctionDefinition('__init__', [
PythonFunctionArgument('self', PythonArgumentKind.NORMAL, None, None)
], None, None, [])
])),
])
def test_PythonDefinition_from_node(node, expected):
assert PythonClassDefinition.from_node(node) == expected
@@ -0,0 +1,84 @@
# Copyright 2026 jCloud Services GbR
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from src.jcloud_docsgen.core.python.definitions import PythonDefinition
from src.jcloud_docsgen.exceptions import InvalidPythonIdentifierError
from tests.utils.ast_node import ast_node
import pytest
@pytest.mark.parametrize('definition,expected', [
(PythonDefinition('a', None, []), 'PythonDefinition(\'a\', None, [])'),
(PythonDefinition('a', 'Hello, World!', []), 'PythonDefinition(\'a\', \'Hello, World!\', [])'),
])
def test_PythonDefinition_string_representation(definition, expected):
assert repr(definition) == expected
@pytest.mark.parametrize('name,doc,decorators,expected_exception,expected_exception_message', [
('', None, [], InvalidPythonIdentifierError, 'invalid identifier'),
('.', None, [], InvalidPythonIdentifierError, 'invalid identifier: .'),
('#', None, [], InvalidPythonIdentifierError, 'invalid identifier: #'),
('1a', None, [], InvalidPythonIdentifierError, 'invalid identifier: 1a'),
('1_', None, [], InvalidPythonIdentifierError, 'invalid identifier: 1_'),
('1,', None, [], InvalidPythonIdentifierError, 'invalid identifier: 1,'),
(',', None, [], InvalidPythonIdentifierError, 'invalid identifier: ,'),
(':', None, [], InvalidPythonIdentifierError, 'invalid identifier: :'),
('-', None, [], InvalidPythonIdentifierError, 'invalid identifier: -'),
(';', None, [], InvalidPythonIdentifierError, 'invalid identifier: ;'),
('|', None, [], InvalidPythonIdentifierError, 'invalid identifier: |'),
('<', None, [], InvalidPythonIdentifierError, 'invalid identifier: <'),
('>', None, [], InvalidPythonIdentifierError, 'invalid identifier: >'),
('a,', None, [], InvalidPythonIdentifierError, 'invalid identifier: a,'),
(',a', None, [], InvalidPythonIdentifierError, 'invalid identifier: ,a'),
('a:', None, [], InvalidPythonIdentifierError, 'invalid identifier: a:'),
(':a', None, [], InvalidPythonIdentifierError, 'invalid identifier: :a'),
('a-', None, [], InvalidPythonIdentifierError, 'invalid identifier: a-'),
('-a', None, [], InvalidPythonIdentifierError, 'invalid identifier: -a'),
('a;', None, [], InvalidPythonIdentifierError, 'invalid identifier: a;'),
(';a', None, [], InvalidPythonIdentifierError, 'invalid identifier: ;a'),
('a|', None, [], InvalidPythonIdentifierError, 'invalid identifier: a|'),
('|a', None, [], InvalidPythonIdentifierError, 'invalid identifier: |a'),
('a<', None, [], InvalidPythonIdentifierError, 'invalid identifier: a<'),
('<a', None, [], InvalidPythonIdentifierError, 'invalid identifier: <a'),
('a>', None, [], InvalidPythonIdentifierError, 'invalid identifier: a>'),
('>a', None, [], InvalidPythonIdentifierError, 'invalid identifier: >a'),
('class', None, [], InvalidPythonIdentifierError, 'invalid identifier: class'),
('def', None, [], InvalidPythonIdentifierError, 'invalid identifier: def'),
('lambda', None, [], InvalidPythonIdentifierError, 'invalid identifier: lambda'),
('None', None, [], InvalidPythonIdentifierError, 'invalid identifier: None'),
('True', None, [], InvalidPythonIdentifierError, 'invalid identifier: True'),
])
def test_PythonDefinition_exceptions(name, doc, decorators, expected_exception, expected_exception_message):
with pytest.raises(expected_exception) as exc_info:
PythonDefinition(name, doc, decorators)
assert str(exc_info.value) == expected_exception_message
@pytest.mark.parametrize('definition1,definition2,expected', [
(PythonDefinition('a', None, []), PythonDefinition('a', None, []), True),
(PythonDefinition('a', 'doc', []), PythonDefinition('a', 'doc', []), True),
(PythonDefinition('a', 'doc', ['a']), PythonDefinition('a', 'doc', ['a']), True),
(PythonDefinition('a', 'doc', ['a', 'b']), PythonDefinition('a', 'doc', ['a', 'b']), True),
(PythonDefinition('a', None, []), PythonDefinition('b', None, []), False),
(PythonDefinition('a', 'doc', []), PythonDefinition('a', None, []), False),
(PythonDefinition('a', 'doc', []), PythonDefinition('a', 'docs', []), False),
(PythonDefinition('a', None, ['a']), PythonDefinition('a', None, ['b']), False),
(PythonDefinition('a', None, ['a']), PythonDefinition('a', None, []), False),
(PythonDefinition('a', None, ['a']), PythonDefinition('a', None, ['a', 'b']), False),
(PythonDefinition('a', None, ['b', 'a']), PythonDefinition('a', None, ['a', 'b']), False),
(PythonDefinition('a', 'doc', []), PythonDefinition('b', None, ['a', 'b']), False),
])
def test_PythonDefinition___eq__(definition1, definition2, expected):
assert (definition1 == definition2) == expected
assert (definition2 == definition1) == expected
@@ -0,0 +1,90 @@
# Copyright 2026 jCloud Services GbR
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from src.jcloud_docsgen.core.python.definitions import PythonFunctionDefinition
from src.jcloud_docsgen.core.python.arguments import PythonFunctionArgument, PythonArgumentKind
from src.jcloud_docsgen.exceptions import InvalidPythonIdentifierError
from tests.utils.ast_node import ast_node
import pytest
@pytest.mark.parametrize('definition,expected', [
(PythonFunctionDefinition('a', [], None, None, []), 'PythonFunctionDefinition(\'a\', [], None, None, [])'),
(PythonFunctionDefinition('a', [], None, 'Hello, World!', []), 'PythonFunctionDefinition(\'a\', [], None, \'Hello, World!\', [])'),
])
def test_PythonDefinition_string_representation(definition, expected):
assert repr(definition) == expected
@pytest.mark.parametrize('name,args,returns,doc,decorators,expected_exception,expected_exception_message', [
('', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier'),
('.', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: .'),
('#', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: #'),
('1a', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: 1a'),
('1_', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: 1_'),
('1,', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: 1,'),
(',', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: ,'),
(':', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: :'),
('-', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: -'),
(';', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: ;'),
('|', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: |'),
('<', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: <'),
('>', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: >'),
('a,', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: a,'),
(',a', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: ,a'),
('a:', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: a:'),
(':a', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: :a'),
('a-', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: a-'),
('-a', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: -a'),
('a;', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: a;'),
(';a', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: ;a'),
('a|', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: a|'),
('|a', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: |a'),
('a<', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: a<'),
('<a', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: <a'),
('a>', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: a>'),
('>a', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: >a'),
('class', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: class'),
('def', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: def'),
('lambda', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: lambda'),
('None', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: None'),
('True', [], None, None, [], InvalidPythonIdentifierError, 'invalid identifier: True'),
])
def test_PythonFunctionDefinition_exceptions(name, args, returns, doc, decorators, expected_exception, expected_exception_message):
with pytest.raises(expected_exception) as exc_info:
PythonFunctionDefinition(name, args, returns, doc, decorators)
assert str(exc_info.value) == expected_exception_message
@pytest.mark.parametrize('node,expected', [
(ast_node('def add(a: int, b: int) -> int: return a + b'), PythonFunctionDefinition('add', [
PythonFunctionArgument('a', PythonArgumentKind.NORMAL, None, 'int'),
PythonFunctionArgument('b', PythonArgumentKind.NORMAL, None, 'int'),
], 'int', None, [])),
(ast_node('@decorator\ndef add(a: int, b: int) -> int: return a + b'), PythonFunctionDefinition('add', [
PythonFunctionArgument('a', PythonArgumentKind.NORMAL, None, 'int'),
PythonFunctionArgument('b', PythonArgumentKind.NORMAL, None, 'int'),
], 'int', None, ['decorator'])),
(ast_node('@decorator1\n@decorator2\ndef add(a: int, b: int) -> int: return a + b'), PythonFunctionDefinition('add', [
PythonFunctionArgument('a', PythonArgumentKind.NORMAL, None, 'int'),
PythonFunctionArgument('b', PythonArgumentKind.NORMAL, None, 'int'),
], 'int', None, ['decorator1', 'decorator2'])),
(ast_node('def func(): ...'), PythonFunctionDefinition('func', [], None, None, [])),
(ast_node('@decorator\ndef func(): ...'), PythonFunctionDefinition('func', [], None, None, ['decorator'])),
# (ast_node('class Class(BaseClass1, BaseClass2):\n\tdef __init__(self, value: object) -> None:\n\t\tself._value = value\n\n\t@property\n\tdef value(self) -> object:\n\t\treturn self._value'), PythonFunctionDefinition('add', [
# PythonFunctionArgument('a', PythonArgumentKind.NORMAL, None, 'int'),
# PythonFunctionArgument('b', PythonArgumentKind.NORMAL, None, 'int'),
# ], 'int', None, [])),
])
def test_PythonDefinition_from_node(node, expected):
assert PythonFunctionDefinition.from_node(node) == expected
+36
View File
@@ -0,0 +1,36 @@
# Copyright 2026 jCloud Services GbR
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Union
import ast
__all__ = [
'ast_node'
]
def ast_node(statement: str) -> Union[ast.stmt, None]:
'''
Returns an AST node from the statement.
:param statement: The statement.
:type statement: str
:return: The node.
:rtype: Union[ast.stmt, None]
'''
body = ast.parse(statement).body
if not body:
return None
return body[0]