tinybird 4.6.12.dev0__tar.gz → 4.6.13__tar.gz

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.
Files changed (127) hide show
  1. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/PKG-INFO +16 -1
  2. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/client.py +34 -10
  3. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/datafile/common.py +20 -10
  4. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/datafile/parse_pipe.py +30 -6
  5. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/feedback_manager.py +3 -0
  6. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/service_datasources.py +3 -3
  7. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/sql_template.py +83 -4
  8. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/sql_toolset.py +26 -7
  9. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/__cli__.py +2 -2
  10. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/client.py +25 -0
  11. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/common.py +15 -1
  12. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/config.py +1 -1
  13. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/datafile/build.py +1 -1
  14. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/datafile/pipe_checker.py +6 -2
  15. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/datafile/playground.py +1 -1
  16. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/datasource.py +72 -4
  17. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb_cli_modules/common.py +15 -1
  18. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb_cli_modules/config.py +1 -1
  19. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb_cli_modules/datasource.py +68 -4
  20. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tornado_template.py +5 -5
  21. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird.egg-info/PKG-INFO +16 -1
  22. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/setup.cfg +0 -0
  23. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/__cli__.py +0 -0
  24. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/ch_utils/constants.py +0 -0
  25. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/ch_utils/engine.py +0 -0
  26. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/check_pypi.py +0 -0
  27. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/config.py +0 -0
  28. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/context.py +0 -0
  29. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/datafile/exceptions.py +0 -0
  30. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/datafile/parse_connection.py +0 -0
  31. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/datafile/parse_datasource.py +0 -0
  32. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/datatypes.py +0 -0
  33. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/git_settings.py +0 -0
  34. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/iterating/__init__.py +0 -0
  35. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/iterating/data_branch_modes.py +0 -0
  36. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/prompts.py +0 -0
  37. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/sql.py +0 -0
  38. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/sql_template_fmt.py +0 -0
  39. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/syncasync.py +0 -0
  40. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/check_pypi.py +0 -0
  41. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/cli.py +0 -0
  42. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/config.py +0 -0
  43. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/branch.py +0 -0
  44. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/build.py +0 -0
  45. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/build_common.py +0 -0
  46. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/cicd.py +0 -0
  47. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/cli.py +0 -0
  48. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/connection.py +0 -0
  49. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/connection_dynamodb.py +0 -0
  50. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/connection_kafka.py +0 -0
  51. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/connection_s3.py +0 -0
  52. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/copy.py +0 -0
  53. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/create.py +0 -0
  54. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/datafile/build_common.py +0 -0
  55. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/datafile/build_datasource.py +0 -0
  56. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/datafile/build_pipe.py +0 -0
  57. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/datafile/diff.py +0 -0
  58. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/datafile/fixture.py +0 -0
  59. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/datafile/format_common.py +0 -0
  60. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/datafile/format_connection.py +0 -0
  61. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/datafile/format_datasource.py +0 -0
  62. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/datafile/format_pipe.py +0 -0
  63. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/datafile/pull.py +0 -0
  64. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/deployment.py +0 -0
  65. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/deployment_common.py +0 -0
  66. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/deprecations.py +0 -0
  67. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/endpoint.py +0 -0
  68. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/exceptions.py +0 -0
  69. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/feedback_manager.py +0 -0
  70. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/fmt.py +0 -0
  71. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/info.py +0 -0
  72. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/infra.py +0 -0
  73. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/job.py +0 -0
  74. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/job_common.py +0 -0
  75. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/llm.py +0 -0
  76. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/llm_utils.py +0 -0
  77. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/local.py +0 -0
  78. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/local_common.py +0 -0
  79. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/local_logs.py +0 -0
  80. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/login.py +0 -0
  81. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/login_common.py +0 -0
  82. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/logout.py +0 -0
  83. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/logs.py +0 -0
  84. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/materialization.py +0 -0
  85. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/open.py +0 -0
  86. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/pipe.py +0 -0
  87. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/preview.py +0 -0
  88. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/project.py +0 -0
  89. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/project_commands.py +0 -0
  90. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/py_project.py +0 -0
  91. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/query_output.py +0 -0
  92. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/regions.py +0 -0
  93. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/secret.py +0 -0
  94. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/secret_common.py +0 -0
  95. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/sink.py +0 -0
  96. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/table.py +0 -0
  97. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/telemetry.py +0 -0
  98. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/test.py +0 -0
  99. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/test_common.py +0 -0
  100. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/tinyunit/tinyunit.py +0 -0
  101. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/token.py +0 -0
  102. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/ts_project.py +0 -0
  103. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/watch.py +0 -0
  104. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/workspace.py +0 -0
  105. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb/modules/workspace_members.py +0 -0
  106. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb_cli.py +0 -0
  107. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb_cli_modules/auth.py +0 -0
  108. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb_cli_modules/branch.py +0 -0
  109. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb_cli_modules/cicd.py +0 -0
  110. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb_cli_modules/cli.py +0 -0
  111. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb_cli_modules/connection.py +0 -0
  112. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb_cli_modules/exceptions.py +0 -0
  113. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb_cli_modules/fmt.py +0 -0
  114. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb_cli_modules/job.py +0 -0
  115. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb_cli_modules/pipe.py +0 -0
  116. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb_cli_modules/regions.py +0 -0
  117. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb_cli_modules/tag.py +0 -0
  118. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb_cli_modules/telemetry.py +0 -0
  119. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb_cli_modules/test.py +0 -0
  120. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb_cli_modules/tinyunit/tinyunit.py +0 -0
  121. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb_cli_modules/workspace.py +0 -0
  122. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird/tb_cli_modules/workspace_members.py +0 -0
  123. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird.egg-info/SOURCES.txt +0 -0
  124. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird.egg-info/dependency_links.txt +0 -0
  125. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird.egg-info/entry_points.txt +0 -0
  126. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird.egg-info/requires.txt +0 -0
  127. {tinybird-4.6.12.dev0 → tinybird-4.6.13}/tinybird.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: tinybird
3
- Version: 4.6.12.dev0
3
+ Version: 4.6.13
4
4
  Summary: Tinybird Command Line Tool
5
5
  Home-page: https://www.tinybird.co/docs/forward/commands
6
6
  Author: Tinybird
@@ -52,6 +52,19 @@ The Tinybird command-line tool allows you to use all the Tinybird functionality
52
52
  Changelog
53
53
  ----------
54
54
 
55
+ 4.6.13
56
+ ********
57
+
58
+ - `Fixed` `tb deploy` and `tb build` now reject pipes that declare a parameter without a valid name (e.g. a numeric-literal cast like ``{{Float32(1682892000.0)}}``), instead of creating an unusable parameter.
59
+ - `Added` ``tb datasource append`` and ``tb datasource replace`` support the experimental ``--experimental=use_v1`` option for local CSV, NDJSON, and Parquet files. It submits the import through the v1 endpoint and prints the queued job ID; pass ``--wait`` to wait for completion.
60
+
61
+ 4.6.12
62
+ ********
63
+
64
+ - `Added` Forward sink pipes support ``{{ tb_secret(...) }}`` in ``EXPORT_*`` metadata (for example ``EXPORT_BUCKET_URI``). Secrets are resolved at deploy time and baked into the sink settings; changing a secret requires a redeploy.
65
+ - `Fixed` Forward deploy now raises an error when a secret used in sink metadata resolves to an empty value.
66
+ - `Fixed` Forward deploy now rejects ``{{ tb_secret(...) }}`` in ``EXPORT_SCHEDULE`` and ``COPY_SCHEDULE``. Use a literal cron expression (for example ``0 * * * *``) or ``@on-demand``.
67
+
55
68
  4.6.11
56
69
  ********
57
70
 
@@ -62,6 +75,7 @@ Changelog
62
75
 
63
76
  - `Removed` `tinybird.bi_stats_rt` from `tb logs` sources. The BI Connector `bi_stats` / `bi_stats_rt` service datasources are deprecated.
64
77
 
78
+
65
79
  4.6.9
66
80
  *******
67
81
 
@@ -70,6 +84,7 @@ Changelog
70
84
  4.6.8
71
85
  *******
72
86
 
87
+ - `Changed` Upgraded Tornado to 6.5.5.
73
88
  - `Fixed` Tinybird Local CLI commands to respect Git branch local workspaces correctly.
74
89
 
75
90
  4.6.7
@@ -441,6 +441,7 @@ class TinyB:
441
441
  sql_condition: Optional[str] = None,
442
442
  format: str = "csv",
443
443
  replace_options: Optional[Set[str]] = None,
444
+ use_v1: bool = False,
444
445
  ):
445
446
  params = {"name": datasource_name, "mode": mode, "format": format, "debug": "blocks_block_log"}
446
447
 
@@ -452,20 +453,43 @@ class TinyB:
452
453
 
453
454
  async with aiofiles.open(file, "rb") as content:
454
455
  file_content = await content.read()
455
- if format == "csv":
456
- files = {"csv": ("csv", file_content)}
457
- else:
458
- files = {"ndjson": ("ndjson", file_content)}
459
456
 
460
- res = await self._req(
461
- f"v0/datasources?{urlencode(params, safe='')}",
462
- files=files,
457
+ if use_v1:
458
+ v1_params = {"format": format}
459
+ if sql_condition:
460
+ v1_params["replace_condition"] = sql_condition
461
+ if replace_options:
462
+ for option in list(replace_options):
463
+ v1_params[option] = "true"
464
+ content_types = {
465
+ "csv": "text/csv",
466
+ "ndjson": "application/x-ndjson",
467
+ "parquet": "application/vnd.apache.parquet",
468
+ }
469
+ headers = {"Content-Type": content_types[format]}
470
+ if str(file).endswith(".gz"):
471
+ headers["Content-Encoding"] = "gzip"
472
+ return await self._req(
473
+ f"/v1/datasources/{quote(datasource_name, safe='')}/{mode}?{urlencode(v1_params, safe='')}",
474
+ data=file_content,
475
+ headers=headers,
463
476
  method="POST",
464
477
  )
465
- if status_callback:
466
- status_callback(res)
467
478
 
468
- return res
479
+ if format == "csv":
480
+ files = {"csv": ("csv", file_content)}
481
+ else:
482
+ files = {"ndjson": ("ndjson", file_content)}
483
+
484
+ res = await self._req(
485
+ f"v0/datasources?{urlencode(params, safe='')}",
486
+ files=files,
487
+ method="POST",
488
+ )
489
+ if status_callback:
490
+ status_callback(res)
491
+
492
+ return res
469
493
 
470
494
  async def datasource_truncate(self, datasource_name: str):
471
495
  return await self._req(f"/v0/datasources/{datasource_name}/truncate", method="POST", data="")
@@ -401,12 +401,15 @@ class Datafile:
401
401
  if node.get("mode") and node["mode"] not in ["append", "replace"]:
402
402
  raise DatafileValidationError("COPY node mode must be append or replace")
403
403
  # copy schedule must be @on-demand or a cron-expression
404
- if (
405
- node.get("copy_schedule")
406
- and node["copy_schedule"] != ON_DEMAND
407
- and not croniter.is_valid(node["copy_schedule"])
408
- ):
409
- raise DatafileValidationError("COPY node schedule must be @on-demand or a valid cron expression.")
404
+ copy_schedule = node.get("copy_schedule")
405
+ if copy_schedule and copy_schedule != ON_DEMAND:
406
+ if isinstance(copy_schedule, str) and "tb_secret" in copy_schedule:
407
+ raise DatafileValidationError(
408
+ "COPY_SCHEDULE does not support { tb_secret(...) }. "
409
+ 'Use a literal cron expression (for example "0 * * * *") or @on-demand.'
410
+ )
411
+ if not croniter.is_valid(copy_schedule):
412
+ raise DatafileValidationError("COPY node schedule must be @on-demand or a valid cron expression.")
410
413
  for key in node.keys():
411
414
  if key not in CopyParameters.valid_params():
412
415
  raise DatafileValidationError(
@@ -503,10 +506,16 @@ class Datafile:
503
506
 
504
507
  # Validate schedule format (common for both Kafka and S3/GCS)
505
508
  export_schedule = node.get("export_schedule")
506
- if export_schedule and export_schedule != ON_DEMAND and not croniter.is_valid(export_schedule):
507
- raise DatafileValidationError(
508
- f"Sink node {repr(node['name'])} has invalid export_schedule '{export_schedule}'. Must be @on-demand or a valid cron expression."
509
- )
509
+ if export_schedule and export_schedule != ON_DEMAND:
510
+ if isinstance(export_schedule, str) and "tb_secret" in export_schedule:
511
+ raise DatafileValidationError(
512
+ f"Sink node {repr(node['name'])}: EXPORT_SCHEDULE does not support {{ tb_secret(...) }}. "
513
+ 'Use a literal cron expression (for example "0 * * * *") or @on-demand.'
514
+ )
515
+ if not croniter.is_valid(export_schedule):
516
+ raise DatafileValidationError(
517
+ f"Sink node {repr(node['name'])} has invalid export_schedule '{export_schedule}'. Must be @on-demand or a valid cron expression."
518
+ )
510
519
 
511
520
  def validate(self):
512
521
  if self.kind == DatafileKind.pipe:
@@ -2082,6 +2091,7 @@ def parse(
2082
2091
  "sql": sql("sql"),
2083
2092
  "version": version,
2084
2093
  "deployment_method": assign_var("deployment_method", allowed_values={"alter"}),
2094
+ "export_service": export_service, # Deprecated
2085
2095
  "export_connection_name": assign_var("export_connection_name"),
2086
2096
  "export_schedule": assign_var("export_schedule"),
2087
2097
  "export_bucket_uri": assign_var("export_bucket_uri"),
@@ -11,7 +11,11 @@ from tinybird.datafile.common import (
11
11
  parse,
12
12
  )
13
13
  from tinybird.datafile.exceptions import IncludeFileNotFoundException, ParseException
14
- from tinybird.sql_template import get_template_and_variables, render_sql_template
14
+ from tinybird.sql_template import (
15
+ get_template_and_variables,
16
+ render_sql_template,
17
+ validate_template_parameter_names,
18
+ )
15
19
  from tinybird.tb.modules.feedback_manager import FeedbackManager
16
20
  from tinybird.tornado_template import UnClosedIfError
17
21
 
@@ -52,20 +56,38 @@ def parse_pipe(
52
56
  if "type" in node:
53
57
  node["type"] = node["type"].lower()
54
58
  sql = node.get("sql", "")
55
- if sql.strip()[0] == "%":
59
+ source_sql = sql.strip()
60
+ if not source_sql:
61
+ raise click.ClickException(
62
+ FeedbackManager.error_parsing_node(node=node["name"], pipe=filename, error="Empty query")
63
+ )
64
+ if source_sql.startswith("%"):
56
65
  secrets_list: Optional[List[str]] = None
57
66
  if secrets:
58
67
  secrets_list = list(secrets.keys())
68
+ # Reject parameters declared without a usable name (e.g. a numeric
69
+ # literal cast like `{{Float32(1682892000.0)}}`) before rendering.
70
+ validate_template_parameter_names(sql[1:], node_id=node["name"])
59
71
  # Setting test_mode=True to ignore errors on required parameters and
60
72
  # secrets_in_test_mode=False to raise errors on missing secrets
61
73
  sql, _, variable_warnings = render_sql_template(
62
- sql[1:],
74
+ source_sql[1:],
63
75
  name=node["name"],
64
76
  secrets=secrets_list,
65
77
  test_mode=True,
66
78
  secrets_in_test_mode=ignore_secrets,
67
79
  )
68
80
  doc.warnings = variable_warnings
81
+ if not sql.strip():
82
+ raise click.ClickException(
83
+ FeedbackManager.error_parsing_node(
84
+ node=node["name"],
85
+ pipe=filename,
86
+ error="Template renders to an empty query without parameters",
87
+ )
88
+ )
89
+ else:
90
+ sql = source_sql
69
91
  # it'll fail with a ModuleNotFoundError when the toolset is not available but it returns the parsed doc
70
92
  from tinybird.sql_toolset import format_sql as toolset_format_sql
71
93
 
@@ -79,13 +101,15 @@ def parse_pipe(
79
101
  )
80
102
  )
81
103
  except ValueError as e:
82
- t, template_variables, _ = get_template_and_variables(sql, name=node["name"])
104
+ source_sql = node.get("sql", "")
105
+ source_sql_stripped = source_sql.strip()
106
+ t, template_variables, _ = get_template_and_variables(source_sql, name=node["name"])
83
107
 
84
- if sql.strip()[0] != "%" and len(template_variables) > 0:
108
+ if not source_sql_stripped.startswith("%") and len(template_variables) > 0:
85
109
  raise click.ClickException(FeedbackManager.error_template_start(filename=filename))
86
110
  raise click.ClickException(
87
111
  FeedbackManager.error_parsing_file(
88
- filename=filename, lineno="", error=f"{str(e)} + SQL(value error): {sql}"
112
+ filename=filename, lineno="", error=f"{str(e)} + SQL(value error): {source_sql}"
89
113
  )
90
114
  )
91
115
  except UnClosedIfError as e:
@@ -864,6 +864,9 @@ class FeedbackManager:
864
864
  success_total_rows = success_message("** Total rows in {datasource}: {total_rows}")
865
865
  success_appended_datasource = success_message("** Data appended to Data Source '{datasource}' successfully!")
866
866
  success_replaced_datasource = success_message("** Data replaced in Data Source '{datasource}' successfully!")
867
+ success_import_job_queued = success_message("** {operation} import job queued: {job_id}")
868
+ success_import_job_completed = success_message("** {operation} import completed: {job_id}")
869
+ info_import_job_status = info_message("Check status: tb job details {job_id}")
867
870
  success_auth = success_message(
868
871
  "** Auth successful! \n** Configuration written to .tinyb file, consider adding it to .gitignore"
869
872
  )
@@ -19,12 +19,12 @@ def get_tinybird_service_datasources() -> List[Dict[str, Any]]:
19
19
  return [
20
20
  {
21
21
  "name": "tinybird.pipe_stats_rt",
22
- "description": "Contains information about all requests made to your API endpoints in real time. This data source has a TTL of 7 days. If you need to query data older than 7 days you must use the aggregated by day data available at tinybird.pipe_stats.",
22
+ "description": "Contains information about all requests made to your API endpoints in real time. This data source has a TTL of 3 months. If you need to query data older than 3 months you must use the aggregated by day data available at tinybird.pipe_stats.",
23
23
  "dateColumn": "start_datetime",
24
24
  "engine": {
25
25
  "engine": "MergeTree",
26
26
  "sorting_key": "pipe_id, start_datetime",
27
- "ttl": "start_datetime + toIntervalDay(7)",
27
+ "ttl": "start_datetime + toIntervalMonth(3)",
28
28
  },
29
29
  "columns": [
30
30
  {"name": "start_datetime", "type": "DateTime"},
@@ -659,7 +659,7 @@ def get_organization_service_datasources() -> List[Dict[str, Any]]:
659
659
  "engine": {
660
660
  "engine": "MergeTree",
661
661
  "sorting_key": "workspace_id, pipe_id, start_datetime",
662
- "ttl": "start_datetime + toIntervalDay(7)",
662
+ "ttl": "start_datetime + toIntervalMonth(3)",
663
663
  },
664
664
  "columns": [
665
665
  {"name": "workspace_id", "type": "String"},
@@ -1396,7 +1396,7 @@ _namespace = {
1396
1396
  }
1397
1397
 
1398
1398
 
1399
- reserved_vars = set(["_tt_tmp", "_tt_append", "isinstance", "str", "error", "custom_error", *list(vars(builtins))])
1399
+ reserved_vars = {"_tt_tmp", "_tt_append", "isinstance", "str", "error", "custom_error", *list(vars(builtins))}
1400
1400
  for p in DEFAULT_PARAM_NAMES: # we handle these in an specific manner
1401
1401
  reserved_vars.discard(p) # `format` is part of builtins
1402
1402
  # Allow 'id' to be used as a template parameter - https://gitlab.com/tinybird/analytics/-/issues/19119
@@ -2263,7 +2263,12 @@ def get_var_names_and_types(t: Template, node_id: Optional[str] = None) -> List[
2263
2263
  vars_out.extend(
2264
2264
  parse_statement_code_if_new_vars(statement_code, skip_names=typed_names | statement_expr_names)
2265
2265
  )
2266
- return vars_out
2266
+ # Inside a {% %} statement a type function with a literal first arg
2267
+ # (e.g. {% set x = Float32(1.5) %}) is a plain value cast: the
2268
+ # assigned variable is the parameter, the literal is not. Drop
2269
+ # these artifacts so they don't surface as bogus params nor get
2270
+ # rejected by validate_template_parameter_names.
2271
+ return [vd for vd in vars_out if isinstance(vd["name"], str) and vd["name"]]
2267
2272
 
2268
2273
  def _n(chunks: list, vars_out: list[dict[str, Any]]) -> None:
2269
2274
  for x in chunks:
@@ -2310,6 +2315,43 @@ def get_var_names_and_types_cached(t: Template):
2310
2315
  return get_var_names_and_types(t)
2311
2316
 
2312
2317
 
2318
+ def validate_template_parameter_names(sql: str, node_id: Optional[str] = None) -> None:
2319
+ """Reject template parameters that don't have a usable name.
2320
+
2321
+ Type functions such as ``{{Float32(x)}}`` double as casts, so a literal
2322
+ first argument (e.g. ``{{Float32(1682892000.0)}}`` or ``{{String('')}}``)
2323
+ is parsed as a parameter whose name is a number or an empty string instead
2324
+ of an identifier. Such a "parameter" can never be supplied by a caller and
2325
+ breaks parameter metadata consumers, so it must be rejected at write time.
2326
+
2327
+ Raises ``ValueError`` with the same wording used elsewhere for invalid names.
2328
+
2329
+ Templates that fail to parse are left untouched: there are no parameters to
2330
+ validate, and reporting/deferring those errors is the job of the existing
2331
+ render/validation flow (some paths, e.g. ``ignore_sql_errors``, tolerate
2332
+ unparseable SQL on purpose). This keeps the check strictly additive. The
2333
+ only exception is the invalid-name ``ValueError`` that extraction itself
2334
+ raises (e.g. for a list literal used as first argument), which is exactly
2335
+ what this validator exists to report.
2336
+ """
2337
+ try:
2338
+ params = get_var_names_and_types(Template(sql), node_id=node_id)
2339
+ except SQLTemplateException:
2340
+ # Template-level problem (e.g. a SecurityException converted by
2341
+ # get_var_names_and_types), not an invalid parameter name.
2342
+ return
2343
+ except ValueError:
2344
+ # Extraction itself rejects some invalid names (e.g. a list literal
2345
+ # as first argument) with the same ValueError this function raises.
2346
+ raise
2347
+ except Exception:
2348
+ return
2349
+ for param in params:
2350
+ name = param.get("name")
2351
+ if not isinstance(name, str) or not name:
2352
+ raise ValueError(f'"{name}" can not be used as a variable name')
2353
+
2354
+
2313
2355
  def wrap_vars(t, escape_arrays: bool = False):
2314
2356
  def _n(chunks, v):
2315
2357
  for x in chunks:
@@ -2911,13 +2953,21 @@ def extract_variables_from_sql(sql: str, params: List[Dict[str, Any]]) -> Dict[s
2911
2953
  return defaults
2912
2954
 
2913
2955
 
2914
- def render_template_with_secrets(name: str, content: str, secrets: Optional[Dict[str, str]] = None) -> str:
2956
+ def render_template_with_secrets(
2957
+ name: str,
2958
+ content: str,
2959
+ secrets: Optional[Dict[str, str]] = None,
2960
+ empty_secret_raises: bool = False,
2961
+ ) -> str:
2915
2962
  """Renders a template with secrets, allowing for default values.
2916
2963
 
2917
2964
  Args:
2918
2965
  name: The name of the template
2919
2966
  content: The template content
2920
2967
  secrets: A dictionary mapping secret names to their values
2968
+ empty_secret_raises: When True, empty secret values or empty defaults raise
2969
+ instead of returning '""'. Used for sink metadata (Forward deploy) where
2970
+ an empty bucket path must not be baked as s3://"".
2921
2971
 
2922
2972
  Returns:
2923
2973
  The rendered template
@@ -2959,6 +3009,26 @@ def render_template_with_secrets(name: str, content: str, secrets: Optional[Dict
2959
3009
  Traceback (most recent call last):
2960
3010
  ...
2961
3011
  tinybird.sql_template.SQLTemplateException: Template Syntax Error: Cannot access secret 'MISSING_SECRET'. Check the secret exists in the Workspace and the token has the required scope.
3012
+
3013
+ >>> render_template_with_secrets(
3014
+ ... "sink_bucket",
3015
+ ... "s3://{{ tb_secret('MISSING_SECRET', '') }}",
3016
+ ... secrets = {},
3017
+ ... empty_secret_raises=True,
3018
+ ... )
3019
+ Traceback (most recent call last):
3020
+ ...
3021
+ tinybird.sql_template.SQLTemplateException: Template Syntax Error: Secret 'MISSING_SECRET' resolves to an empty value and cannot be used in sink metadata.
3022
+
3023
+ >>> render_template_with_secrets(
3024
+ ... "sink_bucket",
3025
+ ... "s3://{{ tb_secret('EMPTY_SECRET', 'fallback') }}",
3026
+ ... secrets = {'EMPTY_SECRET': ''},
3027
+ ... empty_secret_raises=True,
3028
+ ... )
3029
+ Traceback (most recent call last):
3030
+ ...
3031
+ tinybird.sql_template.SQLTemplateException: Template Syntax Error: Secret 'EMPTY_SECRET' resolves to an empty value and cannot be used in sink metadata.
2962
3032
  """
2963
3033
  if not secrets:
2964
3034
  secrets = {}
@@ -2974,15 +3044,24 @@ def render_template_with_secrets(name: str, content: str, secrets: Optional[Dict
2974
3044
  The secret value or default
2975
3045
 
2976
3046
  Raises:
2977
- SQLTemplateException: If the secret is not found and no default is provided
3047
+ SQLTemplateException: If the secret is not found and no default is provided,
3048
+ or if empty_secret_raises is True and the resolved value is empty
2978
3049
  """
2979
3050
  if secret_name in secrets:
2980
3051
  value = secrets[secret_name]
2981
3052
  if isinstance(value, str) and len(value) == 0:
3053
+ if empty_secret_raises:
3054
+ raise SQLTemplateException(
3055
+ f"Secret '{secret_name}' resolves to an empty value and cannot be used in sink metadata."
3056
+ )
2982
3057
  return '""'
2983
3058
  return value
2984
3059
  elif default is not None:
2985
3060
  if isinstance(default, str) and len(default) == 0:
3061
+ if empty_secret_raises:
3062
+ raise SQLTemplateException(
3063
+ f"Secret '{secret_name}' resolves to an empty value and cannot be used in sink metadata."
3064
+ )
2986
3065
  return '""'
2987
3066
  return default
2988
3067
  else:
@@ -158,7 +158,20 @@ def has_unoptimized_join(sql: str, left_table: Optional[Union[Tuple[str, str], T
158
158
  raise UnoptimizedJoinException(sql)
159
159
 
160
160
 
161
- def format_where_for_mutation_command(where_clause: str, lightweight: bool = False) -> str:
161
+ def _format_fragment_for_mutation_command(fragment: str) -> str:
162
+ """Normalize a SQL fragment the way CH stores it in the
163
+ `system.mutations.command` column: format the expression with CH's own
164
+ formatter, then escape it as it appears inside the serialized command
165
+ (string literals keep backslash-escaped quotes)."""
166
+ formatted = chquery.format(f"""SELECT {fragment}""").split("SELECT ")[1]
167
+ formatted = formatted.replace("\\", "\\\\").replace("'", "''")
168
+ quoted = chquery.format(f"SELECT '{formatted}'").split("SELECT ")[1]
169
+ return quoted[1:-1]
170
+
171
+
172
+ def format_where_for_mutation_command(
173
+ where_clause: str, lightweight: bool = False, partition: Optional[str] = None
174
+ ) -> str:
162
175
  """
163
176
  >>> format_where_for_mutation_command("numnights = 99")
164
177
  'DELETE WHERE numnights = 99'
@@ -172,12 +185,18 @@ def format_where_for_mutation_command(where_clause: str, lightweight: bool = Fal
172
185
  "DELETE WHERE reservationid = \\\\'\\\\\\\\\\\\'foo\\\\'"
173
186
  >>> format_where_for_mutation_command("number < 3", lightweight=True)
174
187
  'UPDATE _row_exists = 0 WHERE number < 3'
188
+ >>> format_where_for_mutation_command("number < 3", lightweight=True, partition="201901")
189
+ 'UPDATE _row_exists = 0 IN PARTITION 201901 WHERE number < 3'
190
+ >>> format_where_for_mutation_command("number < 3", lightweight=True, partition="'2019-01-01'")
191
+ "UPDATE _row_exists = 0 IN PARTITION \\\\'2019-01-01\\\\' WHERE number < 3"
175
192
  """
176
- formatted_condition = chquery.format(f"""SELECT {where_clause}""").split("SELECT ")[1]
177
- formatted_condition = formatted_condition.replace("\\", "\\\\").replace("'", "''")
178
- quoted_condition = chquery.format(f"SELECT '{formatted_condition}'").split("SELECT ")[1]
179
- prefix = "UPDATE _row_exists = 0 WHERE" if lightweight else "DELETE WHERE"
180
- return f"{prefix} {quoted_condition[1:-1]}"
193
+ quoted_condition = _format_fragment_for_mutation_command(where_clause)
194
+ prefix = "UPDATE _row_exists = 0" if lightweight else "DELETE"
195
+ # Partition-scoped mutations store the partition expression in the
196
+ # command (`… IN PARTITION <expr> WHERE <cond>`), normalized by the same
197
+ # CH formatter, so it must be part of the match.
198
+ partition_clause = f" IN PARTITION {_format_fragment_for_mutation_command(partition)}" if partition else ""
199
+ return f"{prefix}{partition_clause} WHERE {quoted_condition}"
181
200
 
182
201
 
183
202
  # Functions that take table/dictionary names as string literal arguments.
@@ -624,7 +643,7 @@ def replace_tables(
624
643
 
625
644
  if current_replacements:
626
645
  # We need to transform the dictionary into something cacheable, so a sorted tuple of tuples it is
627
- r = tuple(sorted([(k, v) for k, v in current_replacements.items()]))
646
+ r = tuple(sorted(current_replacements.items()))
628
647
  sql = replace_tables_chquery_cached(
629
648
  sql,
630
649
  r,
@@ -4,5 +4,5 @@ __description__ = 'Tinybird Command Line Tool'
4
4
  __url__ = 'https://www.tinybird.co/docs/forward/commands'
5
5
  __author__ = 'Tinybird'
6
6
  __author_email__ = 'support@tinybird.co'
7
- __version__ = '4.6.12.dev0'
8
- __revision__ = '7bdea3d'
7
+ __version__ = '4.6.13'
8
+ __revision__ = '5d2bbc5'
@@ -465,6 +465,7 @@ class TinyB:
465
465
  sql_condition: Optional[str] = None,
466
466
  format: str = "csv",
467
467
  replace_options: Optional[Set[str]] = None,
468
+ use_v1: bool = False,
468
469
  ):
469
470
  params = {"name": datasource_name, "mode": mode, "format": format, "debug": "blocks_block_log"}
470
471
 
@@ -476,6 +477,30 @@ class TinyB:
476
477
 
477
478
  with open(file, "rb") as content:
478
479
  file_content = content.read()
480
+ content_types = {
481
+ "csv": "text/csv",
482
+ "ndjson": "application/x-ndjson",
483
+ "parquet": "application/vnd.apache.parquet",
484
+ }
485
+ headers = {"Content-Type": content_types[format]}
486
+ if str(file).endswith(".gz"):
487
+ headers["Content-Encoding"] = "gzip"
488
+ if use_v1:
489
+ v1_params = {"format": format}
490
+ if sql_condition:
491
+ v1_params["replace_condition"] = sql_condition
492
+ if replace_options:
493
+ for option in list(replace_options):
494
+ v1_params[option] = "true"
495
+ res = self._req(
496
+ f"/v1/datasources/{quote(datasource_name, safe='')}/{mode}?{urlencode(v1_params, safe='')}",
497
+ data=file_content,
498
+ headers=headers,
499
+ method="POST",
500
+ )
501
+ if "error" in res:
502
+ raise Exception(res["error"])
503
+ return res
479
504
  if format == "csv":
480
505
  files = {"csv": ("csv", file_content)}
481
506
  else:
@@ -925,7 +925,8 @@ def push_data(
925
925
  replace_options=None,
926
926
  concurrency: int = 1,
927
927
  silent: bool = False,
928
- ):
928
+ use_v1: bool = False,
929
+ ) -> Optional[list[str]]:
929
930
  if url and type(url) is tuple:
930
931
  url = url[0]
931
932
 
@@ -962,6 +963,8 @@ def push_data(
962
963
  datasource_name: str, url: str, mode: str, sql_condition: Optional[str], replace_options: Optional[Set[str]]
963
964
  ):
964
965
  parsed = urlparse(url)
966
+ if use_v1 and parsed.scheme in ("http", "https"):
967
+ raise CLIException("--experimental=use_v1 only supports local files.")
965
968
  # poor man's format detection
966
969
  _format = get_format_from_filename_or_url(url)
967
970
  if parsed.scheme in ("http", "https"):
@@ -982,8 +985,15 @@ def push_data(
982
985
  sql_condition=sql_condition,
983
986
  format=_format,
984
987
  replace_options=replace_options,
988
+ use_v1=use_v1,
985
989
  )
986
990
 
991
+ if use_v1:
992
+ job_id = res.get("id") or res.get("import_id")
993
+ if not isinstance(job_id, str):
994
+ raise CLIException("The v1 import response did not include a job ID.")
995
+ return job_id
996
+
987
997
  datasource_name = res["datasource"]["name"]
988
998
  try:
989
999
  datasource = client.get_datasource(datasource_name)
@@ -1014,6 +1024,8 @@ def push_data(
1014
1024
  try:
1015
1025
  tasks = [process_url(datasource_name, url, mode, sql_condition, replace_options) for url in urls]
1016
1026
  output = gather_with_concurrency(concurrency, *tasks)
1027
+ if use_v1:
1028
+ return list(output)
1017
1029
  parser, total_rows, appended_rows = list(output)[-1]
1018
1030
  except AuthNoTokenException:
1019
1031
  raise
@@ -1031,6 +1043,8 @@ def push_data(
1031
1043
 
1032
1044
  click.echo(FeedbackManager.success_progress_blocks())
1033
1045
 
1046
+ return None
1047
+
1034
1048
 
1035
1049
  def sync_data(ctx, datasource_name: str, yes: bool):
1036
1050
  client: TinyB = ctx.obj["client"]
@@ -85,7 +85,7 @@ class CLIConfig:
85
85
  def to_dict(self) -> Dict[str, Any]:
86
86
  """Helper to ease"""
87
87
  result: Dict[str, Any] = {}
88
- result.update(dict((v.name, deepcopy(v.value)) for v in self._values.values()))
88
+ result.update({v.name: deepcopy(v.value) for v in self._values.values()})
89
89
  return result
90
90
 
91
91
  def __getitem__(self, key) -> Any:
@@ -951,7 +951,7 @@ def process_file(
951
951
  "filename": filename,
952
952
  "name": name + version,
953
953
  "nodes": nodes,
954
- "deps": [x for x in set(deps)],
954
+ "deps": list(set(deps)),
955
955
  "tokens": doc.tokens,
956
956
  "description": description,
957
957
  "warnings": doc.warnings,
@@ -162,12 +162,12 @@ class PipeChecker(unittest.TestCase):
162
162
 
163
163
  if self.ignore_order:
164
164
  current_data = (
165
- sorted(normalize_array(current_data), key=itemgetter(*[k for k in current_data[0].keys()]))
165
+ sorted(normalize_array(current_data), key=itemgetter(*list(current_data[0].keys())))
166
166
  if len(current_data) > 0
167
167
  else current_data
168
168
  )
169
169
  checker_data = (
170
- sorted(normalize_array(checker_data), key=itemgetter(*[k for k in checker_data[0].keys()]))
170
+ sorted(normalize_array(checker_data), key=itemgetter(*list(checker_data[0].keys())))
171
171
  if len(checker_data) > 0
172
172
  else checker_data
173
173
  )
@@ -282,6 +282,8 @@ class PipeCheckerRunner:
282
282
  FROM {pipe_stats_rt}
283
283
  WHERE
284
284
  pipe_name = '{self.pipe_name}'
285
+ -- pipe_stats_rt retains 3 months; recent requests are enough for checks
286
+ AND start_datetime >= now() - INTERVAL 7 DAY
285
287
  AND url IS NOT NULL
286
288
  AND extractURLParameter(assumeNotNull(url), 'from') <> 'ui'
287
289
  AND extractURLParameter(assumeNotNull(url), 'pipe_checker') <> 'true'
@@ -309,6 +311,8 @@ class PipeCheckerRunner:
309
311
  FROM {pipe_stats_rt}
310
312
  WHERE
311
313
  pipe_name = '{self.pipe_name}'
314
+ -- pipe_stats_rt retains 3 months; recent requests are enough for checks
315
+ AND start_datetime >= now() - INTERVAL 7 DAY
312
316
  AND url IS NOT NULL
313
317
  AND extractURLParameter(assumeNotNull(url), 'from') <> 'ui'
314
318
  AND extractURLParameter(assumeNotNull(url), 'pipe_checker') <> 'true'
@@ -992,7 +992,7 @@ def process_file(
992
992
  "filename": filename,
993
993
  "name": name + version,
994
994
  "nodes": nodes,
995
- "deps": [x for x in set(deps)],
995
+ "deps": list(set(deps)),
996
996
  "tokens": doc.tokens,
997
997
  "description": description,
998
998
  "warnings": doc.warnings,