port-ocean 0.5.6__py3-none-any.whl → 0.17.8__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 port-ocean might be problematic. Click here for more details.

Files changed (111) hide show
  1. integrations/_infra/Dockerfile.Deb +56 -0
  2. integrations/_infra/Dockerfile.alpine +108 -0
  3. integrations/_infra/Dockerfile.base.builder +26 -0
  4. integrations/_infra/Dockerfile.base.runner +13 -0
  5. integrations/_infra/Dockerfile.dockerignore +94 -0
  6. {port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}} → integrations/_infra}/Makefile +21 -8
  7. integrations/_infra/grpcio.sh +18 -0
  8. integrations/_infra/init.sh +5 -0
  9. port_ocean/bootstrap.py +1 -1
  10. port_ocean/cli/commands/defaults/clean.py +3 -1
  11. port_ocean/cli/commands/new.py +42 -7
  12. port_ocean/cli/commands/sail.py +7 -1
  13. port_ocean/cli/cookiecutter/cookiecutter.json +3 -0
  14. port_ocean/cli/cookiecutter/hooks/post_gen_project.py +20 -3
  15. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/.env.example +6 -0
  16. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/.port/resources/blueprints.json +41 -0
  17. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/.port/resources/port-app-config.yml +16 -0
  18. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/.port/spec.yaml +6 -7
  19. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/CHANGELOG.md +1 -1
  20. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/CONTRIBUTING.md +7 -0
  21. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/changelog/.gitignore +1 -0
  22. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/main.py +16 -1
  23. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/pyproject.toml +21 -10
  24. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/tests/test_sample.py +2 -0
  25. port_ocean/clients/port/authentication.py +16 -4
  26. port_ocean/clients/port/client.py +17 -0
  27. port_ocean/clients/port/mixins/blueprints.py +7 -8
  28. port_ocean/clients/port/mixins/entities.py +108 -53
  29. port_ocean/clients/port/mixins/integrations.py +23 -34
  30. port_ocean/clients/port/retry_transport.py +0 -5
  31. port_ocean/clients/port/utils.py +9 -3
  32. port_ocean/config/base.py +16 -16
  33. port_ocean/config/settings.py +79 -11
  34. port_ocean/context/event.py +18 -5
  35. port_ocean/context/ocean.py +14 -3
  36. port_ocean/core/defaults/clean.py +10 -3
  37. port_ocean/core/defaults/common.py +25 -9
  38. port_ocean/core/defaults/initialize.py +111 -100
  39. port_ocean/core/event_listener/__init__.py +8 -0
  40. port_ocean/core/event_listener/base.py +49 -10
  41. port_ocean/core/event_listener/factory.py +9 -1
  42. port_ocean/core/event_listener/http.py +11 -3
  43. port_ocean/core/event_listener/kafka.py +24 -5
  44. port_ocean/core/event_listener/once.py +96 -4
  45. port_ocean/core/event_listener/polling.py +16 -14
  46. port_ocean/core/event_listener/webhooks_only.py +41 -0
  47. port_ocean/core/handlers/__init__.py +1 -2
  48. port_ocean/core/handlers/entities_state_applier/base.py +4 -1
  49. port_ocean/core/handlers/entities_state_applier/port/applier.py +29 -87
  50. port_ocean/core/handlers/entities_state_applier/port/order_by_entities_dependencies.py +5 -2
  51. port_ocean/core/handlers/entity_processor/base.py +26 -22
  52. port_ocean/core/handlers/entity_processor/jq_entity_processor.py +253 -45
  53. port_ocean/core/handlers/port_app_config/base.py +55 -15
  54. port_ocean/core/handlers/port_app_config/models.py +24 -5
  55. port_ocean/core/handlers/resync_state_updater/__init__.py +5 -0
  56. port_ocean/core/handlers/resync_state_updater/updater.py +84 -0
  57. port_ocean/core/integrations/base.py +5 -7
  58. port_ocean/core/integrations/mixins/events.py +3 -1
  59. port_ocean/core/integrations/mixins/sync.py +4 -2
  60. port_ocean/core/integrations/mixins/sync_raw.py +209 -74
  61. port_ocean/core/integrations/mixins/utils.py +1 -1
  62. port_ocean/core/models.py +44 -0
  63. port_ocean/core/ocean_types.py +29 -11
  64. port_ocean/core/utils/entity_topological_sorter.py +90 -0
  65. port_ocean/core/utils/utils.py +109 -0
  66. port_ocean/debug_cli.py +5 -0
  67. port_ocean/exceptions/core.py +4 -0
  68. port_ocean/exceptions/port_defaults.py +0 -2
  69. port_ocean/helpers/retry.py +85 -24
  70. port_ocean/log/handlers.py +23 -2
  71. port_ocean/log/logger_setup.py +8 -1
  72. port_ocean/log/sensetive.py +25 -10
  73. port_ocean/middlewares.py +10 -2
  74. port_ocean/ocean.py +57 -24
  75. port_ocean/run.py +10 -5
  76. port_ocean/tests/__init__.py +0 -0
  77. port_ocean/tests/clients/port/mixins/test_entities.py +53 -0
  78. port_ocean/tests/conftest.py +4 -0
  79. port_ocean/tests/core/defaults/test_common.py +166 -0
  80. port_ocean/tests/core/handlers/entity_processor/test_jq_entity_processor.py +350 -0
  81. port_ocean/tests/core/handlers/mixins/test_sync_raw.py +552 -0
  82. port_ocean/tests/core/test_utils.py +73 -0
  83. port_ocean/tests/core/utils/test_entity_topological_sorter.py +99 -0
  84. port_ocean/tests/helpers/__init__.py +0 -0
  85. port_ocean/tests/helpers/fake_port_api.py +191 -0
  86. port_ocean/tests/helpers/fixtures.py +46 -0
  87. port_ocean/tests/helpers/integration.py +31 -0
  88. port_ocean/tests/helpers/ocean_app.py +66 -0
  89. port_ocean/tests/helpers/port_client.py +21 -0
  90. port_ocean/tests/helpers/smoke_test.py +82 -0
  91. port_ocean/tests/log/test_handlers.py +71 -0
  92. port_ocean/tests/test_smoke.py +74 -0
  93. port_ocean/tests/utils/test_async_iterators.py +45 -0
  94. port_ocean/tests/utils/test_cache.py +189 -0
  95. port_ocean/utils/async_iterators.py +109 -0
  96. port_ocean/utils/cache.py +37 -1
  97. port_ocean/utils/misc.py +22 -4
  98. port_ocean/utils/queue_utils.py +88 -0
  99. port_ocean/utils/signal.py +1 -4
  100. port_ocean/utils/time.py +54 -0
  101. {port_ocean-0.5.6.dist-info → port_ocean-0.17.8.dist-info}/METADATA +27 -19
  102. port_ocean-0.17.8.dist-info/RECORD +164 -0
  103. {port_ocean-0.5.6.dist-info → port_ocean-0.17.8.dist-info}/WHEEL +1 -1
  104. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/.dockerignore +0 -94
  105. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/Dockerfile +0 -15
  106. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/config.yaml +0 -17
  107. port_ocean/core/handlers/entities_state_applier/port/validate_entity_relations.py +0 -40
  108. port_ocean/core/utils.py +0 -65
  109. port_ocean-0.5.6.dist-info/RECORD +0 -129
  110. {port_ocean-0.5.6.dist-info → port_ocean-0.17.8.dist-info}/LICENSE.md +0 -0
  111. {port_ocean-0.5.6.dist-info → port_ocean-0.17.8.dist-info}/entry_points.txt +0 -0
port_ocean/config/base.py CHANGED
@@ -1,11 +1,11 @@
1
1
  import os
2
2
  import re
3
- from pathlib import Path
4
3
  from types import GenericAlias
5
4
  from typing import Any
6
5
 
7
6
  import yaml
8
7
  from humps import decamelize
8
+ from pathlib import Path
9
9
  from pydantic import BaseSettings
10
10
  from pydantic.env_settings import EnvSettingsSource, InitSettingsSource
11
11
  from pydantic.main import ModelMetaclass, BaseModel
@@ -14,18 +14,21 @@ PROVIDER_WRAPPER_PATTERN = r"{{ from (.*) }}"
14
14
  PROVIDER_CONFIG_PATTERN = r"^[a-zA-Z0-9]+ .*$"
15
15
 
16
16
 
17
- def read_yaml_config_settings_source(
18
- settings: "BaseOceanSettings", base_path: str
19
- ) -> str:
20
- yaml_file = getattr(settings.__config__, "yaml_file", "")
17
+ def read_yaml_config_settings_source(settings: "BaseOceanSettings") -> dict[str, Any]:
18
+ yaml_file = getattr(settings.Config, "yaml_file", "")
21
19
 
22
20
  assert yaml_file, "Settings.yaml_file not properly configured"
23
- path = Path(base_path, yaml_file)
21
+ path = Path(
22
+ getattr(
23
+ settings,
24
+ "_base_path",
25
+ ),
26
+ yaml_file,
27
+ )
24
28
 
25
29
  if not path.exists():
26
- raise FileNotFoundError(f"Could not open yaml settings file at: {path}")
27
-
28
- return path.read_text("utf-8")
30
+ return {}
31
+ return yaml.safe_load(path.read_text("utf-8"))
29
32
 
30
33
 
31
34
  def parse_config_provider(value: str) -> tuple[str, str]:
@@ -120,16 +123,15 @@ def decamelize_config(
120
123
 
121
124
 
122
125
  def load_providers(
123
- settings: "BaseOceanSettings", existing_values: dict[str, Any], base_path: str
126
+ settings: "BaseOceanSettings", existing_values: dict[str, Any]
124
127
  ) -> dict[str, Any]:
125
- yaml_content = read_yaml_config_settings_source(settings, base_path)
126
- data = yaml.safe_load(yaml_content)
128
+ data = read_yaml_config_settings_source(settings)
127
129
  snake_case_config = decamelize_config(settings, data)
128
130
  return parse_providers(settings, snake_case_config, existing_values)
129
131
 
130
132
 
131
133
  class BaseOceanSettings(BaseSettings):
132
- base_path: str
134
+ _base_path: str = "./"
133
135
 
134
136
  def get_sensitive_fields_data(self) -> set[str]:
135
137
  return _get_sensitive_information(self)
@@ -152,9 +154,7 @@ class BaseOceanSettings(BaseSettings):
152
154
  return (
153
155
  init_settings,
154
156
  env_settings,
155
- lambda s: load_providers(
156
- s, env_settings(s), init_settings.init_kwargs["base_path"]
157
- ),
157
+ lambda s: load_providers(s, {**env_settings(s), **init_settings(s)}),
158
158
  )
159
159
 
160
160
 
@@ -1,16 +1,20 @@
1
- from typing import Any, Literal
1
+ from typing import Any, Literal, Type, cast
2
2
 
3
- from pydantic import Extra, AnyHttpUrl, parse_obj_as, validator
3
+ from pydantic import Extra, AnyHttpUrl, parse_obj_as, parse_raw_as
4
+ from pydantic.class_validators import root_validator, validator
5
+ from pydantic.env_settings import InitSettingsSource, EnvSettingsSource, BaseSettings
4
6
  from pydantic.fields import Field
5
7
  from pydantic.main import BaseModel
6
8
 
7
9
  from port_ocean.config.base import BaseOceanSettings, BaseOceanModel
8
10
  from port_ocean.core.event_listener import EventListenerSettingsType
11
+ from port_ocean.core.models import Runtime
12
+ from port_ocean.utils.misc import get_integration_name, get_spec_file
9
13
 
10
14
  LogLevelType = Literal["ERROR", "WARNING", "INFO", "DEBUG", "CRITICAL"]
11
15
 
12
16
 
13
- class ApplicationSettings(BaseOceanModel):
17
+ class ApplicationSettings(BaseSettings):
14
18
  log_level: LogLevelType = "INFO"
15
19
  enable_http_logging: bool = True
16
20
  port: int = 8000
@@ -21,7 +25,13 @@ class ApplicationSettings(BaseOceanModel):
21
25
  env_file_encoding = "utf-8"
22
26
 
23
27
  @classmethod
24
- def customise_sources(cls, init_settings, env_settings, *_, **__): # type: ignore
28
+ def customise_sources( # type: ignore
29
+ cls,
30
+ init_settings: InitSettingsSource,
31
+ env_settings: EnvSettingsSource,
32
+ *_,
33
+ **__,
34
+ ):
25
35
  return env_settings, init_settings
26
36
 
27
37
 
@@ -29,22 +39,80 @@ class PortSettings(BaseOceanModel, extra=Extra.allow):
29
39
  client_id: str = Field(..., sensitive=True)
30
40
  client_secret: str = Field(..., sensitive=True)
31
41
  base_url: AnyHttpUrl = parse_obj_as(AnyHttpUrl, "https://api.getport.io")
42
+ port_app_config_cache_ttl: int = 60
32
43
 
33
44
 
34
45
  class IntegrationSettings(BaseOceanModel, extra=Extra.allow):
35
46
  identifier: str
36
47
  type: str
37
- config: dict[str, Any] | BaseModel
48
+ config: Any = Field(default_factory=dict)
38
49
 
39
- @validator("identifier", "type")
40
- def validate_lower(cls, v: str) -> str:
41
- return v.lower()
50
+ @root_validator(pre=True)
51
+ def root_validator(cls, values: dict[str, Any]) -> dict[str, Any]:
52
+ integ_type = values.get("type")
53
+
54
+ if not integ_type:
55
+ integ_type = get_integration_name()
56
+
57
+ values["type"] = integ_type.lower() if integ_type else None
58
+ if not values.get("identifier"):
59
+ values["identifier"] = f"my-{integ_type}-integration".lower()
60
+
61
+ return values
42
62
 
43
63
 
44
64
  class IntegrationConfiguration(BaseOceanSettings, extra=Extra.allow):
65
+ _integration_config_model: BaseModel | None = None
66
+
67
+ allow_environment_variables_jq_access: bool = True
45
68
  initialize_port_resources: bool = True
46
69
  scheduled_resync_interval: int | None = None
47
- client_timeout: int = 30
70
+ client_timeout: int = 60
71
+ send_raw_data_examples: bool = True
48
72
  port: PortSettings
49
- event_listener: EventListenerSettingsType
50
- integration: IntegrationSettings
73
+ event_listener: EventListenerSettingsType = Field(
74
+ default=cast(EventListenerSettingsType, {"type": "POLLING"})
75
+ )
76
+ # If an identifier or type is not provided, it will be generated based on the integration name
77
+ integration: IntegrationSettings = Field(
78
+ default_factory=lambda: IntegrationSettings(type="", identifier="")
79
+ )
80
+ runtime: Runtime = Runtime.OnPrem
81
+ resources_path: str = Field(default=".port/resources")
82
+
83
+ @root_validator()
84
+ def validate_integration_config(cls, values: dict[str, Any]) -> dict[str, Any]:
85
+ if not (config_model := values.get("_integration_config_model")):
86
+ return values
87
+
88
+ # Using the integration dynamic config model to parse the config
89
+ def parse_config(model: Type[BaseModel], config: Any) -> BaseModel:
90
+ # In some cases, the config is parsed as a string so we need to handle it
91
+ # Example: when the config is loaded from the environment variables and there is an object inside the config
92
+ if isinstance(config, str):
93
+ return parse_raw_as(model, config)
94
+ else:
95
+ return parse_obj_as(model, config)
96
+
97
+ integration_config = values["integration"]
98
+ integration_config.config = parse_config(
99
+ config_model, integration_config.config
100
+ )
101
+
102
+ return values
103
+
104
+ @validator("runtime")
105
+ def validate_runtime(cls, runtime: Runtime) -> Runtime:
106
+ if runtime.is_saas_runtime:
107
+ spec = get_spec_file()
108
+ if spec is None:
109
+ raise ValueError(
110
+ "Could not determine whether it's safe to run "
111
+ "the integration due to not found spec.yaml."
112
+ )
113
+
114
+ saas_config = spec.get("saas")
115
+ if saas_config and not saas_config["enabled"]:
116
+ raise ValueError("This integration can't be ran as Saas")
117
+
118
+ return runtime
@@ -14,6 +14,7 @@ from typing import (
14
14
  from uuid import uuid4
15
15
 
16
16
  from loguru import logger
17
+ from port_ocean.core.utils.entity_topological_sorter import EntityTopologicalSorter
17
18
  from pydispatch import dispatcher # type: ignore
18
19
  from werkzeug.local import LocalStack, LocalProxy
19
20
 
@@ -24,6 +25,7 @@ from port_ocean.exceptions.context import (
24
25
  )
25
26
  from port_ocean.utils.misc import get_time
26
27
 
28
+
27
29
  if TYPE_CHECKING:
28
30
  from port_ocean.core.handlers.port_app_config.models import (
29
31
  ResourceConfig,
@@ -50,6 +52,9 @@ class EventContext:
50
52
  _parent_event: Optional["EventContext"] = None
51
53
  _event_id: str = field(default_factory=lambda: str(uuid4()))
52
54
  _on_abort_callbacks: list[AbortCallbackFunction] = field(default_factory=list)
55
+ entity_topological_sorter: EntityTopologicalSorter = field(
56
+ default_factory=EntityTopologicalSorter
57
+ )
53
58
 
54
59
  def on_abort(self, func: AbortCallbackFunction) -> None:
55
60
  self._on_abort_callbacks.append(func)
@@ -125,10 +130,17 @@ async def event_context(
125
130
  event_type: str,
126
131
  trigger_type: TriggerType = "manual",
127
132
  attributes: dict[str, Any] | None = None,
133
+ parent_override: EventContext | None = None,
128
134
  ) -> AsyncIterator[EventContext]:
129
- attributes = attributes or {}
135
+ parent = parent_override or _event_context_stack.top
136
+ parent_attributes = parent.attributes if parent else {}
137
+ entity_topological_sorter = (
138
+ parent.entity_topological_sorter
139
+ if parent and parent.entity_topological_sorter
140
+ else EntityTopologicalSorter()
141
+ )
130
142
 
131
- parent = _event_context_stack.top
143
+ attributes = {**parent_attributes, **(attributes or {})}
132
144
  new_event = EventContext(
133
145
  event_type,
134
146
  trigger_type=trigger_type,
@@ -136,6 +148,7 @@ async def event_context(
136
148
  _parent_event=parent,
137
149
  # inherit port app config from parent event, so it can be used in nested events
138
150
  _port_app_config=parent.port_app_config if parent else None,
151
+ entity_topological_sorter=entity_topological_sorter,
139
152
  )
140
153
  _event_context_stack.push(new_event)
141
154
 
@@ -156,9 +169,9 @@ async def event_context(
156
169
  event_kind=event.event_type,
157
170
  event_id=event.id,
158
171
  event_parent_id=event.parent_id,
159
- event_resource_kind=event.resource_config.kind
160
- if event.resource_config
161
- else None,
172
+ event_resource_kind=(
173
+ event.resource_config.kind if event.resource_config else None
174
+ ),
162
175
  ):
163
176
  logger.info("Event started")
164
177
  try:
@@ -23,6 +23,8 @@ if TYPE_CHECKING:
23
23
  from port_ocean.ocean import Ocean
24
24
  from port_ocean.clients.port.client import PortClient
25
25
 
26
+ from loguru import logger
27
+
26
28
 
27
29
  class PortOceanContext:
28
30
  def __init__(self, app: Union["Ocean", None]) -> None:
@@ -63,14 +65,23 @@ class PortOceanContext:
63
65
  return self.app.port_client
64
66
 
65
67
  @property
66
- def event_listener_type(self) -> Literal["WEBHOOK", "KAFKA", "POLLING", "ONCE"]:
68
+ def event_listener_type(
69
+ self,
70
+ ) -> Literal["WEBHOOK", "KAFKA", "POLLING", "ONCE", "WEBHOOKS_ONLY"]:
67
71
  return self.app.config.event_listener.type
68
72
 
69
73
  def on_resync(
70
74
  self,
71
75
  kind: str | None = None,
72
- ) -> Callable[[RESYNC_EVENT_LISTENER], RESYNC_EVENT_LISTENER]:
73
- def wrapper(function: RESYNC_EVENT_LISTENER) -> RESYNC_EVENT_LISTENER:
76
+ ) -> Callable[[RESYNC_EVENT_LISTENER | None], RESYNC_EVENT_LISTENER | None]:
77
+ def wrapper(
78
+ function: RESYNC_EVENT_LISTENER | None,
79
+ ) -> RESYNC_EVENT_LISTENER | None:
80
+ if not self.app.config.event_listener.should_resync:
81
+ logger.debug(
82
+ "Webhook only event listener is used, resync events are ignored"
83
+ )
84
+ return None
74
85
  return self.integration.on_resync(function, kind)
75
86
 
76
87
  return wrapper
@@ -4,6 +4,7 @@ from typing import Type
4
4
  import httpx
5
5
  from loguru import logger
6
6
 
7
+ from port_ocean.config.settings import IntegrationConfiguration
7
8
  from port_ocean.context.ocean import ocean
8
9
  from port_ocean.core.defaults.common import (
9
10
  get_port_integration_defaults,
@@ -14,12 +15,13 @@ from port_ocean.core.handlers.port_app_config.models import PortAppConfig
14
15
 
15
16
  def clean_defaults(
16
17
  config_class: Type[PortAppConfig],
18
+ integration_config: IntegrationConfiguration,
17
19
  force: bool,
18
20
  wait: bool,
19
21
  ) -> None:
20
22
  try:
21
23
  asyncio.new_event_loop().run_until_complete(
22
- _clean_defaults(config_class, force, wait)
24
+ _clean_defaults(config_class, integration_config, force, wait)
23
25
  )
24
26
 
25
27
  except Exception as e:
@@ -27,13 +29,18 @@ def clean_defaults(
27
29
 
28
30
 
29
31
  async def _clean_defaults(
30
- config_class: Type[PortAppConfig], force: bool, wait: bool
32
+ config_class: Type[PortAppConfig],
33
+ integration_config: IntegrationConfiguration,
34
+ force: bool,
35
+ wait: bool,
31
36
  ) -> None:
32
37
  port_client = ocean.port_client
33
38
  is_exists = await is_integration_exists(port_client)
34
39
  if not is_exists:
35
40
  return None
36
- defaults = get_port_integration_defaults(config_class)
41
+ defaults = get_port_integration_defaults(
42
+ config_class, integration_config.resources_path
43
+ )
37
44
  if not defaults:
38
45
  return None
39
46
 
@@ -3,6 +3,7 @@ from pathlib import Path
3
3
  from typing import Type, Any, TypedDict, Optional
4
4
 
5
5
  import httpx
6
+ from loguru import logger
6
7
  import yaml
7
8
  from pydantic import BaseModel, Field
8
9
  from starlette import status
@@ -24,7 +25,7 @@ class Preset(TypedDict):
24
25
 
25
26
  class Defaults(BaseModel):
26
27
  blueprints: list[dict[str, Any]] = []
27
- actions: list[Preset] = []
28
+ actions: list[dict[str, Any]] = []
28
29
  scorecards: list[Preset] = []
29
30
  pages: list[dict[str, Any]] = []
30
31
  port_app_config: Optional[PortAppConfig] = Field(
@@ -77,18 +78,33 @@ def deconstruct_blueprints_to_creation_steps(
77
78
  )
78
79
 
79
80
 
81
+ def is_valid_dir(path: Path) -> bool:
82
+ return path.is_dir()
83
+
84
+
80
85
  def get_port_integration_defaults(
81
- port_app_config_class: Type[PortAppConfig], base_path: Path = Path(".")
86
+ port_app_config_class: Type[PortAppConfig],
87
+ custom_defaults_dir: Optional[str] = None,
88
+ base_path: Path = Path("."),
82
89
  ) -> Defaults | None:
83
- defaults_dir = base_path / ".port/resources"
84
- if not defaults_dir.exists():
85
- return None
86
-
87
- if not defaults_dir.is_dir():
88
- raise UnsupportedDefaultFileType(
89
- f"Defaults directory is not a directory: {defaults_dir}"
90
+ fallback_dir = base_path / ".port/resources"
91
+
92
+ if custom_defaults_dir and is_valid_dir(base_path / custom_defaults_dir):
93
+ defaults_dir = base_path / custom_defaults_dir
94
+ elif is_valid_dir(fallback_dir):
95
+ logger.info(
96
+ f"Could not find custom defaults directory {custom_defaults_dir}, falling back to {fallback_dir}",
97
+ fallback_dir=fallback_dir,
98
+ custom_defaults_dir=custom_defaults_dir,
99
+ )
100
+ defaults_dir = fallback_dir
101
+ else:
102
+ logger.warning(
103
+ f"Could not find defaults directory {fallback_dir}, skipping defaults"
90
104
  )
105
+ return None
91
106
 
107
+ logger.info(f"Loading defaults from {defaults_dir}", defaults_dir=defaults_dir)
92
108
  default_jsons = {}
93
109
  allowed_file_names = [
94
110
  field_model.alias for _, field_model in Defaults.__fields__.items()