Repository: pypa/hatch

Issue:
Context Formatting Not Compatible with Environments?
The docs mention that local projects are supported via [context formatting](https://hatch.pypa.io/latest/config/dependency/#local), but I can't seem to get it working inside environments.

A minimal example is one where you have a project with multiple other projects in a namespace, in this case `main` is the main project, and there are three projects in a directory `namespace` (`proj1`, `proj2`, `proj3`):

```
.
├── namespace
│  ├── proj1
│  │  ├── pyproject.toml
│  │  └── ...
│  ├── proj2
│  │  ├── pyproject.toml
│  │  └── ...
│  ├── proj3
│  │  ├── pyproject.toml
│  │  └── ...
├── pyproject.toml
└── ...
```

`main/pyproject.toml` can have these local projects as dependencies, and an environment with some random dependency from pypi, and that works:

```toml
[project]
dependencies = [
  "proj1 @ {root:uri}/namespace/proj1",
]

[tool.hatch.envs.cowsay]
extra-dependencies = [
  "cowsay",
]
```

```
hatch env show
             Standalone             
┏━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━┓
┃ Name    ┃ Type    ┃ Dependencies ┃
┡━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━┩
│ default │ virtual │              │
├─────────┼─────────┼──────────────┤
│ cowsay  │ virtual │ cowsay       │
└─────────┴─────────┴──────────────┘
```

However using context formatting in the environment dependencies does not work:

```toml
[project]
dependencies = [
  "proj1 @ {root:uri}/namespace/proj1",
]

[tool.hatch.envs.cowsay]
extra-dependencies = [
  "cowsay",
]

[tool.hatch.envs.with2]
extra-dependencies = [
  "proj2 @ {root:uri}/namespace/proj2",
]
```

Running `hatch env show` now gives a `InvalidRequirement: Invalid URL: {root:uri}/namespace/proj2` error.

Discussion:
RobertRosca: Test failures on different platforms are caused by the URI path being longer than expected, causing the table to be wider than the hard coded table width in the assert statement. The tests should probably just be done with the JSON output to avoid issues caused by table formatting widths changing with the temp paths.
RobertRosca: Just changing the tests to check the JSON output didn't work, as the JSON output just shows the raw unformatted string.

Is that the expected behaviour, or should JSON output have context formatting applied to the dependencies?

Anyway, instead I changed the tests to something much more basic, now it just checks that the URI path is in the table output, ignoring the rest of the formatting.

Patch:
diff --git a/backend/src/hatchling/__about__.py b/backend/src/hatchling/__about__.py
index e5102d30..52af183e 100644
--- a/backend/src/hatchling/__about__.py
+++ b/backend/src/hatchling/__about__.py
@@ -1,1 +1,1 @@
-__version__ = '1.9.0'
+__version__ = '1.10.0'
diff --git a/backend/src/hatchling/builders/config.py b/backend/src/hatchling/builders/config.py
index 329b22bb..3f295f97 100644
--- a/backend/src/hatchling/builders/config.py
+++ b/backend/src/hatchling/builders/config.py
@@ -787,7 +787,7 @@ class BuilderConfig:
         return []
 
     def default_global_exclude(self):
-        patterns = ['*.py[cdo]', *EXCLUDED_DIRECTORIES]
+        patterns = ['*.py[cdo]', f'/{DEFAULT_BUILD_DIRECTORY}']
         patterns.sort()
         return patterns
 
diff --git a/backend/src/hatchling/builders/constants.py b/backend/src/hatchling/builders/constants.py
index 73c690d0..d25af73a 100644
--- a/backend/src/hatchling/builders/constants.py
+++ b/backend/src/hatchling/builders/constants.py
@@ -1,6 +1,23 @@
 DEFAULT_BUILD_DIRECTORY = 'dist'
 
-EXCLUDED_DIRECTORIES = frozenset(('.git', '__pycache__'))
+EXCLUDED_DIRECTORIES = frozenset(
+    (
+        # Python bytecode
+        '__pycache__',
+        # PEP 582
+        '__pypackages__',
+        # Git
+        '.git',
+        # Mercurial
+        '.hg',
+        # Hatch
+        '.hatch',
+        # tox
+        '.tox',
+        # nox
+        '.nox',
+    )
+)
 
 
 class BuildEnvVars:
diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py
index 55dcbdbb..24544ad1 100644
--- a/backend/src/hatchling/metadata/core.py
+++ b/backend/src/hatchling/metadata/core.py
@@ -28,6 +28,7 @@ class ProjectMetadata:
         self._hatch = None
 
         self._core_raw_metadata = None
+        self._dynamic = None
         self._name = None
         self._version = None
         self._project_file = None
@@ -63,6 +64,22 @@ class ProjectMetadata:
 
         return self._core_raw_metadata
 
+    @property
+    def dynamic(self):
+        # Keep track of the original dynamic fields before depopulation
+        if self._dynamic is None:
+            dynamic = self.core_raw_metadata.get('dynamic', [])
+            if not isinstance(dynamic, list):
+                raise TypeError('Field `project.dynamic` must be an array')
+
+            for i, field in enumerate(dynamic, 1):
+                if not isinstance(field, str):
+                    raise TypeError(f'Field #{i} of field `project.dynamic` must be a string')
+
+            self._dynamic = list(dynamic)
+
+        return self._dynamic
+
     @property
     def name(self):
         # Duplicate the name parsing here for situations where it's
@@ -83,6 +100,8 @@ class ProjectMetadata:
         """
         if self._version is None:
             self._set_version()
+            if 'version' in self.dynamic and 'version' in self.core_raw_metadata['dynamic']:
+                self.core_raw_metadata['dynamic'].remove('version')
 
         return self._version
 
@@ -114,6 +133,9 @@ class ProjectMetadata:
         if self._core is None:
             metadata = CoreMetadata(self.root, self.core_raw_metadata, self.hatch.metadata, self.context)
 
+            # Save the fields
+            _ = self.dynamic
+
             metadata_hooks = self.hatch.metadata.hooks
             if metadata_hooks:
                 static_fields = set(self.core_raw_metadata)
@@ -172,6 +194,7 @@ class ProjectMetadata:
         if version is None:
             version = self.hatch.version.cached
             source = f'source `{self.hatch.version.source_name}`'
+            core_metadata._version_set = True
         else:
             source = 'field `project.version`'
 
@@ -304,6 +327,9 @@ class CoreMetadata:
         self._optional_dependencies = None
         self._dynamic = None
 
+        # Indicates that the version has been successfully set dynamically
+        self._version_set = False
+
     @property
     def raw_name(self):
         """
@@ -349,7 +375,7 @@ class CoreMetadata:
         """
         if self._version is None:
             if 'version' not in self.config:
-                if 'version' not in self.dynamic:
+                if not self._version_set and 'version' not in self.dynamic:
                     raise ValueError(
                         'Field `project.version` can only be resolved dynamically '
                         'if `version` is in field `project.dynamic`'
@@ -1123,7 +1149,10 @@ class CoreMetadata:
 
                         entries[get_normalized_dependency(requirement)] = requirement
 
-                normalized_option = normalize_project_name(option)
+                if self.hatch_metadata.allow_ambiguous_features:
+                    normalized_option = option
+                else:
+                    normalized_option = normalize_project_name(option)
                 if normalized_option in normalized_options:
                     raise ValueError(
                         f'Optional dependency groups `{normalized_options[normalized_option]}` and `{option}` of '
@@ -1155,15 +1184,7 @@ class CoreMetadata:
         https://peps.python.org/pep-0621/#dynamic
         """
         if self._dynamic is None:
-            dynamic = self.config.get('dynamic', [])
-            if not isinstance(dynamic, list):
-                raise TypeError('Field `project.dynamic` must be an array')
-
-            for i, field in enumerate(dynamic, 1):
-                if not isinstance(field, str):
-                    raise TypeError(f'Field #{i} of field `project.dynamic` must be a string')
-
-            self._dynamic = dynamic
+            self._dynamic = self.config.get('dynamic', [])
 
         return self._dynamic
 
@@ -1327,6 +1348,7 @@ class HatchMetadataSettings:
         self.plugin_manager = plugin_manager
 
         self._allow_direct_references = None
+        self._allow_ambiguous_features = None
         self._hook_config = None
         self._hooks = None
 
@@ -1341,6 +1363,18 @@ class HatchMetadataSettings:
 
         return self._allow_direct_references
 
+    @property
+    def allow_ambiguous_features(self):
+        # TODO: remove in the first minor release after Jan 1, 2024
+        if self._allow_ambiguous_features is None:
+            allow_ambiguous_features = self.config.get('allow-ambiguous-features', False)
+            if not isinstance(allow_ambiguous_features, bool):
+                raise TypeError('Field `tool.hatch.metadata.allow-ambiguous-features` must be a boolean')
+
+            self._allow_ambiguous_features = allow_ambiguous_features
+
+        return self._allow_ambiguous_features
+
     @property
     def hook_config(self):
         if self._hook_config is None:
diff --git a/backend/src/hatchling/metadata/spec.py b/backend/src/hatchling/metadata/spec.py
index 2fb6dd2a..43a0fa67 100644
--- a/backend/src/hatchling/metadata/spec.py
+++ b/backend/src/hatchling/metadata/spec.py
@@ -138,6 +138,8 @@ def construct_metadata_file_2_1(metadata, extra_dependencies=()):
             for dependency in dependencies:
                 if ';' in dependency:
                     metadata_file += f'Requires-Dist: {dependency} and extra == {option!r}\n'
+                elif '@ ' in dependency:
+                    metadata_file += f'Requires-Dist: {dependency} ; extra == {option!r}\n'
                 else:
                     metadata_file += f'Requires-Dist: {dependency}; extra == {option!r}\n'
 
@@ -214,6 +216,8 @@ def construct_metadata_file_2_2(metadata, extra_dependencies=()):
             for dependency in dependencies:
                 if ';' in dependency:
                     metadata_file += f'Requires-Dist: {dependency} and extra == {option!r}\n'
+                elif '@ ' in dependency:
+                    metadata_file += f'Requires-Dist: {dependency} ; extra == {option!r}\n'
                 else:
                     metadata_file += f'Requires-Dist: {dependency}; extra == {option!r}\n'
 
@@ -282,6 +286,8 @@ def construct_metadata_file_2_3(metadata, extra_dependencies=()):
             for dependency in dependencies:
                 if ';' in dependency:
                     metadata_file += f'Requires-Dist: {dependency} and extra == {option!r}\n'
+                elif '@ ' in dependency:
+                    metadata_file += f'Requires-Dist: {dependency} ; extra == {option!r}\n'
                 else:
                     metadata_file += f'Requires-Dist: {dependency}; extra == {option!r}\n'
 
diff --git a/docs/config/metadata.md b/docs/config/metadata.md
index f6aefc0c..63ed37d8 100644
--- a/docs/config/metadata.md
+++ b/docs/config/metadata.md
@@ -293,3 +293,24 @@ By default, [dependencies](#dependencies) are not allowed to define [direct refe
     [metadata]
     allow-direct-references = true
     ```
+
+### Allowing ambiguous features
+
+By default, names of [optional dependencies](#optional) are normalized to prevent ambiguity. To disable this normalization, set `allow-ambiguous-features` to `true`:
+
+=== ":octicons-file-code-16: pyproject.toml"
+
+    ```toml
+    [tool.hatch.metadata]
+    allow-ambiguous-features = true
+    ```
+
+=== ":octicons-file-code-16: hatch.toml"
+
+    ```toml
+    [metadata]
+    allow-ambiguous-features = true
+    ```
+
+!!! danger "Deprecated"
+    This option temporarily exists to provide better interoperability with tools that do not yet support [PEP 685](https://peps.python.org/pep-0685/) and will be removed in the first minor release after Jan 1, 2024.
diff --git a/docs/history.md b/docs/history.md
index 9d03f96f..cbc4720a 100644
--- a/docs/history.md
+++ b/docs/history.md
@@ -22,10 +22,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 - Add `build_environment_exists` method to the environment interface for implementations that cache the build environment
 - Support Bash on Windows for the `shell` command
 - The `setuptools` migration script no longer modifies the formatting of existing `pyproject.toml` configuration
-- Bump the minimum supported version of Hatchling to 1.9.0
+- Bump the minimum supported version of Hatchling to 1.10.0
 
 ***Fixed:***
 
+- Environments now respect dynamically defined project dependencies
 - Handle more edge cases in the `setuptools` migration script
 - Depend on `packaging` explicitly rather than relying on it being a transitive dependency of Hatchling
 
@@ -174,6 +175,18 @@ This is the first stable release of Hatch v1, a complete rewrite. Enjoy!
 
 ### Unreleased
 
+### [1.10.0](https://github.com/pypa/hatch/releases/tag/hatchling-v1.10.0) - 2022-09-18 ### {: #hatchling-v1.10.0 }
+
+***Added:***
+
+- Add the following to the list of directories that cannot be traversed: `__pypackages__`, `.hg`, `.hatch`, `.tox`, `.nox`
+- Add deprecated option to allow ambiguous features
+
+***Fixed:***
+
+- Improve tracking of dynamic metadata
+- Fix core metadata for entries in `project.optional-dependencies` that use direct references
+
 ### [1.9.0](https://github.com/pypa/hatch/releases/tag/hatchling-v1.9.0) - 2022-09-09 ### {: #hatchling-v1.9.0 }
 
 ***Changed:***
diff --git a/docs/plugins/version-source/reference.md b/docs/plugins/version-source/reference.md
index e969f819..02a162d8 100644
--- a/docs/plugins/version-source/reference.md
+++ b/docs/plugins/version-source/reference.md
@@ -6,6 +6,7 @@
 
 - [hatch-vcs](https://github.com/ofek/hatch-vcs) - uses your preferred version control system (like Git)
 - [hatch-nodejs-version](https://github.com/agoose77/hatch-nodejs-version) - uses the `version` field of NodeJS `package.json` files
+- [hatch-regex-commit](https://github.com/frankie567/hatch-regex-commit) - automatically creates a Git commit and tag after version bumping
 
 ::: hatchling.version.source.plugin.interface.VersionSourceInterface
     options:
diff --git a/docs/users.md b/docs/users.md
index d60ff544..03e1253e 100644
--- a/docs/users.md
+++ b/docs/users.md
@@ -13,7 +13,7 @@ The following is not intended to be a complete enumeration. Be sure to view the
 | [HTTPX](https://github.com/encode/httpx/blob/45b7cfaad3a8987ea35fa5bf092bbdda485444fd/pyproject.toml#L1-L3)
 | [iCalendar for Humans](https://github.com/ics-py/ics-py/blob/133a0955f6efbb83ff0eae45ad0bbe6902a8f2f1/pyproject.toml#L61-L63)
 | [LinkChecker](https://github.com/linkchecker/linkchecker/blob/de40321b57a2271e90e696b5320c0409faaa895d/pyproject.toml#L29-L34)
-| [Material for MkDocs](https://github.com/squidfunk/mkdocs-material/blob/9603c71adafe5959e57584a4982ee03d340a7be3/pyproject.toml#L1-L3)
+| [Material for MkDocs](https://github.com/squidfunk/mkdocs-material/blob/e6d829556aaa78cff59f91433943708488fb290c/pyproject.toml#L21-L23)
 | [pipx](https://github.com/pypa/pipx/blob/bc7dd03c4d872c443257685109a650ec3d524814/pyproject.toml#L1-L3)
 | [platformdirs](https://github.com/platformdirs/platformdirs/blob/382e961c436f9974e56dc69ce105b6fd8945c343/pyproject.toml#L1-L3)
 | [Pydantic](https://github.com/pydantic/pydantic/blob/f341049b9e5538a125751d75b4e44c1609b53df6/pyproject.toml#L1-L3)
@@ -29,14 +29,16 @@ The following is not intended to be a complete enumeration. Be sure to view the
 
 ## Industry
 
-- [Anaconda](https://www.anaconda.com) <sup>\[[1](https://github.com/ContinuumIO/dask-awkward/blob/105275b1937cce9a80a352af0b200d4e264f27f7/pyproject.toml#L1-L3)|[2](https://github.com/conda-incubator/ensureconda/blob/b20dbcf7166009ff4e9270f35ed75da7afc3db60/pyproject.toml#L1-L3)\]</sup>
+- [Anaconda](https://www.anaconda.com) <sup>\[[1](https://github.com/ContinuumIO/dask-awkward/blob/105275b1937cce9a80a352af0b200d4e264f27f7/pyproject.toml#L1-L3)|[2](https://github.com/conda-incubator/ensureconda/blob/b20dbcf7166009ff4e9270f35ed75da7afc3db60/pyproject.toml#L1-L3)|[3](https://github.com/conda-incubator/conda-lock/pull/239)\]</sup>
 - [Bloomberg](https://www.bloomberg.com) <sup>\[[1](https://github.com/bloomberg/pytest-memray/blob/4ea6a7608adb0de4572d35768fbd370aee016627/pyproject.toml#L1-L3)\]</sup>
 - [Datadog](https://www.datadoghq.com) <sup>\[[1](https://github.com/DataDog/datadogpy/blob/63d0c01b5bbcb8158cf3ddab153639951ab44945/pyproject.toml#L1-L3)|[2](https://github.com/DataDog/integrations-core/pulls?q=is%3Apr+author%3Aofek+in%3Atitle+Add+pyproject.toml+file)|[3](https://github.com/DataDog/integrations-extras/pulls?q=is%3Apr+author%3Aofek+in%3Atitle+Add+pyproject.toml+file)|[4](https://github.com/DataDog/mkdocs-click/blob/434925323f3bb187595d4c7f6a2c80b790015109/pyproject.toml#L1-L3)\]</sup>
 - [deepset](https://www.deepset.ai) <sup>\[[1](https://github.com/deepset-ai/haystack/blob/e92ea4fccb31001156dece6c1509e3e162a9de00/pyproject.toml#L1-L5)\]</sup>
 - [Elastic](https://www.elastic.co) <sup>\[[1](https://github.com/elastic/rally/blob/8ba7980bb25b85f25fe20f3fd5dd8e12b9b1214b/pyproject.toml#L1-L3)|[2](https://github.com/elastic/rally-tracks/blob/33840005cd3e2a6191d73a567e5c2c0858169270/pyproject.toml#L1-L3)\]</sup>
+- [Intel Corporation](https://www.intel.com) <sup>\[[1](https://github.com/intel/neural-compressor/blob/5f6f38b96d45d0253b8de239df51c09b2471a8fb/neural_coder/extensions/neurl_compressor_ext_lab_alibaba/pyproject.toml#L1-L3)\]</sup>
 - [Meta](https://about.facebook.com) <sup>\[[1](https://github.com/facebook/usort/blob/b3d1dc49abac0c06ac29f1ceb332d2b86a50e850/pyproject.toml#L1-L3)|[2](https://github.com/Instagram/Fixit/blob/c95b0ef9f8c02adfd6a541b55f22f0bd6a922706/pyproject.toml#L1-L3)\]</sup>
-- [Palo Alto Networks](https://www.paloaltonetworks.com) <sup>\[[1](https://github.com/PaloAltoNetworks/pc-python-integration/blob/08d6ed919f7b48b0e5663bc5fa8eb31213378585/pyproject.toml#L1-L2)\]</sup>
+- [Palo Alto Networks](https://www.paloaltonetworks.com) <sup>\[[1](https://github.com/PaloAltoNetworks/pc-python-integration/blob/a3e29d71c6704dfb07cf85d592dec15a9ea575b7/pyproject.toml#L1-L3)\]</sup>
 - [Virtru](https://www.virtru.com) <sup>\[[1](https://github.com/virtru/access-pdp/blob/46089e8a2ef691b80f92bbd6777bdfbcff1c1671/clients/python/accesspdp/pyproject.toml#L24-L26)|[2](https://github.com/virtru/access-pdp/blob/46089e8a2ef691b80f92bbd6777bdfbcff1c1671/clients/python/attributes/pyproject.toml#L21-L23)\]</sup>
+- [Volvo Group](https://www.volvogroup.com) <sup>\[[1](https://github.com/VolvoGroup/dymoval/blob/75261b85635dce594719b01c5fc33ad951ce55b0/pyproject.toml#L1-L3)\]</sup>
 
 ## Organizations
 
@@ -51,10 +53,16 @@ The following is not intended to be a complete enumeration. Be sure to view the
 ## Academia
 
 - [Clariah](https://www.clariah.nl) <sup>\[[1](https://github.com/CLARIAH/pure3d/blob/3f93d62cb1f5223836c9ebf4c058e6f491de71b9/pyproject.toml#L1-L3)\]</sup>
+- [Heidelberg University](https://www.uni-heidelberg.de)
+    - [Center for Molecular Biology](https://www.zmbh.uni-heidelberg.de) <sup>\[[1](https://github.com/anders-biostat/pymetdense/blob/a1d210f2c03d2919b549f2fed1e4db986d01c8d5/pyproject.toml#L1-L3)\]</sup>
+- [Siberian Branch of the Russian Academy of Sciences](https://www.sbras.ru/en/)
+    - [Institute of Cytology and Genetics](https://www.icgbio.ru/en/) <sup>\[[1](https://github.com/genomech/FastContext/blob/f8ff7f4bbea9d6d3cdf2e3a361f72e9283b04f67/pyproject.toml#L1-L3)|[2](https://github.com/genomech/exoclasma-index/blob/2e0555c3e86d731f3aa8c978b23b586d3a0c492e/pyproject.toml#L1-L3)|[3](https://github.com/genomech/exoclasma-fastq/blob/80ea3eddf603d2b54bb02b5ada6d275a9436f287/pyproject.toml#L1-L3)|[4](https://github.com/genomech/exoclasma-pipe/blob/fbe365dd9301eec51879ef53b1704be66813bb8b/pyproject.toml#L1-L3)\]</sup>
 - [Stanford University](https://www.stanford.edu)
     - [Empirical Security Research Group](https://esrg.stanford.edu/) <sup>\[[1](https://github.com/stanford-esrg/gps/blob/66f803bfd4726cd9d1b3e1724abfd34a36079530/pyproject.toml#L1-L3)\]</sup>
 - [University of Toronto Scarborough](https://www.utsc.utoronto.ca/home/)
     - [utsc-networking](https://github.com/utsc-networking) <sup>\[[1](https://github.com/utsc-networking/utsc-tools/blob/02a79d48d133470a4394fced138b40c660cf111c/projects/core/pyproject.toml#L1-L3)|[2](https://github.com/utsc-networking/utsc-tools/blob/02a79d48d133470a4394fced138b40c660cf111c/projects/nautobot/pyproject.toml#L1-L3)|[3](https://github.com/utsc-networking/utsc-tools/blob/02a79d48d133470a4394fced138b40c660cf111c/projects/switchconfig/pyproject.toml#L1-L3)|[4](https://github.com/utsc-networking/utsc-tools/blob/02a79d48d133470a4394fced138b40c660cf111c/projects/scripts/pyproject.toml#L1-L3)\]</sup>
+- [University of Washington](https://www.washington.edu)
+    - [Virtual Brain Lab](https://github.com/VirtualBrainLab) <sup>\[[1](https://github.com/VirtualBrainLab/ephys-link/blob/ebdf3a1488f1010faa19f22397f10d6be4d29d6f/pyproject.toml#L1-L3)\]</sup>
 
 ## Research
 
diff --git a/pyproject.toml b/pyproject.toml
index 65cf12cc..2f1f10be 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,5 @@
 [build-system]
-requires = ["hatchling>=1.9.0"]
+requires = ["hatchling>=1.10.0"]
 build-backend = "hatchling.build"
 
 [project]
@@ -39,7 +39,7 @@ classifiers = [
 ]
 dependencies = [
   "click>=8.0.3",
-  "hatchling>=1.9.0",
+  "hatchling>=1.10.0",
   "httpx>=0.22.0",
   "hyperlink>=21.0.0",
   "keyring>=23.5.0",
diff --git a/src/hatch/cli/application.py b/src/hatch/cli/application.py
index a37b2c5e..baa36a6b 100644
--- a/src/hatch/cli/application.py
+++ b/src/hatch/cli/application.py
@@ -200,3 +200,4 @@ class SafeApplication:
         self.prompt = app.prompt
         self.confirm = app.confirm
         self.status_waiting = app.status_waiting
+        self.read_builder = app.read_builder
diff --git a/src/hatch/cli/env/show.py b/src/hatch/cli/env/show.py
index 4c146b5a..dfcdbed0 100644
--- a/src/hatch/cli/env/show.py
+++ b/src/hatch/cli/env/show.py
@@ -29,7 +29,9 @@ def show(app, envs, force_ascii, as_json):
         if config.get('extra-dependencies'):
             dependencies.extend(config['extra-dependencies'])
         if dependencies:
-            columns['Dependencies'][i] = '\n'.join(get_normalized_dependencies(dependencies))
+            columns['Dependencies'][i] = '\n'.join(
+                get_normalized_dependencies(dependencies, context=app.project.metadata.context)
+            )
 
         if config.get('env-vars'):
             columns['Environment variables'][i] = '\n'.join(
diff --git a/src/hatch/cli/version/__init__.py b/src/hatch/cli/version/__init__.py
index 4d8ea01b..1f525c85 100644
--- a/src/hatch/cli/version/__init__.py
+++ b/src/hatch/cli/version/__init__.py
@@ -16,7 +16,8 @@ def version(app, desired_version):
     from hatchling.dep.core import dependencies_in_sync
 
     with app.project.location.as_cwd():
-        if dependencies_in_sync(app.project.metadata.build.requires_complex):
+        dynamic_version = 'version' in app.project.metadata.dynamic
+        if not dynamic_version or dependencies_in_sync(app.project.metadata.build.requires_complex):
             source = app.project.metadata.hatch.version.source
 
             version_data = source.get_version_data()
diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py
index 31f6fd05..e8b137df 100644
--- a/src/hatch/env/plugin/interface.py
+++ b/src/hatch/env/plugin/interface.py
@@ -315,9 +315,50 @@ class EnvironmentInterface(ABC):
             # Ensure these are checked last to speed up initial environment creation since
             # they will already be installed along with the project
             if not self.skip_install and self.dev_mode:
-                dependencies_complex.extend(self.metadata.core.dependencies_complex.values())
-                for feature in self.features:
-                    dependencies_complex.extend(self.metadata.core.optional_dependencies_complex[feature].values())
+                from hatchling.dep.core import dependencies_in_sync
+
+                dynamic_fields = self.metadata.dynamic
+                if 'dependencies' not in dynamic_fields and 'optional-dependencies' not in dynamic_fields:
+                    dependencies_complex.extend(self.metadata.core.dependencies_complex.values())
+                    for feature in self.features:
+                        dependencies_complex.extend(self.metadata.core.optional_dependencies_complex[feature].values())
+                elif dependencies_in_sync(self.metadata.build.requires_complex):
+                    dependencies_complex.extend(self.metadata.core.dependencies_complex.values())
+
+                    optional_dependencies = self.metadata.core.optional_dependencies_complex
+                    for feature in self.features:
+                        if feature not in optional_dependencies:
+                            raise ValueError(
+                                f'Feature `{feature}` of field `tool.hatch.envs.{self.name}.features` is not '
+                                f'defined in the dynamic field `project.optional-dependencies`'
+                            )
+
+                        dependencies_complex.extend(optional_dependencies[feature].values())
+                else:
+                    import json
+
+                    from packaging.requirements import Requirement
+
+                    with self.root.as_cwd(), self.build_environment(self.metadata.build.requires):
+                        command = ['python', '-u', '-m', 'hatchling', 'metadata', '--app', '--compact']
+                        process = self.platform.capture_process(command)
+                        project_metadata = json.loads(self.app.read_builder(process))
+
+                        dependencies_complex.extend(
+                            Requirement(dependency) for dependency in project_metadata.get('dependencies', [])
+                        )
+
+                        optional_dependencies = project_metadata.get('optional-dependencies', {})
+                        for feature in self.features:
+                            if feature not in optional_dependencies:
+                                raise ValueError(
+                                    f'Feature `{feature}` of field `tool.hatch.envs.{self.name}.features` is not '
+                                    f'defined in the dynamic field `project.optional-dependencies`'
+                                )
+
+                            dependencies_complex.extend(
+                                Requirement(dependency) for dependency in optional_dependencies[feature].values()
+                            )
 
             self._dependencies_complex = dependencies_complex
 
@@ -429,6 +470,7 @@ class EnvironmentInterface(ABC):
             if not isinstance(features, list):
                 raise TypeError(f'Field `tool.hatch.envs.{self.name}.features` must be an array of strings')
 
+            dynamic_features = 'optional-dependencies' in self.metadata.dynamic
             all_features = set()
             for i, feature in enumerate(features, 1):
                 if not isinstance(feature, str):
@@ -438,8 +480,9 @@ class EnvironmentInterface(ABC):
                         f'Feature #{i} of field `tool.hatch.envs.{self.name}.features` cannot be an empty string'
                     )
 
-                feature = normalize_project_name(feature)
-                if feature not in self.metadata.core.optional_dependencies:
+                if not self.metadata.hatch.metadata.allow_ambiguous_features:
+                    feature = normalize_project_name(feature)
+                if not dynamic_features and feature not in self.metadata.core.optional_dependencies:
                     raise ValueError(
                         f'Feature `{feature}` of field `tool.hatch.envs.{self.name}.features` is not '
                         f'defined in field `project.optional-dependencies`'
diff --git a/src/hatch/utils/dep.py b/src/hatch/utils/dep.py
index 49d94539..788a4c64 100644
--- a/src/hatch/utils/dep.py
+++ b/src/hatch/utils/dep.py
@@ -8,6 +8,11 @@ def normalize_marker_quoting(text):
     return text.replace('"', "'")
 
 
-def get_normalized_dependencies(dependencies):
+def get_normalized_dependencies(dependencies, context=None):
+    """Normalizes dependencies, optionally context can be provided to perform context
+    formatting before normalization"""
+    if context:
+        dependencies = [context.format(dependency) for dependency in dependencies]
+
     normalized_dependencies = {get_normalized_dependency(Requirement(dependency)) for dependency in dependencies}
     return sorted(normalized_dependencies)


Test patch:
diff --git a/tests/backend/builders/test_config.py b/tests/backend/builders/test_config.py
index 23a947a2..166f2897 100644
--- a/tests/backend/builders/test_config.py
+++ b/tests/backend/builders/test_config.py
@@ -1608,7 +1608,7 @@ class TestFileSelectionDefaults:
     def test_global_exclude(self, isolation):
         builder = Builder(str(isolation))
 
-        assert builder.config.default_global_exclude() == ['*.py[cdo]', '.git', '__pycache__']
+        assert builder.config.default_global_exclude() == ['*.py[cdo]', '/dist']
 
 
 class TestPatternInclude:
@@ -1738,7 +1738,7 @@ class TestPatternExclude:
         builder = Builder(str(isolation))
 
         assert isinstance(builder.config.exclude_spec, pathspec.GitIgnoreSpec)
-        assert builder.config.exclude_spec.match_file(f'.git{separator}file.py')
+        assert builder.config.exclude_spec.match_file(f'dist{separator}file.py')
 
     def test_global_invalid_type(self, isolation):
         config = {'tool': {'hatch': {'build': {'exclude': ''}}}}
diff --git a/tests/backend/metadata/test_core.py b/tests/backend/metadata/test_core.py
index 42089694..84e13f2b 100644
--- a/tests/backend/metadata/test_core.py
+++ b/tests/backend/metadata/test_core.py
@@ -92,6 +92,49 @@ class TestDynamic:
         assert metadata.core.dynamic is dynamic
         assert metadata.core.dynamic == ['version']
 
+    def test_cache_not_array(self, isolation):
+        metadata = ProjectMetadata(str(isolation), None, {'project': {'dynamic': 10}})
+
+        with pytest.raises(TypeError, match='Field `project.dynamic` must be an array'):
+            _ = metadata.dynamic
+
+    def test_cache_entry_not_string(self, isolation):
+        metadata = ProjectMetadata(str(isolation), None, {'project': {'dynamic': [10]}})
+
+        with pytest.raises(TypeError, match='Field #1 of field `project.dynamic` must be a string'):
+            _ = metadata.dynamic
+
+    def test_cache_correct(self, temp_dir, helpers):
+        metadata = ProjectMetadata(
+            str(temp_dir),
+            PluginManager(),
+            {
+                'project': {'name': 'foo', 'dynamic': ['version', 'description']},
+                'tool': {'hatch': {'version': {'path': 'a/b'}, 'metadata': {'hooks': {'custom': {}}}}},
+            },
+        )
+
+        file_path = temp_dir / 'a' / 'b'
+        file_path.ensure_parent_dir_exists()
+        file_path.write_text('__version__ = "0.0.1"')
+
+        file_path = temp_dir / DEFAULT_BUILD_SCRIPT
+        file_path.write_text(
+            helpers.dedent(
+                """
+                from hatchling.metadata.plugin.interface import MetadataHookInterface
+
+                class CustomHook(MetadataHookInterface):
+                    def update(self, metadata):
+                        metadata['description'] = metadata['name'] + 'bar'
+                """
+            )
+        )
+
+        # Trigger hooks with `metadata.core` first
+        assert metadata.core.dynamic == []
+        assert metadata.dynamic == ['version', 'description']
+
 
 class TestRawName:
     def test_dynamic(self, isolation):
@@ -1253,6 +1296,18 @@ class TestOptionalDependencies:
         ):
             _ = metadata.core.optional_dependencies
 
+    def test_allow_ambiguity(self, isolation):
+        metadata = ProjectMetadata(
+            str(isolation),
+            None,
+            {
+                'project': {'optional-dependencies': {'foo_bar': [], 'foo.bar': []}},
+                'tool': {'hatch': {'metadata': {'allow-ambiguous-features': True}}},
+            },
+        )
+
+        assert metadata.core.optional_dependencies == {'foo_bar': [], 'foo.bar': []}
+
     def test_direct_reference(self, isolation):
         metadata = ProjectMetadata(
             str(isolation),
diff --git a/tests/backend/metadata/test_hatch.py b/tests/backend/metadata/test_hatch.py
index 65daf8d6..f76cd683 100644
--- a/tests/backend/metadata/test_hatch.py
+++ b/tests/backend/metadata/test_hatch.py
@@ -156,3 +156,24 @@ class TestMetadataAllowDirectReferences:
         metadata = HatchMetadata(str(isolation), config, None)
 
         assert metadata.metadata.allow_direct_references is True
+
+
+class TestMetadataAllowAmbiguousFeatures:
+    def test_default(self, isolation):
+        config = {}
+        metadata = HatchMetadata(str(isolation), config, None)
+
+        assert metadata.metadata.allow_ambiguous_features is metadata.metadata.allow_ambiguous_features is False
+
+    def test_not_boolean(self, isolation):
+        config = {'metadata': {'allow-ambiguous-features': 9000}}
+        metadata = HatchMetadata(str(isolation), config, None)
+
+        with pytest.raises(TypeError, match='Field `tool.hatch.metadata.allow-ambiguous-features` must be a boolean'):
+            _ = metadata.metadata.allow_ambiguous_features
+
+    def test_correct(self, isolation):
+        config = {'metadata': {'allow-ambiguous-features': True}}
+        metadata = HatchMetadata(str(isolation), config, None)
+
+        assert metadata.metadata.allow_ambiguous_features is True
diff --git a/tests/backend/metadata/test_spec.py b/tests/backend/metadata/test_spec.py
index 0b8b6d88..67f3e907 100644
--- a/tests/backend/metadata/test_spec.py
+++ b/tests/backend/metadata/test_spec.py
@@ -697,9 +697,11 @@ class TestCoreMetadataV21:
                     'optional-dependencies': {
                         'feature2': ['foo==1; python_version < "3"', 'bar==5'],
                         'feature1': ['foo==1', 'bar==5; python_version < "3"'],
+                        'feature3': ['baz @ file:///path/to/project'],
                     },
                     'readme': {'content-type': 'text/markdown', 'text': 'test content\n'},
-                }
+                },
+                'tool': {'hatch': {'metadata': {'allow-direct-references': True}}},
             },
         )
 
@@ -730,6 +732,8 @@ class TestCoreMetadataV21:
             Provides-Extra: feature2
             Requires-Dist: bar==5; extra == 'feature2'
             Requires-Dist: foo==1; python_version < '3' and extra == 'feature2'
+            Provides-Extra: feature3
+            Requires-Dist: baz@ file:///path/to/project ; extra == 'feature3'
             Description-Content-Type: text/markdown
 
             test content
@@ -1099,9 +1103,11 @@ class TestCoreMetadataV22:
                     'optional-dependencies': {
                         'feature2': ['foo==1; python_version < "3"', 'bar==5'],
                         'feature1': ['foo==1', 'bar==5; python_version < "3"'],
+                        'feature3': ['baz @ file:///path/to/project'],
                     },
                     'readme': {'content-type': 'text/markdown', 'text': 'test content\n'},
-                }
+                },
+                'tool': {'hatch': {'metadata': {'allow-direct-references': True}}},
             },
         )
 
@@ -1132,6 +1138,8 @@ class TestCoreMetadataV22:
             Provides-Extra: feature2
             Requires-Dist: bar==5; extra == 'feature2'
             Requires-Dist: foo==1; python_version < '3' and extra == 'feature2'
+            Provides-Extra: feature3
+            Requires-Dist: baz@ file:///path/to/project ; extra == 'feature3'
             Description-Content-Type: text/markdown
 
             test content
@@ -1525,9 +1533,11 @@ class TestCoreMetadataV23:
                     'optional-dependencies': {
                         'feature2': ['foo==1; python_version < "3"', 'bar==5'],
                         'feature1': ['foo==1', 'bar==5; python_version < "3"'],
+                        'feature3': ['baz @ file:///path/to/project'],
                     },
                     'readme': {'content-type': 'text/markdown', 'text': 'test content\n'},
-                }
+                },
+                'tool': {'hatch': {'metadata': {'allow-direct-references': True}}},
             },
         )
 
@@ -1561,6 +1571,8 @@ class TestCoreMetadataV23:
             Provides-Extra: feature2
             Requires-Dist: bar==5; extra == 'feature2'
             Requires-Dist: foo==1; python_version < '3' and extra == 'feature2'
+            Provides-Extra: feature3
+            Requires-Dist: baz@ file:///path/to/project ; extra == 'feature3'
             Description-Content-Type: text/markdown
 
             test content
diff --git a/tests/cli/env/test_create.py b/tests/cli/env/test_create.py
index f854ec01..766ebee9 100644
--- a/tests/cli/env/test_create.py
+++ b/tests/cli/env/test_create.py
@@ -4,6 +4,8 @@ from hatch.config.constants import AppEnvVars, ConfigEnvVars
 from hatch.project.core import Project
 from hatch.utils.structures import EnvVars
 from hatch.venv.core import VirtualEnv
+from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT
+from hatchling.utils.fs import path_to_uri
 
 
 def test_undefined(hatch, helpers, temp_dir, config_file):
@@ -1198,6 +1200,167 @@ def test_features(hatch, helpers, temp_dir, platform, config_file, extract_insta
         assert requirements[1].lower() == f'-e {str(project_path).lower()}'
 
 
+@pytest.mark.requires_internet
+def test_sync_dynamic_dependencies(hatch, helpers, temp_dir, platform, config_file, extract_installed_requirements):
+    config_file.model.template.plugins['default']['tests'] = False
+    config_file.save()
+
+    project_name = 'My.App'
+
+    with temp_dir.as_cwd():
+        result = hatch('new', project_name)
+
+    assert result.exit_code == 0, result.output
+
+    for i in range(2):
+        with temp_dir.as_cwd():
+            result = hatch('new', f'{project_name}{i}')
+
+        assert result.exit_code == 0, result.output
+
+    project_path = temp_dir / 'my-app'
+    data_path = temp_dir / 'data'
+    data_path.mkdir()
+
+    project = Project(project_path)
+    config = dict(project.raw_config)
+    config['project'].pop('dependencies')
+    config['project']['dynamic'].extend(('dependencies', 'optional-dependencies'))
+    config['tool']['hatch']['metadata'] = {'allow-direct-references': True, 'hooks': {'custom': {}}}
+    project.save_config(config)
+    helpers.update_project_environment(
+        project,
+        'default',
+        {
+            'dependencies': ['my-app1 @ {root:uri}/../my-app1'],
+            'features': ['foo'],
+            'post-install-commands': ["python -c \"with open('test.txt', 'w') as f: f.write('content')\""],
+            **project.config.envs['default'],
+        },
+    )
+    helpers.update_project_environment(project, 'test', {})
+
+    build_script = project_path / DEFAULT_BUILD_SCRIPT
+    build_script.write_text(
+        helpers.dedent(
+            """
+            from hatchling.metadata.plugin.interface import MetadataHookInterface
+
+            class CustomHook(MetadataHookInterface):
+                def update(self, metadata):
+                    metadata['dependencies'] = ['my-app0 @ {root:uri}/../my-app0']
+                    metadata['optional-dependencies'] = {'foo': ['binary']}
+            """
+        )
+    )
+
+    with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
+        result = hatch('env', 'create', 'test')
+
+    assert result.exit_code == 0, result.output
+    assert result.output == helpers.dedent(
+        """
+        Creating environment: test
+        Installing project in development mode
+        Running post-installation commands
+        Checking dependencies
+        Syncing dependencies
+        """
+    )
+    assert (project_path / 'test.txt').is_file()
+
+    env_data_path = data_path / 'env' / 'virtual'
+    assert env_data_path.is_dir()
+
+    project_data_path = env_data_path / project_path.name
+    assert project_data_path.is_dir()
+
+    storage_dirs = list(project_data_path.iterdir())
+    assert len(storage_dirs) == 1
+
+    storage_path = storage_dirs[0]
+    assert len(storage_path.name) == 8
+
+    env_dirs = list(storage_path.iterdir())
+    assert len(env_dirs) == 1
+
+    env_path = env_dirs[0]
+
+    assert env_path.name == 'test'
+
+    with VirtualEnv(env_path, platform):
+        output = platform.run_command(['pip', 'freeze'], check=True, capture_output=True).stdout.decode('utf-8')
+        requirements = extract_installed_requirements(output.splitlines())
+
+        assert len(requirements) == 4
+        assert requirements[0].startswith('binary==')
+        assert requirements[1].lower() == f'-e {str(project_path).lower()}'
+        assert requirements[2].lower() == f'my-app0 @ {path_to_uri(project_path).lower()}/../my-app0'
+        assert requirements[3].lower() == f'my-app1 @ {path_to_uri(project_path).lower()}/../my-app1'
+
+
+@pytest.mark.requires_internet
+def test_unknown_dynamic_feature(hatch, helpers, temp_dir, platform, config_file, extract_installed_requirements):
+    config_file.model.template.plugins['default']['tests'] = False
+    config_file.save()
+
+    project_name = 'My.App'
+
+    with temp_dir.as_cwd():
+        result = hatch('new', project_name)
+
+    assert result.exit_code == 0, result.output
+
+    with temp_dir.as_cwd():
+        result = hatch('new', f'{project_name}1')
+
+    assert result.exit_code == 0, result.output
+
+    project_path = temp_dir / 'my-app'
+    data_path = temp_dir / 'data'
+    data_path.mkdir()
+
+    project = Project(project_path)
+    config = dict(project.raw_config)
+    config['build-system']['requires'].append(f'my-app1 @ {path_to_uri(project_path).lower()}/../my-app1')
+    config['project']['dynamic'].append('optional-dependencies')
+    config['tool']['hatch']['metadata'] = {'hooks': {'custom': {}}}
+    project.save_config(config)
+    helpers.update_project_environment(
+        project,
+        'default',
+        {
+            'features': ['foo'],
+            'post-install-commands': ["python -c \"with open('test.txt', 'w') as f: f.write('content')\""],
+            **project.config.envs['default'],
+        },
+    )
+    helpers.update_project_environment(project, 'test', {})
+
+    build_script = project_path / DEFAULT_BUILD_SCRIPT
+    build_script.write_text(
+        helpers.dedent(
+            """
+            from hatchling.metadata.plugin.interface import MetadataHookInterface
+
+            class CustomHook(MetadataHookInterface):
+                def update(self, metadata):
+                    metadata['optional-dependencies'] = {'bar': ['binary']}
+            """
+        )
+    )
+
+    with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
+        with pytest.raises(
+            ValueError,
+            match=(
+                'Feature `foo` of field `tool.hatch.envs.test.features` is not defined in the dynamic '
+                'field `project.optional-dependencies`'
+            ),
+        ):
+            hatch('env', 'create', 'test')
+
+
 def test_no_project_file(hatch, helpers, temp_dir, config_file):
     config_file.model.template.plugins['default']['tests'] = False
     config_file.save()
diff --git a/tests/cli/env/test_show.py b/tests/cli/env/test_show.py
index 82172615..83921f9f 100644
--- a/tests/cli/env/test_show.py
+++ b/tests/cli/env/test_show.py
@@ -381,3 +381,35 @@ occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim
         +---------+---------+------------+----------+-----------------------------+-----------------------+---------+------------------------------------------------------------------------------------------+
         """  # noqa: E501
     )
+
+
+def test_context_formatting_deps(hatch, helpers, temp_dir, config_file):
+    config_file.model.template.plugins['default']['tests'] = False
+    config_file.save()
+
+    project_name = 'My.App'
+
+    with temp_dir.as_cwd():
+        result = hatch('new', project_name)
+
+    assert result.exit_code == 0, result.output
+
+    project_path = temp_dir / 'my-app'
+    data_path = temp_dir / 'data'
+    data_path.mkdir()
+
+    project = Project(project_path, config={'dependencies': ['project @ {root:uri}']})
+    helpers.update_project_environment(
+        project,
+        'foo',
+        {
+            'extra-dependencies': ['bar @ {root:uri}/bar', 'baz'],
+        },
+    )
+
+    with project_path.as_cwd():
+        result = hatch('env', 'show', '--ascii')
+
+    uri = (project_path / 'bar').as_uri()
+    assert result.exit_code == 0, result.output
+    assert uri in result.output
diff --git a/tests/env/plugin/test_interface.py b/tests/env/plugin/test_interface.py
index 6209d8b0..e2184689 100644
--- a/tests/env/plugin/test_interface.py
+++ b/tests/env/plugin/test_interface.py
@@ -629,6 +629,25 @@ class TestDependencies:
 
         assert environment.dependencies == ['dep2', 'dep3']
 
+    def test_unknown_dynamic_feature(self, isolation, data_dir, platform):
+        config = {
+            'project': {'name': 'my_app', 'version': '0.0.1', 'dynamic': ['optional-dependencies']},
+            'tool': {'hatch': {'envs': {'default': {'skip-install': False, 'features': ['foo']}}}},
+        }
+        project = Project(isolation, config=config)
+        environment = MockEnvironment(
+            isolation, project.metadata, 'default', project.config.envs['default'], {}, data_dir, platform, 0
+        )
+
+        with pytest.raises(
+            ValueError,
+            match=(
+                'Feature `foo` of field `tool.hatch.envs.default.features` is not defined in the dynamic '
+                'field `project.optional-dependencies`'
+            ),
+        ):
+            _ = environment.dependencies
+
 
 class TestScripts:
     def test_not_table(self, isolation, data_dir, platform):