tinybird 0.0.1.dev25__py3-none-any.whl → 0.0.1.dev27__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 tinybird might be problematic. Click here for more details.
- tinybird/config.py +1 -1
- tinybird/datatypes.py +46 -57
- tinybird/git_settings.py +4 -4
- tinybird/prompts.py +644 -0
- tinybird/sql.py +9 -0
- tinybird/sql_toolset.py +17 -3
- tinybird/syncasync.py +1 -1
- tinybird/tb/__cli__.py +2 -2
- tinybird/tb/cli.py +2 -0
- tinybird/tb/modules/build.py +47 -19
- tinybird/tb/modules/build_server.py +75 -0
- tinybird/tb/modules/cli.py +22 -0
- tinybird/tb/modules/common.py +2 -2
- tinybird/tb/modules/config.py +13 -14
- tinybird/tb/modules/create.py +125 -120
- tinybird/tb/modules/datafile/build.py +28 -0
- tinybird/tb/modules/datafile/common.py +1 -0
- tinybird/tb/modules/datafile/fixture.py +10 -6
- tinybird/tb/modules/datafile/parse_pipe.py +2 -0
- tinybird/tb/modules/datasource.py +1 -1
- tinybird/tb/modules/deploy.py +160 -0
- tinybird/tb/modules/llm.py +32 -16
- tinybird/tb/modules/llm_utils.py +24 -0
- tinybird/tb/modules/local.py +2 -2
- tinybird/tb/modules/login.py +8 -6
- tinybird/tb/modules/mock.py +13 -9
- tinybird/tb/modules/test.py +69 -47
- tinybird/tb/modules/watch.py +2 -2
- tinybird/tb_cli_modules/common.py +2 -2
- tinybird/tb_cli_modules/config.py +5 -5
- tinybird/tornado_template.py +1 -3
- {tinybird-0.0.1.dev25.dist-info → tinybird-0.0.1.dev27.dist-info}/METADATA +1 -1
- {tinybird-0.0.1.dev25.dist-info → tinybird-0.0.1.dev27.dist-info}/RECORD +36 -33
- {tinybird-0.0.1.dev25.dist-info → tinybird-0.0.1.dev27.dist-info}/WHEEL +0 -0
- {tinybird-0.0.1.dev25.dist-info → tinybird-0.0.1.dev27.dist-info}/entry_points.txt +0 -0
- {tinybird-0.0.1.dev25.dist-info → tinybird-0.0.1.dev27.dist-info}/top_level.txt +0 -0
tinybird/sql_toolset.py
CHANGED
|
@@ -69,6 +69,7 @@ def sql_get_used_tables_cached(
|
|
|
69
69
|
default_database: str = "",
|
|
70
70
|
table_functions: bool = True,
|
|
71
71
|
function_allow_list: Optional[FrozenSet[str]] = None,
|
|
72
|
+
function_deny_list: Optional[FrozenSet[str]] = None,
|
|
72
73
|
) -> List[Tuple[str, str, str]]:
|
|
73
74
|
"""More like: get used sql names
|
|
74
75
|
|
|
@@ -92,9 +93,13 @@ def sql_get_used_tables_cached(
|
|
|
92
93
|
"""
|
|
93
94
|
try:
|
|
94
95
|
_function_allow_list = list() if function_allow_list is None else list(function_allow_list)
|
|
96
|
+
_function_deny_list = list() if function_deny_list is None else list(function_deny_list)
|
|
95
97
|
|
|
96
98
|
tables: List[Tuple[str, str, str]] = chquery.tables(
|
|
97
|
-
sql,
|
|
99
|
+
sql,
|
|
100
|
+
default_database=default_database,
|
|
101
|
+
function_allow_list=_function_allow_list,
|
|
102
|
+
function_deny_list=_function_deny_list,
|
|
98
103
|
)
|
|
99
104
|
if not table_functions:
|
|
100
105
|
return [(t[0], t[1], "") for t in tables if t[0] or t[1]]
|
|
@@ -118,15 +123,24 @@ def sql_get_used_tables(
|
|
|
118
123
|
default_database: str = "",
|
|
119
124
|
table_functions: bool = True,
|
|
120
125
|
function_allow_list: Optional[FrozenSet[str]] = None,
|
|
126
|
+
function_deny_list: Optional[FrozenSet[str]] = None,
|
|
121
127
|
) -> List[Tuple[str, str, str]]:
|
|
122
128
|
"""More like: get used sql names
|
|
123
129
|
|
|
124
130
|
Returns a list of tuples: (database_or_namespace, table_name, table_func).
|
|
125
131
|
"""
|
|
126
|
-
|
|
132
|
+
function_allow_hashable_list = frozenset() if function_allow_list is None else function_allow_list
|
|
133
|
+
function_deny_hashable_list = frozenset() if function_deny_list is None else function_deny_list
|
|
127
134
|
|
|
128
135
|
return copy.copy(
|
|
129
|
-
sql_get_used_tables_cached(
|
|
136
|
+
sql_get_used_tables_cached(
|
|
137
|
+
sql,
|
|
138
|
+
raising,
|
|
139
|
+
default_database,
|
|
140
|
+
table_functions,
|
|
141
|
+
function_allow_list=function_allow_hashable_list,
|
|
142
|
+
function_deny_list=function_deny_hashable_list,
|
|
143
|
+
)
|
|
130
144
|
)
|
|
131
145
|
|
|
132
146
|
|
tinybird/syncasync.py
CHANGED
|
@@ -73,7 +73,7 @@ class Local:
|
|
|
73
73
|
def __init__(self, thread_critical: bool = False) -> None:
|
|
74
74
|
self._thread_critical = thread_critical
|
|
75
75
|
self._thread_lock = threading.RLock()
|
|
76
|
-
self._context_refs:
|
|
76
|
+
self._context_refs: weakref.WeakSet[object] = weakref.WeakSet()
|
|
77
77
|
# Random suffixes stop accidental reuse between different Locals,
|
|
78
78
|
# though we try to force deletion as well.
|
|
79
79
|
self._attr_name = "_asgiref_local_impl_{}_{}".format(
|
tinybird/tb/__cli__.py
CHANGED
|
@@ -4,5 +4,5 @@ __description__ = 'Tinybird Command Line Tool'
|
|
|
4
4
|
__url__ = 'https://www.tinybird.co/docs/cli/introduction.html'
|
|
5
5
|
__author__ = 'Tinybird'
|
|
6
6
|
__author_email__ = 'support@tinybird.co'
|
|
7
|
-
__version__ = '0.0.1.
|
|
8
|
-
__revision__ = '
|
|
7
|
+
__version__ = '0.0.1.dev27'
|
|
8
|
+
__revision__ = 'a8944c0'
|
tinybird/tb/cli.py
CHANGED
|
@@ -6,11 +6,13 @@ if sys.platform == "win32":
|
|
|
6
6
|
|
|
7
7
|
import tinybird.tb.modules.auth
|
|
8
8
|
import tinybird.tb.modules.build
|
|
9
|
+
import tinybird.tb.modules.build_server
|
|
9
10
|
import tinybird.tb.modules.cli
|
|
10
11
|
import tinybird.tb.modules.common
|
|
11
12
|
import tinybird.tb.modules.connection
|
|
12
13
|
import tinybird.tb.modules.create
|
|
13
14
|
import tinybird.tb.modules.datasource
|
|
15
|
+
import tinybird.tb.modules.deploy
|
|
14
16
|
import tinybird.tb.modules.fmt
|
|
15
17
|
import tinybird.tb.modules.job
|
|
16
18
|
import tinybird.tb.modules.local
|
tinybird/tb/modules/build.py
CHANGED
|
@@ -28,22 +28,20 @@ from tinybird.tb.modules.watch import watch_files
|
|
|
28
28
|
@click.option(
|
|
29
29
|
"--folder",
|
|
30
30
|
default=".",
|
|
31
|
-
help="Folder from where to execute the command. By default the current folder",
|
|
31
|
+
help="Folder from where to execute the command. By default the current folder.",
|
|
32
32
|
hidden=True,
|
|
33
33
|
type=click.types.STRING,
|
|
34
34
|
)
|
|
35
35
|
@click.option(
|
|
36
36
|
"--watch",
|
|
37
37
|
is_flag=True,
|
|
38
|
-
help="Watch for changes in the files and
|
|
38
|
+
help="Watch for changes in the files and rebuild them.",
|
|
39
39
|
)
|
|
40
40
|
def build(
|
|
41
41
|
folder: str,
|
|
42
42
|
watch: bool,
|
|
43
43
|
) -> None:
|
|
44
|
-
"""
|
|
45
|
-
Watch for changes in the files and re-check them.
|
|
46
|
-
"""
|
|
44
|
+
"""Build the project in Tinybird Local."""
|
|
47
45
|
ignore_sql_errors = FeatureFlags.ignore_sql_errors()
|
|
48
46
|
context.disable_template_security_validation.set(True)
|
|
49
47
|
is_internal = has_internal_datafiles(folder)
|
|
@@ -78,23 +76,40 @@ def build(
|
|
|
78
76
|
ignore_sql_errors=ignore_sql_errors,
|
|
79
77
|
is_internal=is_internal,
|
|
80
78
|
watch=watch,
|
|
79
|
+
folder=folder,
|
|
81
80
|
)
|
|
82
81
|
if len(filenames) > 0:
|
|
83
82
|
filename = filenames[0]
|
|
84
83
|
if filename.endswith(".ndjson"):
|
|
85
84
|
fixture_path = Path(filename)
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
datasources_path = Path(folder) / "datasources"
|
|
86
|
+
ds_name = fixture_path.stem
|
|
87
|
+
ds_path = datasources_path / f"{ds_name}.datasource"
|
|
88
|
+
|
|
89
|
+
if not ds_path.exists():
|
|
90
|
+
try:
|
|
91
|
+
ds_name = "_".join(fixture_path.stem.split("_")[:-1])
|
|
92
|
+
ds_path = datasources_path / f"{ds_name}.datasource"
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
88
96
|
if ds_path.exists():
|
|
89
|
-
await append_datasource(tb_client,
|
|
97
|
+
await append_datasource(tb_client, ds_name, str(fixture_path))
|
|
90
98
|
|
|
91
99
|
if watch:
|
|
92
100
|
if filename.endswith(".datasource"):
|
|
93
101
|
ds_path = Path(filename)
|
|
94
|
-
|
|
95
|
-
|
|
102
|
+
ds_name = ds_path.stem
|
|
103
|
+
name = build_fixture_name(filename, ds_name, ds_path.read_text())
|
|
104
|
+
fixture_folder = get_fixture_dir(folder)
|
|
105
|
+
fixture_path = fixture_folder / f"{name}.ndjson"
|
|
106
|
+
|
|
107
|
+
if not fixture_path.exists():
|
|
108
|
+
fixture_path = fixture_folder / f"{ds_name}.ndjson"
|
|
109
|
+
|
|
96
110
|
if fixture_path.exists():
|
|
97
|
-
await append_datasource(tb_client,
|
|
111
|
+
await append_datasource(tb_client, ds_name, str(fixture_path))
|
|
112
|
+
|
|
98
113
|
if not filename.endswith(".ndjson"):
|
|
99
114
|
await build_and_print_resource(tb_client, filename)
|
|
100
115
|
|
|
@@ -105,7 +120,7 @@ def build(
|
|
|
105
120
|
async def build_once(filenames: List[str]):
|
|
106
121
|
ok = False
|
|
107
122
|
try:
|
|
108
|
-
click.echo("
|
|
123
|
+
click.echo(FeedbackManager.highlight(message="» Building project...\n"))
|
|
109
124
|
time_start = time.time()
|
|
110
125
|
await process(filenames=filenames, watch=False)
|
|
111
126
|
time_end = time.time()
|
|
@@ -113,13 +128,26 @@ def build(
|
|
|
113
128
|
for filename in filenames:
|
|
114
129
|
if filename.endswith(".datasource"):
|
|
115
130
|
ds_path = Path(filename)
|
|
116
|
-
|
|
117
|
-
|
|
131
|
+
ds_name = ds_path.stem
|
|
132
|
+
name = build_fixture_name(filename, ds_name, ds_path.read_text())
|
|
133
|
+
fixture_folder = get_fixture_dir(folder)
|
|
134
|
+
fixture_path = fixture_folder / f"{name}.ndjson"
|
|
135
|
+
|
|
136
|
+
if not fixture_path.exists():
|
|
137
|
+
fixture_path = fixture_folder / f"{ds_name}.ndjson"
|
|
138
|
+
|
|
118
139
|
if fixture_path.exists():
|
|
119
|
-
await append_datasource(tb_client,
|
|
120
|
-
click.echo(FeedbackManager.success(message=f"\n✓ Build completed in {elapsed_time:.1f}s
|
|
140
|
+
await append_datasource(tb_client, ds_name, str(fixture_path))
|
|
141
|
+
click.echo(FeedbackManager.success(message=f"\n✓ Build completed in {elapsed_time:.1f}s"))
|
|
121
142
|
ok = True
|
|
122
143
|
except Exception as e:
|
|
144
|
+
error_path = Path(".tb_error.txt")
|
|
145
|
+
if error_path.exists():
|
|
146
|
+
content = error_path.read_text()
|
|
147
|
+
content += f"\n\n{str(e)}"
|
|
148
|
+
error_path.write_text(content)
|
|
149
|
+
else:
|
|
150
|
+
error_path.write_text(str(e))
|
|
123
151
|
click.echo(FeedbackManager.error_exception(error=e))
|
|
124
152
|
ok = False
|
|
125
153
|
return ok
|
|
@@ -154,7 +182,7 @@ def build(
|
|
|
154
182
|
pipes=pipes,
|
|
155
183
|
endpoints=endpoints,
|
|
156
184
|
)
|
|
157
|
-
click.echo(FeedbackManager.
|
|
185
|
+
click.echo(FeedbackManager.gray(message="\nWatching for changes..."))
|
|
158
186
|
watcher_thread = threading.Thread(
|
|
159
187
|
target=watch_files, args=(filenames, process, shell, folder, build_ok), daemon=True
|
|
160
188
|
)
|
|
@@ -174,13 +202,13 @@ async def append_datasource(
|
|
|
174
202
|
tb_client: TinyB,
|
|
175
203
|
datasource_name: str,
|
|
176
204
|
url: str,
|
|
177
|
-
silent: bool = False,
|
|
178
205
|
):
|
|
206
|
+
await tb_client.datasource_truncate(datasource_name)
|
|
179
207
|
await push_data(
|
|
180
208
|
tb_client,
|
|
181
209
|
datasource_name,
|
|
182
210
|
url,
|
|
183
211
|
mode="append",
|
|
184
212
|
concurrency=1,
|
|
185
|
-
silent=
|
|
213
|
+
silent=True,
|
|
186
214
|
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import glob
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from tinybird.tb.modules.cli import cli
|
|
12
|
+
from tinybird.tb.modules.feedback_manager import FeedbackManager
|
|
13
|
+
from tinybird.tb.modules.local_common import get_tinybird_local_client
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def project_files(project_path: Path) -> List[str]:
|
|
17
|
+
project_file_extensions = ("datasource", "pipe")
|
|
18
|
+
project_files = []
|
|
19
|
+
for extension in project_file_extensions:
|
|
20
|
+
for project_file in glob.glob(f"{project_path}/**/*.{extension}", recursive=True):
|
|
21
|
+
logging.debug(f"Found project file: {project_file}")
|
|
22
|
+
project_files.append(project_file)
|
|
23
|
+
return project_files
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@cli.command()
|
|
27
|
+
@click.argument("project_path", type=click.Path(exists=True), default=Path.cwd())
|
|
28
|
+
def build_server(project_path: Path) -> None:
|
|
29
|
+
"""
|
|
30
|
+
Validate and build the project server side.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
MULTIPART_BOUNDARY_DATA_PROJECT = "data_project://"
|
|
34
|
+
DATAFILE_TYPE_TO_CONTENT_TYPE = {
|
|
35
|
+
".datasource": "text/plain",
|
|
36
|
+
".pipe": "text/plain",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
tb_client = asyncio.run(get_tinybird_local_client(str(project_path)))
|
|
40
|
+
TINYBIRD_API_URL = tb_client.host + "/v1/build"
|
|
41
|
+
TINYBIRD_API_KEY = tb_client.token
|
|
42
|
+
|
|
43
|
+
files = [
|
|
44
|
+
("context://", ("cli-version", "1.0.0", "text/plain")),
|
|
45
|
+
]
|
|
46
|
+
fds = []
|
|
47
|
+
for file_path in project_files(project_path):
|
|
48
|
+
relative_path = str(Path(file_path).relative_to(project_path))
|
|
49
|
+
fd = open(file_path, "rb")
|
|
50
|
+
fds.append(fd)
|
|
51
|
+
content_type = DATAFILE_TYPE_TO_CONTENT_TYPE.get(Path(file_path).suffix, "application/unknown")
|
|
52
|
+
files.append((MULTIPART_BOUNDARY_DATA_PROJECT, (relative_path, fd, content_type)))
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
HEADERS = {"Authorization": f"Bearer {TINYBIRD_API_KEY}"}
|
|
56
|
+
|
|
57
|
+
r = requests.post(TINYBIRD_API_URL, files=files, headers=HEADERS)
|
|
58
|
+
result = r.json()
|
|
59
|
+
logging.debug(json.dumps(result, indent=2))
|
|
60
|
+
|
|
61
|
+
build_result = result.get("result")
|
|
62
|
+
if build_result == "success":
|
|
63
|
+
click.echo(FeedbackManager.success(message="Build completed successfully"))
|
|
64
|
+
elif build_result == "failed":
|
|
65
|
+
click.echo(FeedbackManager.error(message="Build failed"))
|
|
66
|
+
build_errors = result.get("errors")
|
|
67
|
+
for build_error in build_errors:
|
|
68
|
+
click.echo(
|
|
69
|
+
FeedbackManager.error(message=f"{build_error.get('filename')}\n\n{build_error.get('error')}")
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
click.echo(FeedbackManager.error(message=f"Unknown build result. Error: {result.get('error')}"))
|
|
73
|
+
finally:
|
|
74
|
+
for fd in fds:
|
|
75
|
+
fd.close()
|
tinybird/tb/modules/cli.py
CHANGED
|
@@ -48,6 +48,7 @@ from tinybird.tb.modules.datafile.parse_datasource import parse_datasource
|
|
|
48
48
|
from tinybird.tb.modules.datafile.parse_pipe import parse_pipe
|
|
49
49
|
from tinybird.tb.modules.datafile.pull import folder_pull
|
|
50
50
|
from tinybird.tb.modules.feedback_manager import FeedbackManager
|
|
51
|
+
from tinybird.tb.modules.llm import LLM
|
|
51
52
|
from tinybird.tb.modules.local_common import get_tinybird_local_client
|
|
52
53
|
|
|
53
54
|
__old_click_echo = click.echo
|
|
@@ -486,6 +487,27 @@ async def sql(
|
|
|
486
487
|
click.echo(FeedbackManager.info_no_rows())
|
|
487
488
|
|
|
488
489
|
|
|
490
|
+
@cli.command(hidden=True)
|
|
491
|
+
@click.argument("prompt")
|
|
492
|
+
@click.option("--model", default="gpt-4o", help="The model to use for the LLM")
|
|
493
|
+
@click.option("--folder", default=".", help="The folder to use for the project")
|
|
494
|
+
@coro
|
|
495
|
+
async def llm(prompt: str, model: str, folder: str) -> None:
|
|
496
|
+
"""Run a prompt against the LLM api."""
|
|
497
|
+
try:
|
|
498
|
+
config = CLIConfig.get_project_config(folder)
|
|
499
|
+
user_token = config.get_user_token()
|
|
500
|
+
|
|
501
|
+
if not user_token:
|
|
502
|
+
raise CLIException("This action requires authentication. Run 'tb login' first.")
|
|
503
|
+
|
|
504
|
+
client = config.get_client()
|
|
505
|
+
llm = LLM(user_token=user_token, client=client)
|
|
506
|
+
click.echo(await llm.ask(prompt, model=model))
|
|
507
|
+
except Exception as e:
|
|
508
|
+
raise CLIException(FeedbackManager.error_exception(error=e))
|
|
509
|
+
|
|
510
|
+
|
|
489
511
|
def __patch_click_output():
|
|
490
512
|
import re
|
|
491
513
|
|
tinybird/tb/modules/common.py
CHANGED
|
@@ -175,7 +175,7 @@ def generate_datafile(
|
|
|
175
175
|
if not f.exists() or force:
|
|
176
176
|
with open(f"{f}", "w") as ds_file:
|
|
177
177
|
ds_file.write(datafile)
|
|
178
|
-
click.echo(FeedbackManager.info_file_created(file=
|
|
178
|
+
click.echo(FeedbackManager.info_file_created(file=f.relative_to(folder)))
|
|
179
179
|
|
|
180
180
|
if data and (base / "fixtures").exists():
|
|
181
181
|
# Generating a fixture for Parquet files is not so trivial, since Parquet format
|
|
@@ -289,7 +289,7 @@ def load_connector_config(ctx: Context, connector_name: str, debug: bool, check_
|
|
|
289
289
|
click.echo(FeedbackManager.warning_connector_not_installed(connector=connector_name))
|
|
290
290
|
return
|
|
291
291
|
ctx.ensure_object(dict)[connector_name] = create_connector(connector_name, config)
|
|
292
|
-
except
|
|
292
|
+
except OSError:
|
|
293
293
|
if debug:
|
|
294
294
|
click.echo(f"** {connector_name} connector not configured")
|
|
295
295
|
pass
|
tinybird/tb/modules/config.py
CHANGED
|
@@ -43,12 +43,10 @@ def compare_versions(a: str, b: str) -> int:
|
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
class ConfigValueOrigin(Enum):
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
DEFAULT: str = "default"
|
|
51
|
-
NONE: str = ""
|
|
46
|
+
ENVIRONMENT = "env"
|
|
47
|
+
CONFIG = "conf"
|
|
48
|
+
DEFAULT = "default"
|
|
49
|
+
NONE = ""
|
|
52
50
|
|
|
53
51
|
|
|
54
52
|
@dataclass
|
|
@@ -152,7 +150,7 @@ class CLIConfig:
|
|
|
152
150
|
for k, v in values.items():
|
|
153
151
|
self[k] = v
|
|
154
152
|
return True
|
|
155
|
-
except
|
|
153
|
+
except OSError:
|
|
156
154
|
return False
|
|
157
155
|
|
|
158
156
|
def override_with_environment(self) -> None:
|
|
@@ -306,13 +304,14 @@ class CLIConfig:
|
|
|
306
304
|
|
|
307
305
|
Note: the `_path` argument is mainly intended to help during testing.
|
|
308
306
|
"""
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
307
|
+
path: Optional[str] = _path or os.environ.get("XDG_CONFIG_HOME", None)
|
|
308
|
+
if not path:
|
|
309
|
+
path = os.path.join(os.environ.get("HOME", "~"), ".config")
|
|
310
|
+
path = os.path.join(path, APP_CONFIG_NAME, ".tinyb")
|
|
311
|
+
exists_path = os.path.exists(path)
|
|
312
|
+
if not exists_path:
|
|
313
|
+
path = os.path.join(os.getcwd(), ".tinyb")
|
|
314
|
+
return CLIConfig(path, parent=None)
|
|
316
315
|
|
|
317
316
|
@staticmethod
|
|
318
317
|
def get_project_config(working_dir: Optional[str] = None) -> "CLIConfig":
|