recurvedata-lib 0.1.487__py2.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.

Potentially problematic release.


This version of recurvedata-lib might be problematic. Click here for more details.

Files changed (333) hide show
  1. recurvedata/__init__.py +0 -0
  2. recurvedata/__version__.py +1 -0
  3. recurvedata/client/__init__.py +3 -0
  4. recurvedata/client/client.py +150 -0
  5. recurvedata/client/server_client.py +91 -0
  6. recurvedata/config.py +99 -0
  7. recurvedata/connectors/__init__.py +20 -0
  8. recurvedata/connectors/_register.py +46 -0
  9. recurvedata/connectors/base.py +111 -0
  10. recurvedata/connectors/config_schema.py +1575 -0
  11. recurvedata/connectors/connectors/__init__.py +0 -0
  12. recurvedata/connectors/connectors/aliyun_access_key.py +30 -0
  13. recurvedata/connectors/connectors/auth.py +44 -0
  14. recurvedata/connectors/connectors/azure_blob.py +89 -0
  15. recurvedata/connectors/connectors/azure_synapse.py +79 -0
  16. recurvedata/connectors/connectors/bigquery.py +359 -0
  17. recurvedata/connectors/connectors/clickhouse.py +219 -0
  18. recurvedata/connectors/connectors/dingtalk.py +61 -0
  19. recurvedata/connectors/connectors/doris.py +215 -0
  20. recurvedata/connectors/connectors/es.py +62 -0
  21. recurvedata/connectors/connectors/feishu.py +65 -0
  22. recurvedata/connectors/connectors/ftp.py +50 -0
  23. recurvedata/connectors/connectors/generic.py +49 -0
  24. recurvedata/connectors/connectors/google_cloud_storage.py +115 -0
  25. recurvedata/connectors/connectors/google_service_account.py +225 -0
  26. recurvedata/connectors/connectors/hive.py +207 -0
  27. recurvedata/connectors/connectors/impala.py +210 -0
  28. recurvedata/connectors/connectors/jenkins.py +51 -0
  29. recurvedata/connectors/connectors/mail.py +89 -0
  30. recurvedata/connectors/connectors/microsoft_fabric.py +284 -0
  31. recurvedata/connectors/connectors/mongo.py +79 -0
  32. recurvedata/connectors/connectors/mssql.py +131 -0
  33. recurvedata/connectors/connectors/mysql.py +191 -0
  34. recurvedata/connectors/connectors/n8n.py +141 -0
  35. recurvedata/connectors/connectors/oss.py +74 -0
  36. recurvedata/connectors/connectors/owncloud.py +36 -0
  37. recurvedata/connectors/connectors/phoenix.py +36 -0
  38. recurvedata/connectors/connectors/postgres.py +230 -0
  39. recurvedata/connectors/connectors/python.py +50 -0
  40. recurvedata/connectors/connectors/redshift.py +187 -0
  41. recurvedata/connectors/connectors/s3.py +93 -0
  42. recurvedata/connectors/connectors/sftp.py +87 -0
  43. recurvedata/connectors/connectors/slack.py +35 -0
  44. recurvedata/connectors/connectors/spark.py +99 -0
  45. recurvedata/connectors/connectors/starrocks.py +175 -0
  46. recurvedata/connectors/connectors/tencent_cos.py +40 -0
  47. recurvedata/connectors/connectors/tidb.py +49 -0
  48. recurvedata/connectors/const.py +315 -0
  49. recurvedata/connectors/datasource.py +189 -0
  50. recurvedata/connectors/dbapi.py +469 -0
  51. recurvedata/connectors/fs.py +66 -0
  52. recurvedata/connectors/ftp.py +40 -0
  53. recurvedata/connectors/object_store.py +60 -0
  54. recurvedata/connectors/pigeon.py +172 -0
  55. recurvedata/connectors/proxy.py +104 -0
  56. recurvedata/connectors/service.py +223 -0
  57. recurvedata/connectors/utils.py +47 -0
  58. recurvedata/consts.py +49 -0
  59. recurvedata/core/__init__.py +0 -0
  60. recurvedata/core/config.py +46 -0
  61. recurvedata/core/configurable.py +27 -0
  62. recurvedata/core/consts.py +2 -0
  63. recurvedata/core/templating.py +206 -0
  64. recurvedata/core/tracing.py +223 -0
  65. recurvedata/core/transformer.py +186 -0
  66. recurvedata/core/translation.py +91 -0
  67. recurvedata/dbt/client.py +97 -0
  68. recurvedata/dbt/consts.py +99 -0
  69. recurvedata/dbt/cosmos_utils.py +275 -0
  70. recurvedata/dbt/error_codes.py +18 -0
  71. recurvedata/dbt/schemas.py +98 -0
  72. recurvedata/dbt/service.py +451 -0
  73. recurvedata/dbt/utils.py +246 -0
  74. recurvedata/error_codes.py +71 -0
  75. recurvedata/exceptions.py +72 -0
  76. recurvedata/executors/__init__.py +4 -0
  77. recurvedata/executors/cli/__init__.py +7 -0
  78. recurvedata/executors/cli/connector.py +117 -0
  79. recurvedata/executors/cli/dbt.py +118 -0
  80. recurvedata/executors/cli/main.py +82 -0
  81. recurvedata/executors/cli/parameters.py +18 -0
  82. recurvedata/executors/client.py +190 -0
  83. recurvedata/executors/consts.py +50 -0
  84. recurvedata/executors/debug_executor.py +100 -0
  85. recurvedata/executors/executor.py +300 -0
  86. recurvedata/executors/link_executor.py +189 -0
  87. recurvedata/executors/models.py +34 -0
  88. recurvedata/executors/schemas.py +222 -0
  89. recurvedata/executors/service/__init__.py +0 -0
  90. recurvedata/executors/service/connector.py +380 -0
  91. recurvedata/executors/utils.py +172 -0
  92. recurvedata/filestorage/__init__.py +11 -0
  93. recurvedata/filestorage/_factory.py +33 -0
  94. recurvedata/filestorage/backends/__init__.py +0 -0
  95. recurvedata/filestorage/backends/fsspec.py +45 -0
  96. recurvedata/filestorage/backends/local.py +67 -0
  97. recurvedata/filestorage/backends/oss.py +56 -0
  98. recurvedata/filestorage/interface.py +84 -0
  99. recurvedata/operators/__init__.py +10 -0
  100. recurvedata/operators/base.py +28 -0
  101. recurvedata/operators/config.py +21 -0
  102. recurvedata/operators/context.py +255 -0
  103. recurvedata/operators/dbt_operator/__init__.py +2 -0
  104. recurvedata/operators/dbt_operator/model_pipeline_link_operator.py +55 -0
  105. recurvedata/operators/dbt_operator/operator.py +353 -0
  106. recurvedata/operators/link_operator/__init__.py +1 -0
  107. recurvedata/operators/link_operator/operator.py +120 -0
  108. recurvedata/operators/models.py +55 -0
  109. recurvedata/operators/notify_operator/__init__.py +1 -0
  110. recurvedata/operators/notify_operator/operator.py +180 -0
  111. recurvedata/operators/operator.py +119 -0
  112. recurvedata/operators/python_operator/__init__.py +1 -0
  113. recurvedata/operators/python_operator/operator.py +132 -0
  114. recurvedata/operators/sensor_operator/__init__.py +1 -0
  115. recurvedata/operators/sensor_operator/airflow_utils.py +63 -0
  116. recurvedata/operators/sensor_operator/operator.py +172 -0
  117. recurvedata/operators/spark_operator/__init__.py +1 -0
  118. recurvedata/operators/spark_operator/operator.py +200 -0
  119. recurvedata/operators/spark_operator/spark_sample.py +47 -0
  120. recurvedata/operators/sql_operator/__init__.py +1 -0
  121. recurvedata/operators/sql_operator/operator.py +90 -0
  122. recurvedata/operators/task.py +211 -0
  123. recurvedata/operators/transfer_operator/__init__.py +40 -0
  124. recurvedata/operators/transfer_operator/const.py +10 -0
  125. recurvedata/operators/transfer_operator/dump_aliyun_sls.py +82 -0
  126. recurvedata/operators/transfer_operator/dump_sheet_task_base.py +292 -0
  127. recurvedata/operators/transfer_operator/dump_task_cass.py +155 -0
  128. recurvedata/operators/transfer_operator/dump_task_dbapi.py +209 -0
  129. recurvedata/operators/transfer_operator/dump_task_es.py +113 -0
  130. recurvedata/operators/transfer_operator/dump_task_feishu_sheet.py +114 -0
  131. recurvedata/operators/transfer_operator/dump_task_ftp.py +234 -0
  132. recurvedata/operators/transfer_operator/dump_task_google_sheet.py +66 -0
  133. recurvedata/operators/transfer_operator/dump_task_mongodb.py +168 -0
  134. recurvedata/operators/transfer_operator/dump_task_oss.py +285 -0
  135. recurvedata/operators/transfer_operator/dump_task_python.py +212 -0
  136. recurvedata/operators/transfer_operator/dump_task_s3.py +270 -0
  137. recurvedata/operators/transfer_operator/dump_task_sftp.py +229 -0
  138. recurvedata/operators/transfer_operator/load_task_aliyun_oss.py +107 -0
  139. recurvedata/operators/transfer_operator/load_task_azure_blob.py +115 -0
  140. recurvedata/operators/transfer_operator/load_task_azure_synapse.py +90 -0
  141. recurvedata/operators/transfer_operator/load_task_clickhouse.py +167 -0
  142. recurvedata/operators/transfer_operator/load_task_doris.py +164 -0
  143. recurvedata/operators/transfer_operator/load_task_email.py +188 -0
  144. recurvedata/operators/transfer_operator/load_task_es.py +86 -0
  145. recurvedata/operators/transfer_operator/load_task_filebrowser.py +151 -0
  146. recurvedata/operators/transfer_operator/load_task_ftp.py +19 -0
  147. recurvedata/operators/transfer_operator/load_task_google_bigquery.py +90 -0
  148. recurvedata/operators/transfer_operator/load_task_google_cloud_storage.py +127 -0
  149. recurvedata/operators/transfer_operator/load_task_google_sheet.py +130 -0
  150. recurvedata/operators/transfer_operator/load_task_hive.py +158 -0
  151. recurvedata/operators/transfer_operator/load_task_microsoft_fabric.py +105 -0
  152. recurvedata/operators/transfer_operator/load_task_mssql.py +153 -0
  153. recurvedata/operators/transfer_operator/load_task_mysql.py +157 -0
  154. recurvedata/operators/transfer_operator/load_task_owncloud.py +135 -0
  155. recurvedata/operators/transfer_operator/load_task_postgresql.py +109 -0
  156. recurvedata/operators/transfer_operator/load_task_qcloud_cos.py +119 -0
  157. recurvedata/operators/transfer_operator/load_task_recurve_data_prep.py +75 -0
  158. recurvedata/operators/transfer_operator/load_task_redshift.py +95 -0
  159. recurvedata/operators/transfer_operator/load_task_s3.py +150 -0
  160. recurvedata/operators/transfer_operator/load_task_sftp.py +90 -0
  161. recurvedata/operators/transfer_operator/load_task_starrocks.py +169 -0
  162. recurvedata/operators/transfer_operator/load_task_yicrowds.py +97 -0
  163. recurvedata/operators/transfer_operator/mixin.py +31 -0
  164. recurvedata/operators/transfer_operator/operator.py +231 -0
  165. recurvedata/operators/transfer_operator/task.py +223 -0
  166. recurvedata/operators/transfer_operator/utils.py +134 -0
  167. recurvedata/operators/ui.py +80 -0
  168. recurvedata/operators/utils/__init__.py +51 -0
  169. recurvedata/operators/utils/file_factory.py +150 -0
  170. recurvedata/operators/utils/fs.py +10 -0
  171. recurvedata/operators/utils/lineage.py +265 -0
  172. recurvedata/operators/web_init.py +15 -0
  173. recurvedata/pigeon/connector/__init__.py +294 -0
  174. recurvedata/pigeon/connector/_registry.py +17 -0
  175. recurvedata/pigeon/connector/aliyun_oss.py +80 -0
  176. recurvedata/pigeon/connector/awss3.py +123 -0
  177. recurvedata/pigeon/connector/azure_blob.py +176 -0
  178. recurvedata/pigeon/connector/azure_synapse.py +51 -0
  179. recurvedata/pigeon/connector/cass.py +151 -0
  180. recurvedata/pigeon/connector/clickhouse.py +403 -0
  181. recurvedata/pigeon/connector/clickhouse_native.py +351 -0
  182. recurvedata/pigeon/connector/dbapi.py +571 -0
  183. recurvedata/pigeon/connector/doris.py +166 -0
  184. recurvedata/pigeon/connector/es.py +176 -0
  185. recurvedata/pigeon/connector/feishu.py +1135 -0
  186. recurvedata/pigeon/connector/ftp.py +163 -0
  187. recurvedata/pigeon/connector/google_bigquery.py +283 -0
  188. recurvedata/pigeon/connector/google_cloud_storage.py +130 -0
  189. recurvedata/pigeon/connector/hbase_phoenix.py +108 -0
  190. recurvedata/pigeon/connector/hdfs.py +204 -0
  191. recurvedata/pigeon/connector/hive_impala.py +383 -0
  192. recurvedata/pigeon/connector/microsoft_fabric.py +95 -0
  193. recurvedata/pigeon/connector/mongodb.py +56 -0
  194. recurvedata/pigeon/connector/mssql.py +467 -0
  195. recurvedata/pigeon/connector/mysql.py +175 -0
  196. recurvedata/pigeon/connector/owncloud.py +92 -0
  197. recurvedata/pigeon/connector/postgresql.py +267 -0
  198. recurvedata/pigeon/connector/power_bi.py +179 -0
  199. recurvedata/pigeon/connector/qcloud_cos.py +79 -0
  200. recurvedata/pigeon/connector/redshift.py +123 -0
  201. recurvedata/pigeon/connector/sftp.py +73 -0
  202. recurvedata/pigeon/connector/sqlite.py +42 -0
  203. recurvedata/pigeon/connector/starrocks.py +144 -0
  204. recurvedata/pigeon/connector/tableau.py +162 -0
  205. recurvedata/pigeon/const.py +21 -0
  206. recurvedata/pigeon/csv.py +172 -0
  207. recurvedata/pigeon/docs/datasources-example.json +82 -0
  208. recurvedata/pigeon/docs/images/pigeon_design.png +0 -0
  209. recurvedata/pigeon/docs/lightweight-data-sync-solution.md +111 -0
  210. recurvedata/pigeon/dumper/__init__.py +171 -0
  211. recurvedata/pigeon/dumper/aliyun_sls.py +415 -0
  212. recurvedata/pigeon/dumper/base.py +141 -0
  213. recurvedata/pigeon/dumper/cass.py +213 -0
  214. recurvedata/pigeon/dumper/dbapi.py +346 -0
  215. recurvedata/pigeon/dumper/es.py +112 -0
  216. recurvedata/pigeon/dumper/ftp.py +64 -0
  217. recurvedata/pigeon/dumper/mongodb.py +103 -0
  218. recurvedata/pigeon/handler/__init__.py +4 -0
  219. recurvedata/pigeon/handler/base.py +153 -0
  220. recurvedata/pigeon/handler/csv_handler.py +290 -0
  221. recurvedata/pigeon/loader/__init__.py +87 -0
  222. recurvedata/pigeon/loader/base.py +83 -0
  223. recurvedata/pigeon/loader/csv_to_azure_synapse.py +214 -0
  224. recurvedata/pigeon/loader/csv_to_clickhouse.py +152 -0
  225. recurvedata/pigeon/loader/csv_to_doris.py +215 -0
  226. recurvedata/pigeon/loader/csv_to_es.py +51 -0
  227. recurvedata/pigeon/loader/csv_to_google_bigquery.py +169 -0
  228. recurvedata/pigeon/loader/csv_to_hive.py +468 -0
  229. recurvedata/pigeon/loader/csv_to_microsoft_fabric.py +242 -0
  230. recurvedata/pigeon/loader/csv_to_mssql.py +174 -0
  231. recurvedata/pigeon/loader/csv_to_mysql.py +180 -0
  232. recurvedata/pigeon/loader/csv_to_postgresql.py +248 -0
  233. recurvedata/pigeon/loader/csv_to_redshift.py +240 -0
  234. recurvedata/pigeon/loader/csv_to_starrocks.py +233 -0
  235. recurvedata/pigeon/meta.py +116 -0
  236. recurvedata/pigeon/row_factory.py +42 -0
  237. recurvedata/pigeon/schema/__init__.py +124 -0
  238. recurvedata/pigeon/schema/types.py +13 -0
  239. recurvedata/pigeon/sync.py +283 -0
  240. recurvedata/pigeon/transformer.py +146 -0
  241. recurvedata/pigeon/utils/__init__.py +134 -0
  242. recurvedata/pigeon/utils/bloomfilter.py +181 -0
  243. recurvedata/pigeon/utils/date_time.py +323 -0
  244. recurvedata/pigeon/utils/escape.py +15 -0
  245. recurvedata/pigeon/utils/fs.py +266 -0
  246. recurvedata/pigeon/utils/json.py +44 -0
  247. recurvedata/pigeon/utils/keyed_tuple.py +85 -0
  248. recurvedata/pigeon/utils/mp.py +156 -0
  249. recurvedata/pigeon/utils/sql.py +328 -0
  250. recurvedata/pigeon/utils/timing.py +155 -0
  251. recurvedata/provider_manager.py +0 -0
  252. recurvedata/providers/__init__.py +0 -0
  253. recurvedata/providers/dbapi/__init__.py +0 -0
  254. recurvedata/providers/flywheel/__init__.py +0 -0
  255. recurvedata/providers/mysql/__init__.py +0 -0
  256. recurvedata/schedulers/__init__.py +1 -0
  257. recurvedata/schedulers/airflow.py +974 -0
  258. recurvedata/schedulers/airflow_db_process.py +331 -0
  259. recurvedata/schedulers/airflow_operators.py +61 -0
  260. recurvedata/schedulers/airflow_plugin.py +9 -0
  261. recurvedata/schedulers/airflow_trigger_dag_patch.py +117 -0
  262. recurvedata/schedulers/base.py +99 -0
  263. recurvedata/schedulers/cli.py +228 -0
  264. recurvedata/schedulers/client.py +56 -0
  265. recurvedata/schedulers/consts.py +52 -0
  266. recurvedata/schedulers/debug_celery.py +62 -0
  267. recurvedata/schedulers/model.py +63 -0
  268. recurvedata/schedulers/schemas.py +97 -0
  269. recurvedata/schedulers/service.py +20 -0
  270. recurvedata/schedulers/system_dags.py +59 -0
  271. recurvedata/schedulers/task_status.py +279 -0
  272. recurvedata/schedulers/utils.py +73 -0
  273. recurvedata/schema/__init__.py +0 -0
  274. recurvedata/schema/field.py +88 -0
  275. recurvedata/schema/schema.py +55 -0
  276. recurvedata/schema/types.py +17 -0
  277. recurvedata/schema.py +0 -0
  278. recurvedata/server/__init__.py +0 -0
  279. recurvedata/server/app.py +7 -0
  280. recurvedata/server/connector/__init__.py +0 -0
  281. recurvedata/server/connector/api.py +79 -0
  282. recurvedata/server/connector/schemas.py +28 -0
  283. recurvedata/server/data_service/__init__.py +0 -0
  284. recurvedata/server/data_service/api.py +126 -0
  285. recurvedata/server/data_service/client.py +18 -0
  286. recurvedata/server/data_service/consts.py +1 -0
  287. recurvedata/server/data_service/schemas.py +68 -0
  288. recurvedata/server/data_service/service.py +218 -0
  289. recurvedata/server/dbt/__init__.py +0 -0
  290. recurvedata/server/dbt/api.py +116 -0
  291. recurvedata/server/error_code.py +49 -0
  292. recurvedata/server/exceptions.py +19 -0
  293. recurvedata/server/executor/__init__.py +0 -0
  294. recurvedata/server/executor/api.py +37 -0
  295. recurvedata/server/executor/schemas.py +30 -0
  296. recurvedata/server/executor/service.py +220 -0
  297. recurvedata/server/main.py +32 -0
  298. recurvedata/server/schedulers/__init__.py +0 -0
  299. recurvedata/server/schedulers/api.py +252 -0
  300. recurvedata/server/schedulers/schemas.py +50 -0
  301. recurvedata/server/schemas.py +50 -0
  302. recurvedata/utils/__init__.py +15 -0
  303. recurvedata/utils/_typer.py +61 -0
  304. recurvedata/utils/attrdict.py +19 -0
  305. recurvedata/utils/command_helper.py +20 -0
  306. recurvedata/utils/compat.py +12 -0
  307. recurvedata/utils/compression.py +203 -0
  308. recurvedata/utils/crontab.py +42 -0
  309. recurvedata/utils/crypto_util.py +305 -0
  310. recurvedata/utils/dataclass.py +11 -0
  311. recurvedata/utils/date_time.py +464 -0
  312. recurvedata/utils/dispatch.py +114 -0
  313. recurvedata/utils/email_util.py +104 -0
  314. recurvedata/utils/files.py +386 -0
  315. recurvedata/utils/helpers.py +170 -0
  316. recurvedata/utils/httputil.py +117 -0
  317. recurvedata/utils/imports.py +132 -0
  318. recurvedata/utils/json.py +80 -0
  319. recurvedata/utils/log.py +117 -0
  320. recurvedata/utils/log_capture.py +153 -0
  321. recurvedata/utils/mp.py +178 -0
  322. recurvedata/utils/normalizer.py +102 -0
  323. recurvedata/utils/redis_lock.py +474 -0
  324. recurvedata/utils/registry.py +54 -0
  325. recurvedata/utils/shell.py +15 -0
  326. recurvedata/utils/singleton.py +33 -0
  327. recurvedata/utils/sql.py +6 -0
  328. recurvedata/utils/timeout.py +28 -0
  329. recurvedata/utils/tracing.py +14 -0
  330. recurvedata_lib-0.1.487.dist-info/METADATA +605 -0
  331. recurvedata_lib-0.1.487.dist-info/RECORD +333 -0
  332. recurvedata_lib-0.1.487.dist-info/WHEEL +5 -0
  333. recurvedata_lib-0.1.487.dist-info/entry_points.txt +6 -0
@@ -0,0 +1,206 @@
1
+ import copy
2
+ import datetime
3
+ import inspect
4
+ import re
5
+ import types
6
+ from typing import Any, Callable, TypeVar
7
+
8
+ import jinja2.nodes
9
+ from jinja2 import Environment, TemplateSyntaxError, meta, pass_context
10
+ from jinja2.runtime import Context
11
+
12
+ from recurvedata.utils.crontab import previous_schedule
13
+ from recurvedata.utils.registry import jinja2_template_funcs_registry
14
+
15
+ T = TypeVar("T")
16
+
17
+ # {% set navigation = [('index.html', 'Index'), ('about.html', 'About')] %} -> navigation
18
+ # {% set key, value = call_something() %} -> key, value
19
+ _jinja2_set_p = re.compile(r"\{%\s*set\s([\w,\s]+?)\s*=.*")
20
+
21
+
22
+ def get_template_env() -> Environment:
23
+ env = Environment(
24
+ cache_size=0,
25
+ trim_blocks=True,
26
+ lstrip_blocks=True,
27
+ )
28
+ for func_name, func in jinja2_template_funcs_registry.items():
29
+ env.globals[func_name] = func
30
+ return env
31
+
32
+
33
+ def extract_vars_from_template_code(template_code: str) -> list[str]:
34
+ """
35
+ This function is copied from recurve-server recurve.library.jinja_utils
36
+ """
37
+ env = Environment(autoescape=True)
38
+ ast = env.parse(template_code)
39
+
40
+ extracted_var_names = []
41
+
42
+ # Helper function to recursively walk through nodes
43
+ def visit_node(node: jinja2.nodes.Node):
44
+ # We're looking for Call nodes where the function is 'var'
45
+ if isinstance(node, jinja2.nodes.Call) and isinstance(node.node, jinja2.nodes.Name) and node.node.name == "var":
46
+ # The first argument of the Call node is the variable name
47
+ arguments = [arg.value for arg in node.args if isinstance(arg, jinja2.nodes.Const)]
48
+ extracted_var_names.append(arguments[0])
49
+
50
+ # Recursively visit child nodes
51
+ for child_node in node.iter_child_nodes():
52
+ visit_node(child_node)
53
+
54
+ # Start the traversal
55
+ visit_node(ast)
56
+
57
+ return extracted_var_names
58
+
59
+
60
+ @pass_context
61
+ def var_function(context: Context, name: str, default: Any = None) -> Any:
62
+ return context.get(name, default)
63
+
64
+
65
+ class Renderer(object):
66
+ def __init__(self):
67
+ self.env = get_template_env()
68
+ self.env.globals["var"] = var_function
69
+
70
+ @staticmethod
71
+ def init_context(execution_date: datetime.datetime, schedule_interval: str):
72
+ yesterday_dttm = execution_date - datetime.timedelta(days=1)
73
+ tomorrow_dttm = execution_date + datetime.timedelta(days=1)
74
+ data_interval_start = previous_schedule(schedule_interval, execution_date)
75
+
76
+ template_context = {
77
+ "dt": execution_date.date(),
78
+ "yesterday": yesterday_dttm,
79
+ "yesterday_dt": yesterday_dttm.date(),
80
+ "tomorrow": tomorrow_dttm,
81
+ "tomorrow_dt": tomorrow_dttm.date(),
82
+ # "execution_date": execution_date,
83
+ "logical_date": execution_date,
84
+ "data_interval_start": data_interval_start,
85
+ "data_interval_end": execution_date,
86
+ "data_interval_start_dt": data_interval_start and data_interval_start.date(),
87
+ "data_interval_end_dt": execution_date.date(),
88
+ }
89
+ return template_context
90
+
91
+ @staticmethod
92
+ def get_functions() -> dict[str, Callable]:
93
+ return dict(jinja2_template_funcs_registry.items())
94
+
95
+ def render_template(self, tmpl: T, context: dict) -> T:
96
+ if isinstance(tmpl, str):
97
+ result = self.env.from_string(tmpl).render(**context)
98
+ elif isinstance(tmpl, (tuple, list)):
99
+ result = [self.render_template(x, context) for x in tmpl]
100
+ elif isinstance(tmpl, dict):
101
+ result = {k: self.render_template(v, context) for k, v in tmpl.items()}
102
+ else:
103
+ # raise TypeError(f'Type {type(tmpl)} is not supported for templating')
104
+ result = tmpl
105
+ return result
106
+
107
+ def extract_variables(self, tmpl: str) -> list[str]:
108
+ ast = self.env.parse(tmpl)
109
+ var_variables: list[str] = extract_vars_from_template_code(tmpl)
110
+ variables: set[str] = meta.find_undeclared_variables(ast)
111
+ variables.update(var_variables)
112
+
113
+ # exclude assignments within if blocks.
114
+ # e.g. for this template, the undefined variables are ['yesterday_ds']
115
+ # WITHOUT 'dedup_order', which is a local variable defined by set
116
+ # {% if yesterday_ds <= '2020-09-25' %}
117
+ # {% set dedup_order = "snapshot_time ASC" %}
118
+ # {% else %}
119
+ # {% set dedup_order = "sell_count DESC NULLS LAST" %}
120
+ # {% endif %}
121
+ assignments: list[str] = _jinja2_set_p.findall(tmpl)
122
+ for vs in set(assignments):
123
+ for v in vs.split(","):
124
+ v = v.strip()
125
+ if v in variables:
126
+ variables.remove(v)
127
+
128
+ return sorted(variables)
129
+
130
+ def _prepare_jinja_context(
131
+ self, exist_variables: dict, execution_date: datetime.datetime, schedule_interval: str
132
+ ) -> dict:
133
+ template_var_dct = self.init_context(execution_date, schedule_interval)
134
+ context = copy.copy(template_var_dct) # shallow copy
135
+ context.update(exist_variables) # python code may use exist_variables
136
+ for func_name, func in self.get_functions().items():
137
+ context.setdefault(func_name, func)
138
+
139
+ return context
140
+
141
+ def render_variables(
142
+ self, variables: dict[str, Any], execution_date: datetime.datetime, schedule_interval: str
143
+ ) -> dict:
144
+ """
145
+ Renders variables in a dictionary using Jinja2 templating.
146
+
147
+ This method processes each string value in the input dictionary,
148
+ rendering it with Jinja2 if it contains template variables. The
149
+ rendered values are then updated in the original dictionary.
150
+
151
+ Args:
152
+ variables: A dictionary of variables to render.
153
+ execution_date: a given date that some date function can be used to calculate
154
+ schedule_interval: A string representing the schedule interval (crontab expression).
155
+ Returns:
156
+ A dictionary with the same keys as the input, but with rendered values.
157
+ """
158
+ context = self._prepare_jinja_context(variables, execution_date, schedule_interval)
159
+ update_dct = {}
160
+ for name, val in variables.items():
161
+ if not isinstance(val, str):
162
+ # Process only the Jinja variables within the string.
163
+ continue
164
+ try:
165
+ jinja_variables = self.extract_variables(val)
166
+ except TemplateSyntaxError:
167
+ # invalid jinja, leave it unrendered
168
+ continue
169
+ if not jinja_variables:
170
+ continue
171
+
172
+ rendered_val = self.render_template(val, context)
173
+ if rendered_val != val:
174
+ update_dct[name] = rendered_val
175
+ context[name] = rendered_val
176
+ variables.update(update_dct)
177
+ return variables
178
+
179
+ def extract_python_code_variable(
180
+ self, python_code: str, exist_variables: dict, execution_date: datetime.datetime, schedule_interval: str
181
+ ) -> dict:
182
+ result = {}
183
+ name_space = self._prepare_jinja_context(exist_variables, execution_date, schedule_interval)
184
+ orig_name_space = copy.copy(name_space)
185
+ rendered_code = self.render_template(python_code, name_space)
186
+
187
+ compiled_code = compile(rendered_code, "", "exec")
188
+ exec(compiled_code, name_space)
189
+
190
+ for key2, value2 in name_space.items():
191
+ if key2 == "__builtins__":
192
+ continue
193
+ if isinstance(value2, types.ModuleType):
194
+ continue
195
+ if inspect.isclass(value2):
196
+ continue
197
+ if key2 in orig_name_space and orig_name_space[key2] == value2: # only return defined/updated var
198
+ continue
199
+ result[key2] = value2
200
+ return result
201
+
202
+
203
+ if __name__ == "__main__":
204
+ import doctest
205
+
206
+ doctest.testmod()
@@ -0,0 +1,223 @@
1
+ import asyncio
2
+ import functools
3
+ import inspect
4
+ import json
5
+ import random
6
+ import re
7
+ from typing import Any, Dict, overload
8
+
9
+ from opentelemetry import context, trace
10
+ from opentelemetry.context import Context
11
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
12
+ from opentelemetry.sdk.resources import SERVICE_NAME, Resource
13
+ from opentelemetry.sdk.trace import Span, TracerProvider
14
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter
15
+ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
16
+ from pydantic import BaseModel
17
+
18
+ from .consts import TRACE_KEY, TRACING_CONTEXT_KEY
19
+
20
+
21
+ class _OTLPSpanExporter(OTLPSpanExporter):
22
+ def update_auth_headers(self, headers: Dict[str, str]):
23
+ if not headers:
24
+ return
25
+ self._session.headers.update(headers)
26
+
27
+
28
+ class FilteringSpanProcessor(BatchSpanProcessor):
29
+ """A span processor that filters out unwanted spans before they are created."""
30
+
31
+ def __init__(
32
+ self,
33
+ span_exporter: SpanExporter,
34
+ max_queue_size: int = None,
35
+ schedule_delay_millis: float = None,
36
+ max_export_batch_size: int = None,
37
+ export_timeout_millis: float = None,
38
+ ):
39
+ super().__init__(
40
+ span_exporter,
41
+ schedule_delay_millis=schedule_delay_millis,
42
+ export_timeout_millis=export_timeout_millis,
43
+ max_export_batch_size=max_export_batch_size,
44
+ max_queue_size=max_queue_size,
45
+ )
46
+ # Single compiled pattern with OR operator for better performance
47
+ self._fastapi_pattern = re.compile(r"^(?:handling\s+event|Event\s+.*\s+dispatched$)")
48
+
49
+ def _should_filter_span(self, span: Span) -> bool:
50
+ """Determine if a span should be filtered out using regex pattern."""
51
+ if not span.name:
52
+ return False
53
+ return bool(self._fastapi_pattern.match(span.name))
54
+
55
+ def on_end(self, span: Span) -> None:
56
+ """Called when a span is ended."""
57
+ if not self._should_filter_span(span):
58
+ super().on_end(span)
59
+
60
+
61
+ class Singleton(type):
62
+ _instances = {}
63
+
64
+ def __call__(cls, *args, **kwargs):
65
+ if cls not in cls._instances:
66
+ instance = super().__call__(*args, **kwargs)
67
+ cls._instances[cls] = instance
68
+ return cls._instances[cls]
69
+
70
+
71
+ class Tracing(metaclass=Singleton):
72
+ @classmethod
73
+ def is_instantiated(cls):
74
+ """
75
+ Returns the valid singleton instance if it exists, otherwise None.
76
+ """
77
+ instance: "Tracing" = cls._instances.get(cls, None)
78
+ if not instance:
79
+ return False
80
+ if not instance.tracer:
81
+ return False
82
+ return True
83
+
84
+ def __init__(
85
+ self,
86
+ endpoint: str = None,
87
+ service: str = None,
88
+ export_timeout_millis: int = 1000,
89
+ schedule_delay_millis: int = 1000,
90
+ ):
91
+ if endpoint and service:
92
+ self.init(endpoint, service, export_timeout_millis, schedule_delay_millis)
93
+ else:
94
+ self.exporter = None
95
+ self.tracer = None
96
+
97
+ def init(
98
+ self,
99
+ endpoint: str,
100
+ service: str,
101
+ export_timeout_millis: int = 1000,
102
+ schedule_delay_millis: int = 1000,
103
+ ):
104
+ self.exporter = _OTLPSpanExporter(endpoint=endpoint)
105
+ processor = FilteringSpanProcessor(
106
+ self.exporter, schedule_delay_millis=schedule_delay_millis, export_timeout_millis=export_timeout_millis
107
+ )
108
+ resource = Resource.create(attributes={SERVICE_NAME: service})
109
+ provider = TracerProvider(resource=resource)
110
+ provider.add_span_processor(processor)
111
+ trace.set_tracer_provider(provider)
112
+ self.tracer = trace.get_tracer(service, tracer_provider=provider)
113
+
114
+ def set_auth_headers(self, headers: Dict[str, str]):
115
+ self.exporter.update_auth_headers(headers)
116
+
117
+ def dump_context(self) -> Dict[str, str]:
118
+ carrier = {}
119
+ TraceContextTextMapPropagator().inject(carrier)
120
+ return carrier
121
+
122
+ def load_context(self, ctx_json: Dict[str, str] | None = None) -> Context:
123
+ ctx = TraceContextTextMapPropagator().extract(ctx_json or {})
124
+ context.attach(ctx)
125
+
126
+ def send_context(self, payload: dict):
127
+ payload[TRACE_KEY] = self.dump_context()
128
+ return payload
129
+
130
+ @overload
131
+ def receive_context(self, payload: dict):
132
+ ...
133
+
134
+ @overload
135
+ def receive_context(self, payload: str):
136
+ ...
137
+
138
+ @overload
139
+ def receive_context(self, payload: Any):
140
+ ...
141
+
142
+ def receive_context(self, payload: str | dict | BaseModel | Any):
143
+ if isinstance(payload, str):
144
+ try:
145
+ payload = json.loads(payload)
146
+ except Exception:
147
+ return
148
+ elif isinstance(payload, BaseModel):
149
+ payload = payload.model_dump(by_alias=True)
150
+ if not payload.get(TRACING_CONTEXT_KEY, None):
151
+ return
152
+ payload = payload.get(TRACING_CONTEXT_KEY)
153
+ try:
154
+ payload = json.loads(payload)
155
+ except Exception:
156
+ return
157
+ elif isinstance(payload, dict):
158
+ pass
159
+ else:
160
+ from fastapi import Request
161
+
162
+ if isinstance(payload, Request):
163
+ payload = payload.headers
164
+ else:
165
+ return
166
+
167
+ if TRACE_KEY in payload:
168
+ self.load_context(payload.get(TRACE_KEY))
169
+
170
+ def create_span(self, name: str = None, sampling_rate: float = 0.0, context_payload_name: str = None):
171
+ """
172
+ Decorator to create a span for a function.
173
+
174
+ Args:
175
+ name: The name of the span. If not provided, the function name will be used.
176
+ sampling_rate: The rate at which to sample the span. Default is 0.0.
177
+ context_payload_name: The name of the argument that contains the context payload."""
178
+
179
+ def decorator(func):
180
+ def get_all_args_as_dict(func, args, kwargs):
181
+ """Helper to get all arguments as a dictionary."""
182
+ sig = inspect.signature(func)
183
+ bound_args = sig.bind(*args, **kwargs)
184
+ bound_args.apply_defaults()
185
+ return dict(bound_args.arguments)
186
+
187
+ @functools.wraps(func)
188
+ async def async_wrapper(*args, **kwargs):
189
+ all_args = get_all_args_as_dict(func, args, kwargs)
190
+ if not self.tracer:
191
+ return await func(*args, **kwargs)
192
+ if context_payload_name:
193
+ if all_args.get(context_payload_name, None):
194
+ self.receive_context(all_args[context_payload_name])
195
+ span_name = name or f"{func.__module__}.{func.__name__}"
196
+ if not context.get_current():
197
+ if random.random() > sampling_rate:
198
+ return await func(*args, **kwargs)
199
+ with self.tracer.start_as_current_span(span_name):
200
+ return await func(*args, **kwargs)
201
+
202
+ @functools.wraps(func)
203
+ def wrapper(*args, **kwargs):
204
+ all_args = get_all_args_as_dict(func, args, kwargs)
205
+ if not self.tracer:
206
+ return func(*args, **kwargs)
207
+ if context_payload_name:
208
+ if all_args.get(context_payload_name, None):
209
+ self.receive_context(all_args[context_payload_name])
210
+ span_name = name or f"{func.__module__}.{func.__name__}"
211
+ if not context.get_current():
212
+ if random.random() > sampling_rate:
213
+ return func(*args, **kwargs)
214
+ with self.tracer.start_as_current_span(span_name):
215
+ return func(*args, **kwargs)
216
+
217
+ return async_wrapper if asyncio.iscoroutinefunction(func) else wrapper
218
+
219
+ return decorator
220
+
221
+ @property
222
+ def current_span(self) -> Span:
223
+ return trace.get_current_span()
@@ -0,0 +1,186 @@
1
+ import json
2
+ import struct
3
+ import zlib
4
+ from typing import Literal
5
+
6
+ from recurvedata.pigeon.schema import Schema
7
+ from recurvedata.utils.crypto_util import CryptoUtil
8
+
9
+
10
+ class Transformer:
11
+ @property
12
+ def input_schema(self):
13
+ """Returns the schema of input data"""
14
+ return getattr(self, "_input_schema", None)
15
+
16
+ @input_schema.setter
17
+ def input_schema(self, schema):
18
+ """Should be called by the handler"""
19
+ assert isinstance(schema, Schema)
20
+ setattr(self, "_input_schema", schema)
21
+
22
+ @property
23
+ def output_schema(self):
24
+ """Subclasses that change the rows schema should provide the output schema.
25
+
26
+ These operations will change the output schema:
27
+ - Add or remove fields
28
+ - Change the name of fields
29
+ - Change the type of fields
30
+
31
+ An example of valid schema:
32
+
33
+ from recurvedata.pigeon.schema import Schema, Field, types
34
+
35
+ Schema([
36
+ Field(name='id', type=types.INT32),
37
+ Field(name='name', type=types.STRING, size=64),
38
+ Field(name='snapshot_time', type=types.DATETIME),
39
+ Field(name='is_active', type=types.BOOLEAN)
40
+ ])
41
+
42
+ Allowed types:
43
+
44
+ - INT8 = 'INT8' # 1-byte (8-bit) signed integers
45
+ - INT16 = 'INT16' # 2-byte (16-bit) signed integers
46
+ - INT32 = 'INT32' # 4-byte (32-bit) signed integers
47
+ - INT64 = 'INT64' # 8-byte (64-bit) signed integers
48
+ - FLOAT32 = 'FLOAT32' # 4-byte (32-bit) single-precision floating
49
+ - FLOAT64 = 'FLOAT64' # 8-byte (64-bit) double-precision floating
50
+ - BOOLEAN = 'BOOLEAN'
51
+ - DATETIME = 'DATETIME'
52
+ - DATE = 'DATE'
53
+ - STRING = 'STRING'
54
+ """
55
+ return None
56
+
57
+ def transform(self, row: dict, *args, **kwargs) -> dict | list[dict] | None:
58
+ """This is the method called by Handler.
59
+
60
+ It internally calls `transform_impl` to do the real transform logic.
61
+ Subclasses should implement `transform_impl` but not this method.
62
+
63
+ :param row: a Row (namedtuple) object contains a row record fetched from database
64
+ :type row: collection.namedtuple
65
+ :returns: returns one (tuple) or multiple (list of tuple) rows
66
+ """
67
+ return self.transform_impl(row, *args, **kwargs)
68
+
69
+ def transform_impl(self, row: dict, *args, **kwargs) -> dict | list[dict] | None:
70
+ """subclass should override this method to implement the custom transform operations"""
71
+ return row
72
+
73
+ @staticmethod
74
+ def convert_json_to_hive_map(data: str | dict) -> str:
75
+ from recurvedata.connectors.connectors.hive import ( # lazy import
76
+ HIVE_MAP_ITEM_DELIMITER,
77
+ HIVE_MAP_KV_DELIMITER,
78
+ HIVE_NULL,
79
+ )
80
+
81
+ if not data:
82
+ return HIVE_NULL
83
+
84
+ if isinstance(data, str):
85
+ d = json.loads(data)
86
+ else:
87
+ d = data
88
+
89
+ items = []
90
+ for key, value in d.items():
91
+ key = str(key).strip()
92
+ value = str(value).strip()
93
+ item = f"{key}{HIVE_MAP_KV_DELIMITER}{value}"
94
+ items.append(item)
95
+ return HIVE_MAP_ITEM_DELIMITER.join(items)
96
+
97
+ @staticmethod
98
+ def convert_json_to_hive_array(data: str | list) -> str:
99
+ from recurvedata.connectors.connectors.hive import HIVE_ARRAY_DELIMITER, HIVE_NULL
100
+
101
+ if not data:
102
+ return HIVE_NULL
103
+
104
+ if isinstance(data, str):
105
+ items = json.loads(data)
106
+ else:
107
+ items = data
108
+
109
+ return HIVE_ARRAY_DELIMITER.join(items)
110
+
111
+ @staticmethod
112
+ def mysql_uncompress(value: bytes, return_str=False) -> str | bytes:
113
+ """An Python implementation of UNCOMPRESS function of MySQL.
114
+
115
+ Used to decompress result of COMPRESS function.
116
+
117
+ https://dev.mysql.com/doc/refman/5.7/en/encryption-functions.html#function_compress
118
+
119
+ :param value: the compressed data in bytes
120
+ :type value: bytes
121
+ :param return_str: the return value should be unicode
122
+ :type return_str: bool
123
+ :rtype: bytes | str
124
+ """
125
+
126
+ # Empty strings are stored as empty strings.
127
+ # Nonempty strings are stored as a 4-byte length of the uncompressed string
128
+ if not value or len(value) < 4:
129
+ return value
130
+
131
+ rv = zlib.decompress(value[4:])
132
+
133
+ if return_str:
134
+ rv = rv.decode()
135
+ return rv
136
+
137
+ @staticmethod
138
+ def mysql_compress(value: str) -> bytes | None:
139
+ if value is None:
140
+ return None
141
+ if value == "":
142
+ return b""
143
+ size = struct.pack("I", len(value))
144
+ data = zlib.compress(value.encode())
145
+ return size + data
146
+
147
+ @staticmethod
148
+ def json_loads(*args, **kwargs):
149
+ return json.loads(*args, **kwargs)
150
+
151
+ @staticmethod
152
+ def json_dumps(*args, **kwargs) -> str:
153
+ return json.dumps(*args, **kwargs)
154
+
155
+ def aes_encrypt(
156
+ self, key_name: str, data: str | bytes, mode: Literal["ECB", "CBC"] = "ECB", iv: str | bytes = None
157
+ ) -> str:
158
+ return CryptoUtil.base64_encode(CryptoUtil.aes_encrypt(key_name, data, mode, iv))
159
+
160
+ def aes_decrypt(self, key_name: str, data: bytes | str) -> str:
161
+ if isinstance(data, str):
162
+ data = CryptoUtil.base64_decode(data)
163
+ return CryptoUtil.aes_decrypt(key_name, data)
164
+
165
+ def rsa_encrypt(self, key_name: str, data: str | bytes) -> str:
166
+ return CryptoUtil.base64_encode(CryptoUtil.rsa_encrypt(key_name, data))
167
+
168
+ def rsa_decrypt(self, key_name: str, data: bytes | str) -> str:
169
+ if isinstance(data, str):
170
+ data = CryptoUtil.base64_decode(data)
171
+ return CryptoUtil.rsa_decrypt(key_name, data)
172
+
173
+ def base64_encode(self, data: str | bytes) -> str:
174
+ return CryptoUtil.base64_encode(data)
175
+
176
+ def base64_decode(self, data: str | bytes) -> str:
177
+ return CryptoUtil.base64_decode(data)
178
+
179
+ def md5(self, data: str | bytes) -> str:
180
+ return CryptoUtil.md5(data)
181
+
182
+ def sha1(self, data: str | bytes) -> str:
183
+ return CryptoUtil.sha1(data)
184
+
185
+ def sha256(self, data: str | bytes) -> str:
186
+ return CryptoUtil.sha256(data)
@@ -0,0 +1,91 @@
1
+ from contextvars import ContextVar
2
+ from gettext import NullTranslations
3
+ from typing import Any, Union
4
+
5
+ _current_translations: ContextVar[NullTranslations] = ContextVar("_current_translations", default=None)
6
+
7
+
8
+ class Translator:
9
+ _translations_cache: dict[str, NullTranslations] = {}
10
+ _translations_dir: str = "locale"
11
+
12
+ @classmethod
13
+ def set_translations_dir(cls, translations_dir: str):
14
+ cls._translations_dir = translations_dir
15
+
16
+ @classmethod
17
+ def _get_translations(cls, locale: str) -> NullTranslations:
18
+ """Get translations for the given locale."""
19
+ from babel.support import Translations
20
+
21
+ if locale not in cls._translations_cache:
22
+ try:
23
+ translations = Translations.load(dirname=cls._translations_dir, locales=[locale])
24
+ cls._translations_cache[locale] = translations
25
+ except FileNotFoundError:
26
+ return None
27
+ return cls._translations_cache[locale]
28
+
29
+ @classmethod
30
+ def set_locale(cls, locale: str):
31
+ """Set locale for the current context."""
32
+ translations = cls._get_translations(locale)
33
+ if translations:
34
+ translations.install()
35
+ _current_translations.set(translations)
36
+
37
+ @classmethod
38
+ def gettext(cls, message: str) -> str:
39
+ """Translate a message based on the current locale."""
40
+ translations = _current_translations.get()
41
+ if translations:
42
+ return translations.gettext(message)
43
+ return message
44
+
45
+
46
+ class LazyString:
47
+ def __init__(self, message: str):
48
+ self.message = message
49
+
50
+ def __str__(self) -> str:
51
+ return Translator.gettext(self.message)
52
+
53
+ def __hash__(self):
54
+ return hash(str(self))
55
+
56
+ def __eq__(self, other: Union["LazyString", str]):
57
+ if isinstance(other, LazyString):
58
+ return str(self) == str(other)
59
+ return str(self) == other
60
+
61
+ def format(self, *args, **kwargs):
62
+ return str(self).format(*args, **kwargs)
63
+
64
+
65
+ def convert_lazy_string(v: Any) -> Any:
66
+ """Recursively convert LazyString instances to str in nested data structures.
67
+
68
+ This function traverses dictionaries, lists and other data structures recursively,
69
+ converting any LazyString instances to regular strings. This is needed because
70
+ JsonSchema and Pydantic (see Pydantic issue #8439) do not support LazyString.
71
+
72
+ Args:
73
+ v: The value to convert. Can be a LazyString, dict, list, or any other type.
74
+
75
+ Returns:
76
+ The input value with all LazyString instances converted to str. The structure
77
+ of the input (dict/list/etc) is preserved.
78
+ """
79
+ if isinstance(v, dict):
80
+ return {k: convert_lazy_string(v) for k, v in v.items()}
81
+ if isinstance(v, list):
82
+ return [convert_lazy_string(v) for v in v]
83
+ if isinstance(v, LazyString):
84
+ return str(v)
85
+ return v
86
+
87
+
88
+ gettext = Translator.gettext
89
+ lazy_gettext = LazyString
90
+ _ = Translator.gettext
91
+ _l = LazyString