mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 14:12:05 +02:00
Initial commit: Core packages
This commit is contained in:
commit
12c29a983b
9512 changed files with 8379910 additions and 0 deletions
489
odoo-bringout-oca-ocb-base/odoo/tools/safe_eval.py
Normal file
489
odoo-bringout-oca-ocb-base/odoo/tools/safe_eval.py
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
"""
|
||||
safe_eval module - methods intended to provide more restricted alternatives to
|
||||
evaluate simple and/or untrusted code.
|
||||
|
||||
Methods in this module are typically used as alternatives to eval() to parse
|
||||
OpenERP domain strings, conditions and expressions, mostly based on locals
|
||||
condition/math builtins.
|
||||
"""
|
||||
|
||||
# Module partially ripped from/inspired by several different sources:
|
||||
# - http://code.activestate.com/recipes/286134/
|
||||
# - safe_eval in lp:~xrg/openobject-server/optimize-5.0
|
||||
# - safe_eval in tryton http://hg.tryton.org/hgwebdir.cgi/trytond/rev/bbb5f73319ad
|
||||
import dis
|
||||
import functools
|
||||
import logging
|
||||
import sys
|
||||
import types
|
||||
from opcode import HAVE_ARGUMENT, opmap, opname
|
||||
from types import CodeType
|
||||
|
||||
import werkzeug
|
||||
from psycopg2 import OperationalError
|
||||
|
||||
from .misc import ustr
|
||||
|
||||
import odoo
|
||||
|
||||
unsafe_eval = eval
|
||||
|
||||
__all__ = ['test_expr', 'safe_eval', 'const_eval']
|
||||
|
||||
# The time module is usually already provided in the safe_eval environment
|
||||
# but some code, e.g. datetime.datetime.now() (Windows/Python 2.5.2, bug
|
||||
# lp:703841), does import time.
|
||||
_ALLOWED_MODULES = ['_strptime', 'math', 'time']
|
||||
|
||||
# Mock __import__ function, as called by cpython's import emulator `PyImport_Import` inside
|
||||
# timemodule.c, _datetimemodule.c and others.
|
||||
# This function does not actually need to do anything, its expected side-effect is to make the
|
||||
# imported module available in `sys.modules`. The _ALLOWED_MODULES are imported below to make it so.
|
||||
def _import(name, globals=None, locals=None, fromlist=None, level=-1):
|
||||
if name not in sys.modules:
|
||||
raise ImportError(f'module {name} should be imported before calling safe_eval()')
|
||||
|
||||
for module in _ALLOWED_MODULES:
|
||||
__import__(module)
|
||||
|
||||
|
||||
_UNSAFE_ATTRIBUTES = [
|
||||
# Frames
|
||||
'f_builtins', 'f_code', 'f_globals', 'f_locals',
|
||||
# Python 2 functions
|
||||
'func_code', 'func_globals',
|
||||
# Code object
|
||||
'co_code', '_co_code_adaptive',
|
||||
# Method resolution order,
|
||||
'mro',
|
||||
# Tracebacks
|
||||
'tb_frame',
|
||||
# Generators
|
||||
'gi_code', 'gi_frame', 'gi_yieldfrom',
|
||||
# Coroutines
|
||||
'cr_await', 'cr_code', 'cr_frame',
|
||||
# Coroutine generators
|
||||
'ag_await', 'ag_code', 'ag_frame',
|
||||
]
|
||||
|
||||
|
||||
def to_opcodes(opnames, _opmap=opmap):
|
||||
for x in opnames:
|
||||
if x in _opmap:
|
||||
yield _opmap[x]
|
||||
# opcodes which absolutely positively must not be usable in safe_eval,
|
||||
# explicitly subtracted from all sets of valid opcodes just in case
|
||||
_BLACKLIST = set(to_opcodes([
|
||||
# can't provide access to accessing arbitrary modules
|
||||
'IMPORT_STAR', 'IMPORT_NAME', 'IMPORT_FROM',
|
||||
# could allow replacing or updating core attributes on models & al, setitem
|
||||
# can be used to set field values
|
||||
'STORE_ATTR', 'DELETE_ATTR',
|
||||
# no reason to allow this
|
||||
'STORE_GLOBAL', 'DELETE_GLOBAL',
|
||||
]))
|
||||
# opcodes necessary to build literal values
|
||||
_CONST_OPCODES = set(to_opcodes([
|
||||
# stack manipulations
|
||||
'POP_TOP', 'ROT_TWO', 'ROT_THREE', 'ROT_FOUR', 'DUP_TOP', 'DUP_TOP_TWO',
|
||||
'LOAD_CONST',
|
||||
'RETURN_VALUE', # return the result of the literal/expr evaluation
|
||||
# literal collections
|
||||
'BUILD_LIST', 'BUILD_MAP', 'BUILD_TUPLE', 'BUILD_SET',
|
||||
# 3.6: literal map with constant keys https://bugs.python.org/issue27140
|
||||
'BUILD_CONST_KEY_MAP',
|
||||
'LIST_EXTEND', 'SET_UPDATE',
|
||||
# 3.11 replace DUP_TOP, DUP_TOP_TWO, ROT_TWO, ROT_THREE, ROT_FOUR
|
||||
'COPY', 'SWAP',
|
||||
# Added in 3.11 https://docs.python.org/3/whatsnew/3.11.html#new-opcodes
|
||||
'RESUME',
|
||||
# 3.12 https://docs.python.org/3/whatsnew/3.12.html#cpython-bytecode-changes
|
||||
'RETURN_CONST',
|
||||
# 3.13
|
||||
'TO_BOOL',
|
||||
])) - _BLACKLIST
|
||||
|
||||
# operations which are both binary and inplace, same order as in doc'
|
||||
_operations = [
|
||||
'POWER', 'MULTIPLY', # 'MATRIX_MULTIPLY', # matrix operator (3.5+)
|
||||
'FLOOR_DIVIDE', 'TRUE_DIVIDE', 'MODULO', 'ADD',
|
||||
'SUBTRACT', 'LSHIFT', 'RSHIFT', 'AND', 'XOR', 'OR',
|
||||
]
|
||||
# operations on literal values
|
||||
_EXPR_OPCODES = _CONST_OPCODES.union(to_opcodes([
|
||||
'UNARY_POSITIVE', 'UNARY_NEGATIVE', 'UNARY_NOT', 'UNARY_INVERT',
|
||||
*('BINARY_' + op for op in _operations), 'BINARY_SUBSCR',
|
||||
*('INPLACE_' + op for op in _operations),
|
||||
'BUILD_SLICE',
|
||||
# comprehensions
|
||||
'LIST_APPEND', 'MAP_ADD', 'SET_ADD',
|
||||
'COMPARE_OP',
|
||||
# specialised comparisons
|
||||
'IS_OP', 'CONTAINS_OP',
|
||||
'DICT_MERGE', 'DICT_UPDATE',
|
||||
# Basically used in any "generator literal"
|
||||
'GEN_START', # added in 3.10 but already removed from 3.11.
|
||||
# Added in 3.11, replacing all BINARY_* and INPLACE_*
|
||||
'BINARY_OP',
|
||||
'BINARY_SLICE',
|
||||
])) - _BLACKLIST
|
||||
|
||||
_SAFE_OPCODES = _EXPR_OPCODES.union(to_opcodes([
|
||||
'POP_BLOCK', 'POP_EXCEPT',
|
||||
|
||||
# note: removed in 3.8
|
||||
'SETUP_LOOP', 'SETUP_EXCEPT', 'BREAK_LOOP', 'CONTINUE_LOOP',
|
||||
|
||||
'EXTENDED_ARG', # P3.6 for long jump offsets.
|
||||
'MAKE_FUNCTION', 'CALL_FUNCTION', 'CALL_FUNCTION_KW', 'CALL_FUNCTION_EX',
|
||||
# Added in P3.7 https://bugs.python.org/issue26110
|
||||
'CALL_METHOD', 'LOAD_METHOD',
|
||||
|
||||
'GET_ITER', 'FOR_ITER', 'YIELD_VALUE',
|
||||
'JUMP_FORWARD', 'JUMP_ABSOLUTE', 'JUMP_BACKWARD',
|
||||
'JUMP_IF_FALSE_OR_POP', 'JUMP_IF_TRUE_OR_POP', 'POP_JUMP_IF_FALSE', 'POP_JUMP_IF_TRUE',
|
||||
'SETUP_FINALLY', 'END_FINALLY',
|
||||
# Added in 3.8 https://bugs.python.org/issue17611
|
||||
'BEGIN_FINALLY', 'CALL_FINALLY', 'POP_FINALLY',
|
||||
|
||||
'RAISE_VARARGS', 'LOAD_NAME', 'STORE_NAME', 'DELETE_NAME', 'LOAD_ATTR',
|
||||
'LOAD_FAST', 'STORE_FAST', 'DELETE_FAST', 'UNPACK_SEQUENCE',
|
||||
'STORE_SUBSCR',
|
||||
'LOAD_GLOBAL',
|
||||
|
||||
'RERAISE', 'JUMP_IF_NOT_EXC_MATCH',
|
||||
|
||||
# Following opcodes were Added in 3.11
|
||||
# replacement of opcodes CALL_FUNCTION, CALL_FUNCTION_KW, CALL_METHOD
|
||||
'PUSH_NULL', 'PRECALL', 'CALL', 'KW_NAMES',
|
||||
# replacement of POP_JUMP_IF_TRUE and POP_JUMP_IF_FALSE
|
||||
'POP_JUMP_FORWARD_IF_FALSE', 'POP_JUMP_FORWARD_IF_TRUE',
|
||||
'POP_JUMP_BACKWARD_IF_FALSE', 'POP_JUMP_BACKWARD_IF_TRUE',
|
||||
# special case of the previous for IS NONE / IS NOT NONE
|
||||
'POP_JUMP_FORWARD_IF_NONE', 'POP_JUMP_BACKWARD_IF_NONE',
|
||||
'POP_JUMP_FORWARD_IF_NOT_NONE', 'POP_JUMP_BACKWARD_IF_NOT_NONE',
|
||||
# replacement of JUMP_IF_NOT_EXC_MATCH
|
||||
'CHECK_EXC_MATCH',
|
||||
# new opcodes
|
||||
'RETURN_GENERATOR',
|
||||
'PUSH_EXC_INFO',
|
||||
'NOP',
|
||||
'FORMAT_VALUE', 'BUILD_STRING',
|
||||
# 3.12 https://docs.python.org/3/whatsnew/3.12.html#cpython-bytecode-changes
|
||||
'END_FOR',
|
||||
'LOAD_FAST_AND_CLEAR', 'LOAD_FAST_CHECK',
|
||||
'POP_JUMP_IF_NOT_NONE', 'POP_JUMP_IF_NONE',
|
||||
'CALL_INTRINSIC_1',
|
||||
'STORE_SLICE',
|
||||
# 3.13
|
||||
'CALL_KW', 'LOAD_FAST_LOAD_FAST',
|
||||
'STORE_FAST_STORE_FAST', 'STORE_FAST_LOAD_FAST',
|
||||
'CONVERT_VALUE', 'FORMAT_SIMPLE', 'FORMAT_WITH_SPEC',
|
||||
'SET_FUNCTION_ATTRIBUTE',
|
||||
])) - _BLACKLIST
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def assert_no_dunder_name(code_obj, expr):
|
||||
""" assert_no_dunder_name(code_obj, expr) -> None
|
||||
|
||||
Asserts that the code object does not refer to any "dunder name"
|
||||
(__$name__), so that safe_eval prevents access to any internal-ish Python
|
||||
attribute or method (both are loaded via LOAD_ATTR which uses a name, not a
|
||||
const or a var).
|
||||
|
||||
Checks that no such name exists in the provided code object (co_names).
|
||||
|
||||
:param code_obj: code object to name-validate
|
||||
:type code_obj: CodeType
|
||||
:param str expr: expression corresponding to the code object, for debugging
|
||||
purposes
|
||||
:raises NameError: in case a forbidden name (containing two underscores)
|
||||
is found in ``code_obj``
|
||||
|
||||
.. note:: actually forbids every name containing 2 underscores
|
||||
"""
|
||||
for name in code_obj.co_names:
|
||||
if "__" in name or name in _UNSAFE_ATTRIBUTES:
|
||||
raise NameError('Access to forbidden name %r (%r)' % (name, expr))
|
||||
|
||||
def assert_valid_codeobj(allowed_codes, code_obj, expr):
|
||||
""" Asserts that the provided code object validates against the bytecode
|
||||
and name constraints.
|
||||
|
||||
Recursively validates the code objects stored in its co_consts in case
|
||||
lambdas are being created/used (lambdas generate their own separated code
|
||||
objects and don't live in the root one)
|
||||
|
||||
:param allowed_codes: list of permissible bytecode instructions
|
||||
:type allowed_codes: set(int)
|
||||
:param code_obj: code object to name-validate
|
||||
:type code_obj: CodeType
|
||||
:param str expr: expression corresponding to the code object, for debugging
|
||||
purposes
|
||||
:raises ValueError: in case of forbidden bytecode in ``code_obj``
|
||||
:raises NameError: in case a forbidden name (containing two underscores)
|
||||
is found in ``code_obj``
|
||||
"""
|
||||
assert_no_dunder_name(code_obj, expr)
|
||||
|
||||
# set operations are almost twice as fast as a manual iteration + condition
|
||||
# when loading /web according to line_profiler
|
||||
code_codes = {i.opcode for i in dis.get_instructions(code_obj)}
|
||||
if not allowed_codes >= code_codes:
|
||||
raise ValueError("forbidden opcode(s) in %r: %s" % (expr, ', '.join(opname[x] for x in (code_codes - allowed_codes))))
|
||||
|
||||
for const in code_obj.co_consts:
|
||||
if isinstance(const, CodeType):
|
||||
assert_valid_codeobj(allowed_codes, const, 'lambda')
|
||||
|
||||
def test_expr(expr, allowed_codes, mode="eval", filename=None):
|
||||
"""test_expr(expression, allowed_codes[, mode[, filename]]) -> code_object
|
||||
|
||||
Test that the expression contains only the allowed opcodes.
|
||||
If the expression is valid and contains only allowed codes,
|
||||
return the compiled code object.
|
||||
Otherwise raise a ValueError, a Syntax Error or TypeError accordingly.
|
||||
|
||||
:param filename: optional pseudo-filename for the compiled expression,
|
||||
displayed for example in traceback frames
|
||||
:type filename: string
|
||||
"""
|
||||
try:
|
||||
if mode == 'eval':
|
||||
# eval() does not like leading/trailing whitespace
|
||||
expr = expr.strip()
|
||||
code_obj = compile(expr, filename or "", mode)
|
||||
except (SyntaxError, TypeError, ValueError):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ValueError('"%s" while compiling\n%r' % (ustr(e), expr))
|
||||
assert_valid_codeobj(allowed_codes, code_obj, expr)
|
||||
return code_obj
|
||||
|
||||
|
||||
def const_eval(expr):
|
||||
"""const_eval(expression) -> value
|
||||
|
||||
Safe Python constant evaluation
|
||||
|
||||
Evaluates a string that contains an expression describing
|
||||
a Python constant. Strings that are not valid Python expressions
|
||||
or that contain other code besides the constant raise ValueError.
|
||||
|
||||
>>> const_eval("10")
|
||||
10
|
||||
>>> const_eval("[1,2, (3,4), {'foo':'bar'}]")
|
||||
[1, 2, (3, 4), {'foo': 'bar'}]
|
||||
>>> const_eval("1+2")
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: opcode BINARY_ADD not allowed
|
||||
"""
|
||||
c = test_expr(expr, _CONST_OPCODES)
|
||||
return unsafe_eval(c)
|
||||
|
||||
def expr_eval(expr):
|
||||
"""expr_eval(expression) -> value
|
||||
|
||||
Restricted Python expression evaluation
|
||||
|
||||
Evaluates a string that contains an expression that only
|
||||
uses Python constants. This can be used to e.g. evaluate
|
||||
a numerical expression from an untrusted source.
|
||||
|
||||
>>> expr_eval("1+2")
|
||||
3
|
||||
>>> expr_eval("[1,2]*2")
|
||||
[1, 2, 1, 2]
|
||||
>>> expr_eval("__import__('sys').modules")
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: opcode LOAD_NAME not allowed
|
||||
"""
|
||||
c = test_expr(expr, _EXPR_OPCODES)
|
||||
return unsafe_eval(c)
|
||||
|
||||
_BUILTINS = {
|
||||
'__import__': _import,
|
||||
'True': True,
|
||||
'False': False,
|
||||
'None': None,
|
||||
'bytes': bytes,
|
||||
'str': str,
|
||||
'unicode': str,
|
||||
'bool': bool,
|
||||
'int': int,
|
||||
'float': float,
|
||||
'enumerate': enumerate,
|
||||
'dict': dict,
|
||||
'list': list,
|
||||
'tuple': tuple,
|
||||
'map': map,
|
||||
'abs': abs,
|
||||
'min': min,
|
||||
'max': max,
|
||||
'sum': sum,
|
||||
'reduce': functools.reduce,
|
||||
'filter': filter,
|
||||
'sorted': sorted,
|
||||
'round': round,
|
||||
'len': len,
|
||||
'repr': repr,
|
||||
'set': set,
|
||||
'all': all,
|
||||
'any': any,
|
||||
'ord': ord,
|
||||
'chr': chr,
|
||||
'divmod': divmod,
|
||||
'isinstance': isinstance,
|
||||
'range': range,
|
||||
'xrange': range,
|
||||
'zip': zip,
|
||||
'Exception': Exception,
|
||||
}
|
||||
def safe_eval(expr, globals_dict=None, locals_dict=None, mode="eval", nocopy=False, locals_builtins=False, filename=None):
|
||||
"""safe_eval(expression[, globals[, locals[, mode[, nocopy]]]]) -> result
|
||||
|
||||
System-restricted Python expression evaluation
|
||||
|
||||
Evaluates a string that contains an expression that mostly
|
||||
uses Python constants, arithmetic expressions and the
|
||||
objects directly provided in context.
|
||||
|
||||
This can be used to e.g. evaluate
|
||||
an OpenERP domain expression from an untrusted source.
|
||||
|
||||
:param filename: optional pseudo-filename for the compiled expression,
|
||||
displayed for example in traceback frames
|
||||
:type filename: string
|
||||
:throws TypeError: If the expression provided is a code object
|
||||
:throws SyntaxError: If the expression provided is not valid Python
|
||||
:throws NameError: If the expression provided accesses forbidden names
|
||||
:throws ValueError: If the expression provided uses forbidden bytecode
|
||||
"""
|
||||
if type(expr) is CodeType:
|
||||
raise TypeError("safe_eval does not allow direct evaluation of code objects.")
|
||||
|
||||
# prevent altering the globals/locals from within the sandbox
|
||||
# by taking a copy.
|
||||
if not nocopy:
|
||||
# isinstance() does not work below, we want *exactly* the dict class
|
||||
if (globals_dict is not None and type(globals_dict) is not dict) \
|
||||
or (locals_dict is not None and type(locals_dict) is not dict):
|
||||
_logger.warning(
|
||||
"Looks like you are trying to pass a dynamic environment, "
|
||||
"you should probably pass nocopy=True to safe_eval().")
|
||||
if globals_dict is not None:
|
||||
globals_dict = dict(globals_dict)
|
||||
if locals_dict is not None:
|
||||
locals_dict = dict(locals_dict)
|
||||
|
||||
check_values(globals_dict)
|
||||
check_values(locals_dict)
|
||||
|
||||
if globals_dict is None:
|
||||
globals_dict = {}
|
||||
|
||||
globals_dict['__builtins__'] = dict(_BUILTINS)
|
||||
if locals_builtins:
|
||||
if locals_dict is None:
|
||||
locals_dict = {}
|
||||
locals_dict.update(_BUILTINS)
|
||||
c = test_expr(expr, _SAFE_OPCODES, mode=mode, filename=filename)
|
||||
try:
|
||||
return unsafe_eval(c, globals_dict, locals_dict)
|
||||
except odoo.exceptions.UserError:
|
||||
raise
|
||||
except odoo.exceptions.RedirectWarning:
|
||||
raise
|
||||
except werkzeug.exceptions.HTTPException:
|
||||
raise
|
||||
except OperationalError:
|
||||
# Do not hide PostgreSQL low-level exceptions, to let the auto-replay
|
||||
# of serialized transactions work its magic
|
||||
raise
|
||||
except ZeroDivisionError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ValueError('%s: "%s" while evaluating\n%r' % (ustr(type(e)), ustr(e), expr))
|
||||
def test_python_expr(expr, mode="eval"):
|
||||
try:
|
||||
test_expr(expr, _SAFE_OPCODES, mode=mode)
|
||||
except (SyntaxError, TypeError, ValueError) as err:
|
||||
if len(err.args) >= 2 and len(err.args[1]) >= 4:
|
||||
error = {
|
||||
'message': err.args[0],
|
||||
'filename': err.args[1][0],
|
||||
'lineno': err.args[1][1],
|
||||
'offset': err.args[1][2],
|
||||
'error_line': err.args[1][3],
|
||||
}
|
||||
msg = "%s : %s at line %d\n%s" % (type(err).__name__, error['message'], error['lineno'], error['error_line'])
|
||||
else:
|
||||
msg = ustr(err)
|
||||
return msg
|
||||
return False
|
||||
|
||||
|
||||
def check_values(d):
|
||||
if not d:
|
||||
return d
|
||||
for v in d.values():
|
||||
if isinstance(v, types.ModuleType):
|
||||
raise TypeError(f"""Module {v} can not be used in evaluation contexts
|
||||
|
||||
Prefer providing only the items necessary for your intended use.
|
||||
|
||||
If a "module" is necessary for backwards compatibility, use
|
||||
`odoo.tools.safe_eval.wrap_module` to generate a wrapper recursively
|
||||
whitelisting allowed attributes.
|
||||
|
||||
Pre-wrapped modules are provided as attributes of `odoo.tools.safe_eval`.
|
||||
""")
|
||||
return d
|
||||
|
||||
class wrap_module:
|
||||
def __init__(self, module, attributes):
|
||||
"""Helper for wrapping a package/module to expose selected attributes
|
||||
|
||||
:param module: the actual package/module to wrap, as returned by ``import <module>``
|
||||
:param iterable attributes: attributes to expose / whitelist. If a dict,
|
||||
the keys are the attributes and the values
|
||||
are used as an ``attributes`` in case the
|
||||
corresponding item is a submodule
|
||||
"""
|
||||
# builtin modules don't have a __file__ at all
|
||||
modfile = getattr(module, '__file__', '(built-in)')
|
||||
self._repr = f"<wrapped {module.__name__!r} ({modfile})>"
|
||||
for attrib in attributes:
|
||||
target = getattr(module, attrib)
|
||||
if isinstance(target, types.ModuleType):
|
||||
target = wrap_module(target, attributes[attrib])
|
||||
setattr(self, attrib, target)
|
||||
|
||||
def __repr__(self):
|
||||
return self._repr
|
||||
|
||||
# dateutil submodules are lazy so need to import them for them to "exist"
|
||||
import dateutil
|
||||
mods = ['parser', 'relativedelta', 'rrule', 'tz']
|
||||
for mod in mods:
|
||||
__import__('dateutil.%s' % mod)
|
||||
datetime = wrap_module(__import__('datetime'), ['date', 'datetime', 'time', 'timedelta', 'timezone', 'tzinfo', 'MAXYEAR', 'MINYEAR'])
|
||||
dateutil = wrap_module(dateutil, {
|
||||
"tz": ["UTC", "tzutc"],
|
||||
"parser": ["isoparse", "parse"],
|
||||
"relativedelta": ["relativedelta", "MO", "TU", "WE", "TH", "FR", "SA", "SU"],
|
||||
"rrule": ["rrule", "rruleset", "rrulestr", "YEARLY", "MONTHLY", "WEEKLY", "DAILY", "HOURLY", "MINUTELY", "SECONDLY", "MO", "TU", "WE", "TH", "FR", "SA", "SU"],
|
||||
})
|
||||
json = wrap_module(__import__('json'), ['loads', 'dumps'])
|
||||
time = wrap_module(__import__('time'), ['time', 'strptime', 'strftime', 'sleep'])
|
||||
pytz = wrap_module(__import__('pytz'), [
|
||||
'utc', 'UTC', 'timezone',
|
||||
])
|
||||
dateutil.tz.gettz = pytz.timezone
|
||||
Loading…
Add table
Add a link
Reference in a new issue