Skip to content

Commit 091c49c

Browse files
committed
Tests fixed, exception improved, docs adjusted
1 parent b6cf65e commit 091c49c

File tree

9 files changed

+165
-81
lines changed

9 files changed

+165
-81
lines changed

dlt/common/destination/exceptions.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Iterable, List, Sequence
1+
from typing import Any, Iterable, List, Sequence, Optional
22

33
from dlt.common.exceptions import DltException, TerminalException, TransientException
44
from dlt.common.reflection.exceptions import ReferenceImportError
@@ -43,6 +43,31 @@ def __str__(self) -> str:
4343
return msg
4444

4545

46+
class DestinationTypeResolutionException(DestinationException):
47+
def __init__(
48+
self,
49+
ref: str,
50+
type_resolution_error: Exception,
51+
named_dest_error: Optional[Exception],
52+
) -> None:
53+
self.ref = ref
54+
self.named_dest_error = named_dest_error
55+
self.type_resolution_error = type_resolution_error
56+
57+
msg = f"Failed to resolve destination '{ref}'"
58+
59+
if named_dest_error:
60+
msg += (
61+
". First tried to resolve as a named destination with destination type, "
62+
f"but failed: {named_dest_error}. "
63+
f"Then tried to resolve as destination type, but failed: {type_resolution_error}"
64+
)
65+
else:
66+
msg += f" as destination type: {type_resolution_error}"
67+
68+
super().__init__(msg)
69+
70+
4671
class InvalidDestinationReference(DestinationException):
4772
def __init__(self, refs: Any) -> None:
4873
self.refs = refs

dlt/common/destination/reference.py

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from dlt.common.configuration import resolve_configuration, known_sections
2323
from dlt.common.destination.capabilities import DestinationCapabilitiesContext
2424
from dlt.common.destination.exceptions import (
25-
DestinationException,
25+
DestinationTypeResolutionException,
2626
InvalidDestinationReference,
2727
UnknownDestinationModule,
2828
)
@@ -285,27 +285,24 @@ def from_reference(
285285
)
286286
except Exception as e:
287287
named_dest_error = e
288-
logger.debug(
289-
f"Tried to resolve destination '{ref}' as a named destination with destination"
290-
f" type, but got the following error: {e}"
291-
"Will try to resolve destination as destination type."
292-
)
293288

294289
# Fallback to shorthand type reference
295290
try:
296291
dest_ref = DestinationReference.from_reference(
297292
ref, credentials, destination_name, environment, **kwargs
298293
)
299-
logger.info(f"Resolved destination '{ref}' as destination of type '{ref}'.")
300294
return dest_ref
301295
except Exception as e:
302-
if named_dest_error:
303-
logger.error(
304-
"First tried to resolve destination as a named destination with destination"
305-
f" type, but failed: {named_dest_error}Then tried to resolve destination as"
306-
f" destination type, but failed: {e}"
296+
if named_dest_error is None:
297+
# Only direct type resolution was attempted, raise original error
298+
raise e
299+
else:
300+
# Both resolution methods failed, use comprehensive exception
301+
raise DestinationTypeResolutionException(
302+
ref=ref,
303+
type_resolution_error=e,
304+
named_dest_error=named_dest_error,
307305
)
308-
raise
309306

310307

311308
class DestinationReference:

docs/website/docs/general-usage/destination.md

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,21 @@ We recommend that you declare the destination type when creating a pipeline inst
1818

1919
Above, we want to use the **filesystem** built-in destination. You can use shorthand types only for built-ins.
2020

21+
* Use a **custom destination name** with a configured type
22+
<!--@@@DLT_SNIPPET ./snippets/destination-snippets.py::custom_destination_name-->
23+
24+
Above, we use a custom destination name and configure the destination type to **filesystem** using an environment variable.
25+
26+
:::note
27+
When resolving non-module destination references (e.g., `"filesystem"` or `"my_destination"`, not `"dlt.destinations.filesystem"`), dlt first attempts to resolve the reference as a named destination with a valid destination type configured, then falls back to shorthand type resolution.
28+
29+
This means that, in the previous example, if the destination type is not properly configured or is not a valid destination type, dlt will attempt to resolve `"my_destination"` as a shorthand for a built-in type and will eventually fail.
30+
31+
As another example, the following:
32+
<!--@@@DLT_SNIPPET ./snippets/destination-snippets.py::avoid_example-->
33+
will be resolved as a BigQuery destination that is named `"filesystem"`!
34+
:::
35+
2136
* Use full **destination factory type**
2237
<!--@@@DLT_SNIPPET ./snippets/destination-snippets.py::class_type-->
2338

@@ -30,32 +45,34 @@ Above, we import the destination factory for **filesystem** and pass it to the p
3045

3146
All examples above will create the same destination class with default parameters and pull required config and secret values from [configuration](credentials/index.md) - they are equivalent.
3247

33-
34-
### Pass explicit parameters and a name to a destination
48+
### Pass explicit parameters and a name to a destination factory
3549
You can instantiate the **destination factory** yourself to configure it explicitly. When doing this, you work with destinations the same way you work with [sources](source.md)
3650
<!--@@@DLT_SNIPPET ./snippets/destination-snippets.py::instance-->
3751

3852
Above, we import and instantiate the `filesystem` destination factory. We pass the explicit URL of the bucket and name the destination `production_az_bucket`.
3953

40-
If a destination is not named, its shorthand type (the Python factory name) serves as a destination name. Name your destination explicitly if you need several separate configurations of destinations of the same type (i.e., you wish to maintain credentials for development, staging, and production storage buckets in the same config file). The destination name is also stored in the [load info](../running-in-production/running.md#inspect-and-save-the-load-info-and-trace) and pipeline traces, so use them also when you need more descriptive names (other than, for example, `filesystem`).
54+
If a destination is not named, its shorthand type (the Python factory name) serves as the destination name. Name your destination explicitly if you need several separate configurations for destinations of the same type (i.e., when you wish to maintain credentials for development, staging, and production storage buckets in the same config file). The destination name is also stored in the [load info](../running-in-production/running.md#inspect-and-save-the-load-info-and-trace) and pipeline traces, so use explicit names when you need more descriptive identifiers (rather than generic names like `filesystem`).
4155

4256

4357
## Configure a destination
4458
We recommend passing the credentials and other required parameters to configuration via TOML files, environment variables, or other [config providers](credentials/setup). This allows you, for example, to easily switch to production destinations after deployment.
4559

46-
We recommend using the [default config section layout](credentials/advanced#organize-configuration-and-secrets-with-sections) as below:
60+
Use the [default config section layout](credentials/advanced#organize-configuration-and-secrets-with-sections) as shown below:
4761
<!--@@@DLT_SNIPPET ./snippets/destination-toml.toml::default_layout-->
4862

49-
or via environment variables:
63+
Alternatively, you can use environment variables:
5064
```sh
5165
DESTINATION__FILESYSTEM__BUCKET_URL=az://dlt-azure-bucket
5266
DESTINATION__FILESYSTEM__CREDENTIALS__AZURE_STORAGE_ACCOUNT_NAME=dltdata
5367
DESTINATION__FILESYSTEM__CREDENTIALS__AZURE_STORAGE_ACCOUNT_KEY="storage key"
5468
```
5569

56-
For named destinations, you use their names in the config section
70+
When using named destination factories, use the destination name in the config section:
5771
<!--@@@DLT_SNIPPET ./snippets/destination-toml.toml::name_layout-->
5872

73+
For custom destination names passed to your pipeline (e.g., `destination="my_destination"`), dlt resolves the destination type from configuration. Add `destination_type` to specify which destination type to use:
74+
<!--@@@DLT_SNIPPET ./snippets/destination-toml.toml::custom_name_layout-->
75+
5976

6077
Note that when you use the [`dlt init` command](../walkthroughs/add-a-verified-source.md) to create or add a data source, `dlt` creates a sample configuration for the selected destination.
6178

docs/website/docs/general-usage/pipeline.md

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ You instantiate a pipeline by calling the `dlt.pipeline` function with the follo
3131
events and to restore its state and data schemas on subsequent runs. If not provided, `dlt` will
3232
create a pipeline name from the file name of the currently executing Python module.
3333
- `destination`: a name of the [destination](../dlt-ecosystem/destinations) to which dlt
34-
will load the data. It may also be provided to the `run` method of the `pipeline`.
34+
will load the data. It may also be provided to the `run` method of the `pipeline` and can be declared in [various ways](destination.md).
3535
- `dataset_name`: a name of the dataset to which the data will be loaded. A dataset is a logical
3636
group of tables, i.e., `schema` in relational databases or a folder grouping many files. It may also be
3737
provided later to the `run` or `load` methods of the pipeline. If not provided, then
@@ -67,31 +67,6 @@ info = pipeline.run(generate_rows(10))
6767

6868
print(info)
6969
```
70-
## Named destination configuration
71-
You can use a custom destination name in your pipeline and configure the actual destination type through configuration files or environment variables. This approach allows you to switch between different destinations (e.g., from development to production) without changing your pipeline code.
72-
73-
Example:
74-
```py
75-
import dlt
76-
77-
pipeline = dlt.pipeline(destination="custom_pipeline", dataset_name="sql_database_data")
78-
```
79-
80-
Then configure the actual destination type in your `.dlt/secrets.toml`:
81-
82-
```toml
83-
[destination.custom_pipeline]
84-
# for development
85-
destination_type = "duckdb"
86-
87-
# for production
88-
# destination_type = "bigquery"
89-
90-
# [destination.custom_pipeline.credentials]
91-
# project_id = "project_id" # please set me up!
92-
# private_key = "private_key" # please set me up!
93-
# client_email = "client_email" # please set me up!
94-
```
9570

9671
## Pipeline working directory
9772

docs/website/docs/general-usage/snippets/destination-snippets.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,30 @@ def destination_instantiation_snippet() -> None:
3030

3131
assert pipeline.destination.destination_name == "filesystem"
3232

33+
# @@@DLT_SNIPPET_START custom_destination_name
34+
import os
35+
import dlt
36+
37+
os.environ["DESTINATION__MY_DESTINATION__DESTINATION_TYPE"] = "filesystem"
38+
39+
pipeline = dlt.pipeline("pipeline", destination="my_destination")
40+
# @@@DLT_SNIPPET_END custom_destination_name
41+
42+
assert pipeline.destination.destination_type == "dlt.destinations.filesystem"
43+
assert pipeline.destination.destination_name == "my_destination"
44+
45+
# @@@DLT_SNIPPET_START avoid_example
46+
import os
47+
import dlt
48+
49+
os.environ["DESTINATION__FILESYSTEM__DESTINATION_TYPE"] = "bigquery"
50+
51+
pipeline = dlt.pipeline("pipeline", destination="filesystem")
52+
# @@@DLT_SNIPPET_END avoid_example
53+
54+
assert pipeline.destination.destination_type == "dlt.destinations.bigquery"
55+
assert pipeline.destination.destination_name == "filesystem"
56+
3357
# @@@DLT_SNIPPET_START instance
3458
import dlt
3559

docs/website/docs/general-usage/snippets/destination-toml.toml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,13 @@ bucket_url="az://dlt-azure-bucket"
1212
[destination.production_az_bucket.credentials]
1313
azure_storage_account_name="dltdata"
1414
azure_storage_account_key="storage key"
15-
# @@@DLT_SNIPPET_END name_layout
15+
# @@@DLT_SNIPPET_END name_layout
16+
17+
# @@@DLT_SNIPPET_START custom_name_layout
18+
[destination.my_destination]
19+
destination_type="filesystem"
20+
bucket_url="az://dlt-azure-bucket"
21+
[destination.my_destination.credentials]
22+
azure_storage_account_name="dltdata"
23+
azure_storage_account_key="storage key"
24+
# @@@DLT_SNIPPET_END custom_name_layout

tests/common/destination/test_reference.py

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
from dlt.common.destination import Destination, DestinationReference
99
from dlt.common.destination.client import DestinationClientDwhConfiguration, WithStagingDataset
1010
from dlt.common.destination import DestinationCapabilitiesContext
11-
from dlt.common.destination.exceptions import UnknownDestinationModule
11+
from dlt.common.destination.exceptions import (
12+
UnknownDestinationModule,
13+
DestinationTypeResolutionException,
14+
)
1215
from dlt.common.schema import Schema
1316
from dlt.common.typing import is_subclass
1417
from dlt.common.normalizers.naming import sql_ci_v1, sql_cs_v1
@@ -316,39 +319,66 @@ def test_import_destination_config() -> None:
316319
def test_import_destination_type_config(
317320
environment: Dict[str, str],
318321
destination_type: str,
319-
destination_name: str = "my_destination",
320322
) -> None:
321-
environment[f"DESTINATION__{destination_name.upper()}__DESTINATION_TYPE"] = destination_type
322-
msg_from_fallback = "configure a valid dlt destination type"
323+
"""Test destination resolution behavior with both valid and invalid destination types.
324+
325+
This test covers the resolution strategy where dlt first tries to resolve
326+
a destination as a named destination with configured type, and if that fails,
327+
falls back to resolving it as a direct destination type reference.
328+
"""
329+
environment["DESTINATION__MY_DESTINATION__DESTINATION_TYPE"] = destination_type
330+
323331
if destination_type == "wrong_type":
324-
with pytest.raises(UnknownDestinationModule) as py_exc:
325-
Destination.from_reference(ref=destination_name)
326-
test = py_exc.value
327-
assert msg_from_fallback in str(py_exc.value)
328-
329-
# if destination_name is provided, ref should be a valid destination type
330-
with pytest.raises(UnknownDestinationModule) as py_exc:
331-
Destination.from_reference(
332-
ref=f"dlt.destinations.{destination_type}", destination_name=destination_name
333-
)
334-
assert msg_from_fallback not in str(py_exc.value)
335-
336-
# if ref contains dots, it's a module path and should be valid
337-
# names with dots will not resolve correctly from configs anyway
338-
with pytest.raises(UnknownDestinationModule):
332+
# Case 1: Fully qualified ref with dots
333+
# Skips named destination resolution and only attempts direct type resolution
334+
with pytest.raises(UnknownDestinationModule) as module_ex:
339335
Destination.from_reference(ref=f"dlt.destinations.{destination_type}")
340-
assert msg_from_fallback not in str(py_exc.value)
336+
assert "`dlt.destinations.wrong_type` is not registered." in str(module_ex.value)
337+
338+
# Case 2: Explicit destination_name provided
339+
# Same as Case 1
340+
with pytest.raises(UnknownDestinationModule) as module_ex:
341+
Destination.from_reference(ref=destination_type, destination_name="my_destination")
342+
assert "`wrong_type` is not one of the standard dlt destinations." in str(module_ex.value)
343+
344+
# Case 3: Named destination with invalid configured type
345+
# First tries named destination "my_destination" with configured type "wrong_type"
346+
# Then tries "my_destination" as destination type
347+
with pytest.raises(DestinationTypeResolutionException) as resolution_exc:
348+
Destination.from_reference(ref="my_destination")
349+
assert resolution_exc.value.named_dest_error
350+
assert "`wrong_type` is not one of the standard dlt destination types." in str(
351+
resolution_exc.value
352+
)
353+
assert "`my_destination` is not one of the standard dlt destinations." in str(
354+
resolution_exc.value
355+
)
356+
357+
# Case 4: Named destination with missing type configuration
358+
# First tries named destination "my_destination" but no type configured (config error)
359+
# Then tries "my_destination" as direct destination type
360+
environment.clear()
361+
with pytest.raises(DestinationTypeResolutionException) as resolution_exc:
362+
Destination.from_reference(ref="my_destination")
363+
assert resolution_exc.value.named_dest_error
364+
assert (
365+
"Missing 1 field(s) in configuration `DestinationTypeConfiguration`: `destination_type`"
366+
in str(resolution_exc.value)
367+
)
368+
assert "`my_destination` is not one of the standard dlt destinations." in str(
369+
resolution_exc.value
370+
)
341371

342372
else:
343-
dest = Destination.from_reference(ref=destination_name)
373+
dest = Destination.from_reference(ref="my_destination")
344374
assert dest.destination_type == "dlt.destinations.duckdb"
345-
assert dest.destination_name == destination_name
375+
assert dest.destination_name == "my_destination"
346376

347377
dest = Destination.from_reference(
348-
ref=f"dlt.destinations.{destination_type}", destination_name=destination_name
378+
ref=f"dlt.destinations.{destination_type}", destination_name="my_destination"
349379
)
350380
assert dest.destination_type == "dlt.destinations.duckdb"
351-
assert dest.destination_name == destination_name
381+
assert dest.destination_name == "my_destination"
352382

353383
dest = Destination.from_reference(ref=f"dlt.destinations.{destination_type}")
354384
assert dest.destination_type == "dlt.destinations.duckdb"

tests/destinations/test_custom_destination.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,17 @@
1111
from dlt.common.schema import TTableSchema
1212
from dlt.common.data_writers.writers import TLoaderFileFormat
1313
from dlt.common.destination import Destination, DestinationReference
14-
from dlt.common.destination.exceptions import DestinationTransientException
14+
from dlt.common.destination.exceptions import (
15+
DestinationTransientException,
16+
DestinationTypeResolutionException,
17+
)
1518
from dlt.common.configuration.exceptions import ConfigFieldMissingException, ConfigurationValueError
1619
from dlt.common.configuration.specs import ConnectionStringCredentials
1720
from dlt.common.configuration.inject import get_fun_spec
1821
from dlt.common.configuration.specs import BaseConfiguration
1922

2023
from dlt.destinations.impl.destination.configuration import CustomDestinationClientConfiguration
21-
from dlt.destinations.impl.destination.factory import UnknownCustomDestinationCallable, destination
24+
from dlt.destinations.impl.destination.factory import destination
2225
from dlt.pipeline.exceptions import PipelineStepFailed
2326

2427
from tests.cases import table_update_and_row
@@ -290,7 +293,7 @@ def local_sink_func_no_params(items: TDataItems, table: TTableSchema) -> None:
290293
p.run([1, 2, 3], table_name="items")
291294

292295
# pass invalid string reference will fail on instantiation
293-
with pytest.raises(UnknownCustomDestinationCallable):
296+
with pytest.raises(DestinationTypeResolutionException):
294297
p = dlt.pipeline(
295298
"sink_test",
296299
destination=Destination.from_reference(

0 commit comments

Comments
 (0)