Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow docs to be in the root directory #3519

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 2 additions & 5 deletions mkdocs/commands/serve.py
Expand Up @@ -40,11 +40,8 @@ def serve(
site_dir = tempfile.mkdtemp(prefix='mkdocs_')

def get_config():
config = load_config(
config_file=config_file,
site_dir=site_dir,
**kwargs,
)
config = load_config(config_file=config_file, **kwargs)
config.site_dir = site_dir # Overwrite it afterwards, so normal validation still kicks in.
config.watch.extend(watch)
return config

Expand Down
5 changes: 4 additions & 1 deletion mkdocs/config/base.py
Expand Up @@ -168,7 +168,10 @@ def __init__(self, config_file_path: str | bytes | None = None):
config_file_path = config_file_path.decode(encoding=sys.getfilesystemencoding())
except UnicodeDecodeError:
raise ValidationError("config_file_path is not a Unicode string.")
self.config_file_path = config_file_path or ''
if config_file_path:
self.config_file_path = os.path.abspath(config_file_path)
else:
self.config_file_path = ''

def set_defaults(self) -> None:
"""
Expand Down
49 changes: 32 additions & 17 deletions mkdocs/config/config_options.py
Expand Up @@ -4,6 +4,7 @@
import ipaddress
import logging
import os
import pathlib
import string
import sys
import traceback
Expand Down Expand Up @@ -737,13 +738,18 @@ def post_validation(self, config: Config, key_name: str):
if not config.config_file_path:
return

# Validate that the dir is not the parent dir of the config file.
if os.path.dirname(config.config_file_path) == config[key_name]:
raise ValidationError(
f"The '{key_name}' should not be the parent directory of the"
f" config file. Use a child directory instead so that the"
f" '{key_name}' is a sibling of the config file."
)
# Validate that the docs dir does not contain the config file.
config_rel_path = _relative_path(config.config_file_path, to=config[key_name])
if config_rel_path is None:
return # Good, it's not inside the docs_dir.
exclude_docs: pathspec.gitignore.GitIgnoreSpec | None = config.get('exclude_docs')
if exclude_docs and exclude_docs.match_file(config_rel_path):
return # Good, the appropriate config is present.
raise ValidationError(
f"The '{config_rel_path.name}' config file should not be inside the {key_name}.\n"
f"To allow this arrangement, please exclude the file (and other files as applicable) by adding the following configuration:\n\n"
f"exclude_docs: |\n /{config_rel_path.as_posix()}"
)


class File(FilesystemObject):
Expand Down Expand Up @@ -796,19 +802,21 @@ def post_validation(self, config: Config, key_name: str):
# Validate that the docs_dir and site_dir don't contain the
# other as this will lead to copying back and forth on each
# and eventually make a deep nested mess.
if (docs_dir + os.sep).startswith(site_dir.rstrip(os.sep) + os.sep):
if _relative_path(docs_dir, to=site_dir):
raise ValidationError(
f"The 'docs_dir' should not be within the 'site_dir' as this "
f"can mean the source files are overwritten by the output or "
f"it will be deleted if --clean is passed to mkdocs build. "
f"(site_dir: '{site_dir}', docs_dir: '{docs_dir}')"
"The docs_dir should not be inside the site_dir as this "
"can mean the source files are overwritten by the output or "
"deleted entirely."
)
elif (site_dir + os.sep).startswith(docs_dir.rstrip(os.sep) + os.sep):
if site_rel_path := _relative_path(site_dir, to=docs_dir):
exclude_docs: pathspec.gitignore.GitIgnoreSpec | None = config.get('exclude_docs')
if exclude_docs and exclude_docs.match_file(site_rel_path):
return # Good, the appropriate config is present.
raise ValidationError(
f"The 'site_dir' should not be within the 'docs_dir' as this "
f"leads to the build directory being copied into itself and "
f"duplicate nested files in the 'site_dir'. "
f"(site_dir: '{site_dir}', docs_dir: '{docs_dir}')"
f"The site_dir should not be inside the docs_dir as this "
f"leads to the build directory being copied into itself.\n"
f"To allow this arrangement, please exclude the directory (and other files as applicable) by adding the following configuration:\n\n"
f"exclude_docs: |\n /{site_rel_path.as_posix()}"
)


Expand Down Expand Up @@ -1227,3 +1235,10 @@ def run_validation(self, value: object) -> pathspec.gitignore.GitIgnoreSpec:
return pathspec.gitignore.GitIgnoreSpec.from_lines(lines=value.splitlines())
except ValueError as e:
raise ValidationError(str(e))


def _relative_path(main: str | os.PathLike, to: str | os.PathLike) -> pathlib.Path | None:
try:
return pathlib.Path(main).relative_to(to)
except ValueError:
return None
28 changes: 17 additions & 11 deletions mkdocs/tests/config/config_options_legacy_tests.py
Expand Up @@ -768,17 +768,20 @@ class Schema:
)
self.assertEqual(conf['dir'], os.path.join(base_path, 'foo'))

def test_site_dir_is_config_dir_fails(self):
def test_docs_dir_is_config_dir_fails(self):
class Schema:
dir = c.DocsDir()
docs_dir = c.DocsDir()

with self.expect_error(
dir="The 'dir' should not be the parent directory of the config file. "
"Use a child directory instead so that the 'dir' is a sibling of the config file."
docs_dir="""The 'mkdocs.yml' config file should not be inside the docs_dir.
To allow this arrangement, please exclude the file (and other files as applicable) by adding the following configuration:

exclude_docs: |
/mkdocs.yml"""
):
self.get_config(
Schema,
{'dir': '.'},
{'docs_dir': '.'},
config_file_path=os.path.join(os.path.abspath('.'), 'mkdocs.yml'),
)

Expand Down Expand Up @@ -860,7 +863,7 @@ class Schema:
site_dir = c.SiteDir()
docs_dir = c.Dir()

def test_doc_dir_in_site_dir(self):
def test_doc_dir_in_site_dir_fails(self):
j = os.path.join
# The parent dir is not the same on every system, so use the actual dir name
parent_dir = mkdocs.__file__.split(os.sep)[-3]
Expand All @@ -878,24 +881,27 @@ def test_doc_dir_in_site_dir(self):
for test_config in test_configs:
with self.subTest(test_config):
with self.expect_error(
site_dir=re.compile(r"The 'docs_dir' should not be within the 'site_dir'.*")
site_dir="The docs_dir should not be inside the site_dir as this can mean the source files are overwritten by the output or deleted entirely."
):
self.get_config(self.Schema, test_config)

def test_site_dir_in_docs_dir(self):
def test_site_dir_in_docs_dir_fails(self):
j = os.path.join

test_configs = (
{'docs_dir': 'docs', 'site_dir': j('docs', 'site')},
{'docs_dir': '.', 'site_dir': 'site'},
{'docs_dir': '', 'site_dir': 'site'},
{'docs_dir': '/', 'site_dir': 'site'},
)

for test_config in test_configs:
with self.subTest(test_config):
with self.expect_error(
site_dir=re.compile(r"The 'site_dir' should not be within the 'docs_dir'.*")
site_dir="""The site_dir should not be inside the docs_dir as this leads to the build directory being copied into itself.
To allow this arrangement, please exclude the directory (and other files as applicable) by adding the following configuration:

exclude_docs: |
/site"""
):
self.get_config(self.Schema, test_config)

Expand Down Expand Up @@ -1275,7 +1281,7 @@ class Schema:

config_path = "foo/mkdocs.yaml"
self.get_config(Schema, {"sub": {"opt": "bar"}}, config_file_path=config_path)
self.assertEqual(passed_config_path, config_path)
self.assertEqual(passed_config_path, os.path.abspath(config_path))


class ConfigItemsTest(TestCase):
Expand Down
73 changes: 62 additions & 11 deletions mkdocs/tests/config/config_options_tests.py
Expand Up @@ -972,20 +972,54 @@ class Schema(Config):
)
self.assertEqual(conf.dir, os.path.join(base_path, 'foo'))

def test_site_dir_is_config_dir_fails(self) -> None:
def test_docs_dir_is_config_dir_fails(self) -> None:
class Schema(Config):
dir = c.DocsDir()
docs_dir = c.DocsDir()

with self.expect_error(
dir="The 'dir' should not be the parent directory of the config file. "
"Use a child directory instead so that the 'dir' is a sibling of the config file."
docs_dir="""The 'mkdocs.yml' config file should not be inside the docs_dir.
To allow this arrangement, please exclude the file (and other files as applicable) by adding the following configuration:

exclude_docs: |
/mkdocs.yml"""
):
self.get_config(
Schema,
{'dir': '.'},
{'docs_dir': '.'},
config_file_path=os.path.join(os.path.abspath('.'), 'mkdocs.yml'),
)

def test_docs_dir_is_config_dir_nested_fails(self) -> None:
class Schema(Config):
docs_dir = c.DocsDir()

with self.expect_error(
docs_dir="""The 'mkdocos.yaml' config file should not be inside the docs_dir.
To allow this arrangement, please exclude the file (and other files as applicable) by adding the following configuration:

exclude_docs: |
/config/mkdocos.yaml"""
):
self.get_config(
Schema,
{'docs_dir': '..'},
config_file_path=os.path.join(
os.path.abspath('.'), 'docs', 'config', 'mkdocos.yaml'
),
)

def test_docs_dir_is_config_dir_succeeds(self) -> None:
class Schema(Config):
docs_dir = c.DocsDir()
exclude_docs = c.Optional(c.PathSpec())

conf = self.get_config(
Schema,
{'docs_dir': '.', 'exclude_docs': '/mkdocs.y*l'},
config_file_path=os.path.join(os.path.abspath('.'), 'mkdocs.yml'),
)
self.assertEqual(conf.docs_dir, os.path.abspath('.'))


class ListOfPathsTest(TestCase):
def test_valid_path(self) -> None:
Expand Down Expand Up @@ -1072,8 +1106,9 @@ class SiteDirTest(TestCase):
class Schema(Config):
site_dir = c.SiteDir()
docs_dir = c.Dir()
exclude_docs = c.Optional(c.PathSpec())

def test_doc_dir_in_site_dir(self) -> None:
def test_doc_dir_in_site_dir_fails(self) -> None:
j = os.path.join
# The parent dir is not the same on every system, so use the actual dir name
parent_dir = mkdocs.__file__.split(os.sep)[-3]
Expand All @@ -1091,27 +1126,43 @@ def test_doc_dir_in_site_dir(self) -> None:
for test_config in test_configs:
with self.subTest(test_config):
with self.expect_error(
site_dir=re.compile(r"The 'docs_dir' should not be within the 'site_dir'.*")
site_dir="The docs_dir should not be inside the site_dir as this can mean the source files are overwritten by the output or deleted entirely."
):
self.get_config(self.Schema, test_config)

def test_site_dir_in_docs_dir(self) -> None:
def test_site_dir_in_docs_dir_fails(self) -> None:
j = os.path.join

test_configs = (
{'docs_dir': 'docs', 'site_dir': j('docs', 'site')},
{'docs_dir': '.', 'site_dir': 'site'},
{'docs_dir': '', 'site_dir': 'site'},
{'docs_dir': '/', 'site_dir': 'site'},
)

for test_config in test_configs:
with self.subTest(test_config):
with self.expect_error(
site_dir=re.compile(r"The 'site_dir' should not be within the 'docs_dir'.*")
site_dir="""The site_dir should not be inside the docs_dir as this leads to the build directory being copied into itself.
To allow this arrangement, please exclude the directory (and other files as applicable) by adding the following configuration:

exclude_docs: |
/site"""
):
self.get_config(self.Schema, test_config)

def test_site_dir_in_docs_dir_succeeds(self) -> None:
j = os.path.join

test_configs = (
{'docs_dir': 'docs', 'site_dir': j('docs', 'site'), 'exclude_docs': 'site'},
{'docs_dir': '.', 'site_dir': 'site', 'exclude_docs': '/site'},
{'docs_dir': '', 'site_dir': 'site', 'exclude_docs': '/si*e'},
)

for test_config in test_configs:
with self.subTest(test_config):
self.get_config(self.Schema, test_config)

def test_common_prefix(self) -> None:
"""Legitimate settings with common prefixes should not fail validation."""
test_configs = (
Expand Down Expand Up @@ -1571,7 +1622,7 @@ class Schema(Config):

config_path = "foo/mkdocs.yaml"
self.get_config(Schema, {"sub": [{"opt": "bar"}]}, config_file_path=config_path)
self.assertEqual(passed_config_path, config_path)
self.assertEqual(passed_config_path, os.path.abspath(config_path))


class NestedSubConfigTest(TestCase):
Expand Down