Commit a3e60f13 authored by Joseph Lombrozo's avatar Joseph Lombrozo Committed by Waylan Limberg
Browse files

Paths in config file are relative to the config file. (#1376)

parent d6449f90
......@@ -25,6 +25,25 @@ The current and past members of the MkDocs team.
### Major Additions to Development Version
#### Path Based Settings are Relative to Configuration File (#543)
Previously any relative paths in the various configuration options were
resolved relative to the current working directory. They are now resolved
relative to the configuration file. As the documentation has always encouraged
running the various MkDocs commands from the directory that contains the
configuration file (project root), this change will not affect most users.
However, it will make it much easier to implement automated builds or otherwise
run commands from a location other than the project root.
Simply use the `-f/--config-file` option and point it at the configuration file:
```sh
mkdocs build --config-file /path/to/my/config/file.yml
```
As previously, if no file is specified, MkDocs looks for a file named
`mkdocs.yml` in the current working directory.
#### Refactor Search Plugin
The search plugin has been completely refactored to include support for the
......
......@@ -215,9 +215,10 @@ If a set of key/value pairs, the following nested keys can be defined:
#### custom_dir:
A directory to custom a theme. This can either be a relative directory, in
which case it is resolved relative to the directory containing your
configuration file, or it can be an absolute directory path.
A directory containing a custom theme. This can either be a relative
directory, in which case it is resolved relative to the directory containing
your configuration file, or it can be an absolute directory path from the
root of your local file system.
See [styling your docs][theme_dir] for details if you would like to tweak an
existing theme.
......@@ -240,19 +241,19 @@ If a set of key/value pairs, the following nested keys can be defined:
### docs_dir
Lets you set the directory containing the documentation source markdown files.
This can either be a relative directory, in which case it is resolved relative
to the directory containing your configuration file, or it can be an absolute
directory path from the root of your local file system.
The directory containing the documentation source markdown files. This can
either be a relative directory, in which case it is resolved relative to the
directory containing your configuration file, or it can be an absolute directory
path from the root of your local file system.
**default**: `'docs'`
### site_dir
Lets you set the directory where the output HTML and other files are created.
This can either be a relative directory, in which case it is resolved relative
to the directory containing your configuration file, or it can be an absolute
directory path from the root of your local file system.
The directory where the output HTML and other files are created. This can either
be a relative directory, in which case it is resolved relative to the directory
containing your configuration file, or it can be an absolute directory path from
the root of your local file system.
**default**: `'site'`
......
......@@ -20,41 +20,48 @@ and their usage.
## Creating a custom theme
The bare minimum required for a custom theme is a `main.html` [Jinja2 template]
file. This should be placed in a directory which will be the `custom_dir` and it
should be created next to the `mkdocs.yml` configuration file. Within
`mkdocs.yml`, specify the theme `custom_dir` option and set it to the name of
the directory containing `main.html`. For example, given this example project
layout:
mkdocs.yml
docs/
index.md
about.md
custom_theme/
main.html
...
You would include the following settings in `mkdocs.yml` to use the custom theme
file which is placed in a directory that is *not* a child of the [docs_dir].
Within `mkdocs.yml`, set the theme.[custom_dir] option to the path of the
directory containing `main.html`. The path should be relative to the
configuration file. For example, given this example project layout:
```no-highlight
mkdocs.yml
docs/
index.md
about.md
custom_theme/
main.html
...
```
... you would include the following settings in `mkdocs.yml` to use the custom theme
directory:
theme:
name: null
custom_dir: 'custom_theme'
```yaml
theme:
name: null
custom_dir: 'custom_theme/'
```
!!! Note
Generally, when building your own custom theme, the theme `name`
configuration setting would be set to `null`. However, if used in
combination with the `custom_dir` configuration value a custom theme can be
used to replace only specific parts of a built-in theme. For example, with
the above layout and if you set `name: "mkdocs"` then the `main.html` file
in the `custom_dir` would replace that in the theme but otherwise the
`mkdocs` theme would remain the same. This is useful if you want to make
Generally, when building your own custom theme, the theme.[name]
configuration setting would be set to `null`. However, if the
theme.[custom_dir] configuration value is used in combination with an
existing theme, the theme.[custom_dir] can be used to replace only specific
parts of a built-in theme. For example, with the above layout and if you set
`name: "mkdocs"` then the `main.html` file in the theme.[custom_dir] would
replace the file of the same name in the `mkdocs` theme but otherwise the
`mkdocs` theme would remain unchanged. This is useful if you want to make
small adjustments to an existing theme.
For more specific information, see [styling your docs].
[styling your docs]: ./styling-your-docs.md#using-the-theme-custom_dir
[custom_dir]: ./configuration.md#custom_dir
[name]: ./configuration.md#name
[docs_dir]:./configuration.md#docs_dir
## Basic theme
......
......@@ -138,7 +138,7 @@ And then point your `mkdocs.yml` configuration file at the new directory:
```yaml
theme:
name: mkdocs
custom_dir: custom_theme
custom_dir: custom_theme/
```
To override the 404 error page ("file not found"), add a new template file named
......
......@@ -21,13 +21,14 @@ class Config(utils.UserDict):
for running validation on the structure and contents.
"""
def __init__(self, schema):
def __init__(self, schema, config_file_path=None):
"""
The schema is a Python dict which maps the config name to a validator.
"""
self._schema = schema
self._schema_keys = set(dict(schema).keys())
self.config_file_path = config_file_path
self.data = {}
self.user_configs = []
......@@ -172,7 +173,7 @@ def load_config(config_file=None, **kwargs):
# Initialise the config with the default schema .
from mkdocs import config
cfg = Config(schema=config.DEFAULT_SCHEMA)
cfg = Config(schema=config.DEFAULT_SCHEMA, config_file_path=options['config_file_path'])
# First load the config file
cfg.load_file(config_file)
# Then load the options to overwrite anything in the config.
......
......@@ -293,6 +293,24 @@ class FilesystemObject(Type):
super(FilesystemObject, self).__init__(type_=utils.string_types, **kwargs)
self.exists = exists
def pre_validation(self, config, key_name):
value = config[key_name]
if not value:
return
if os.path.isabs(value):
return
if config.config_file_path is None:
# Unable to determine absolute path of the config file; fall back
# to trusting the relative path
return
config_dir = os.path.dirname(config.config_file_path)
value = os.path.join(config_dir, value)
config[key_name] = value
def run_validation(self, value):
value = super(FilesystemObject, self).run_validation(value)
if self.exists and not self.existence_test(value):
......@@ -311,9 +329,11 @@ class Dir(FilesystemObject):
name = 'directory'
def post_validation(self, config, key_name):
if config.config_file_path is None:
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]:
if os.path.dirname(config.config_file_path) == config[key_name]:
raise ValidationError(
("The '{0}' should not be the parent directory of the config "
"file. Use a child directory instead so that the config file "
......@@ -430,7 +450,12 @@ class Theme(BaseConfigOption):
# Ensure custom_dir is an absolute path
if 'custom_dir' in theme_config and not os.path.isabs(theme_config['custom_dir']):
theme_config['custom_dir'] = os.path.abspath(theme_config['custom_dir'])
config_dir = os.path.dirname(config.config_file_path)
theme_config['custom_dir'] = os.path.join(config_dir, theme_config['custom_dir'])
if 'custom_dir' in theme_config and not os.path.isdir(theme_config['custom_dir']):
raise ValidationError("The path set in {name}.custom_dir ('{path}') does not exist.".
format(path=theme_config['custom_dir'], name=self.name))
config[key_name] = theme.Theme(**theme_config)
......@@ -621,6 +646,10 @@ class Plugins(OptionallyRequired):
def __init__(self, **kwargs):
super(Plugins, self).__init__(**kwargs)
self.installed_plugins = plugins.get_plugins()
self.config_file_path = None
def pre_validation(self, config, key_name):
self.config_file_path = config.config_file_path
def run_validation(self, value):
if not isinstance(value, (list, tuple)):
......@@ -635,11 +664,15 @@ class Plugins(OptionallyRequired):
if not isinstance(cfg, dict):
raise ValidationError('Invalid config options for '
'the "{0}" plugin.'.format(name))
plgins[name] = self.load_plugin(name, cfg)
elif isinstance(item, utils.string_types):
plgins[item] = self.load_plugin(item, {})
item = name
else:
cfg = {}
if not isinstance(item, utils.string_types):
raise ValidationError('Invalid Plugins configuration')
plgins[item] = self.load_plugin(item, cfg)
return plgins
def load_plugin(self, name, config):
......@@ -654,7 +687,7 @@ class Plugins(OptionallyRequired):
plugins.BasePlugin.__name__))
plugin = Plugin()
errors, warnings = plugin.load_config(config)
errors, warnings = plugin.load_config(config, self.config_file_path)
self.warnings.extend(warnings)
errors_message = '\n'.join(
"Plugin value: '{}'. Error: {}".format(x, y)
......
......@@ -42,10 +42,10 @@ class BasePlugin(object):
config_scheme = ()
config = {}
def load_config(self, options):
def load_config(self, options, config_file_path=None):
""" Load config from a dict of options. Returns a tuple of (errors, warnings)."""
self.config = Config(schema=self.config_scheme)
self.config = Config(schema=self.config_scheme, config_file_path=config_file_path)
self.config.load_dict(options)
return self.config.validate()
......
......@@ -3,8 +3,6 @@
from __future__ import unicode_literals
import os
import shutil
import tempfile
import unittest
import mock
import io
......@@ -15,6 +13,11 @@ except ImportError:
# In Py3 use builtin zip function
pass
try:
# py>=3.2
from tempfile import TemporaryDirectory
except ImportError:
from backports.tempfile import TemporaryDirectory
from mkdocs import nav
from mkdocs.commands import build
......@@ -319,9 +322,7 @@ class BuildTests(unittest.TestCase):
self.assertEqual(page.content.strip(), '<p>foo</p>')
def test_copying_media(self):
docs_dir = tempfile.mkdtemp()
site_dir = tempfile.mkdtemp()
try:
with TemporaryDirectory() as docs_dir, TemporaryDirectory() as site_dir:
# Create a non-empty markdown file, image, html file, dot file and dot directory.
f = open(os.path.join(docs_dir, 'index.md'), 'w')
f.write(dedent("""
......@@ -351,14 +352,9 @@ class BuildTests(unittest.TestCase):
self.assertTrue(os.path.isfile(os.path.join(site_dir, 'example.html')))
self.assertFalse(os.path.isfile(os.path.join(site_dir, '.hidden')))
self.assertFalse(os.path.isfile(os.path.join(site_dir, '.git/hidden')))
finally:
shutil.rmtree(docs_dir)
shutil.rmtree(site_dir)
def test_copy_theme_files(self):
docs_dir = tempfile.mkdtemp()
site_dir = tempfile.mkdtemp()
try:
with TemporaryDirectory() as docs_dir, TemporaryDirectory() as site_dir:
# Create a non-empty markdown file.
f = open(os.path.join(docs_dir, 'index.md'), 'w')
f.write(dedent("""
......@@ -383,9 +379,6 @@ class BuildTests(unittest.TestCase):
self.assertFalse(os.path.isfile(os.path.join(site_dir, 'base.html')))
self.assertFalse(os.path.isfile(os.path.join(site_dir, 'content.html')))
self.assertFalse(os.path.isfile(os.path.join(site_dir, 'nav.html')))
finally:
shutil.rmtree(docs_dir)
shutil.rmtree(site_dir)
def test_strict_mode_valid(self):
pages = [
......@@ -467,9 +460,7 @@ class BuildTests(unittest.TestCase):
self.assertEqual(context['config']['extra']['a'], 1)
def test_BOM(self):
docs_dir = tempfile.mkdtemp()
site_dir = tempfile.mkdtemp()
try:
with TemporaryDirectory() as docs_dir, TemporaryDirectory() as site_dir:
# Create an UTF-8 Encoded file with BOM (as Micorsoft editors do). See #1186.
f = io.open(os.path.join(docs_dir, 'index.md'), 'w', encoding='utf-8-sig')
f.write('# An UTF-8 encoded file with a BOM')
......@@ -490,7 +481,3 @@ class BuildTests(unittest.TestCase):
self.assertTrue(
'<h1 id="an-utf-8-encoded-file-with-a-bom">An UTF-8 encoded file with a BOM</h1>' in output
)
finally:
shutil.rmtree(docs_dir)
shutil.rmtree(site_dir)
......@@ -3,6 +3,12 @@ import os
import tempfile
import unittest
try:
# py>=3.2
from tempfile import TemporaryDirectory
except ImportError:
from backports.tempfile import TemporaryDirectory
from mkdocs import exceptions
from mkdocs.config import base, defaults
from mkdocs.config.config_options import BaseConfigOption
......@@ -42,7 +48,9 @@ class ConfigBaseTests(unittest.TestCase):
Allows users to specify a config other than the default `mkdocs.yml`.
"""
config_file = tempfile.NamedTemporaryFile('w', delete=False)
temp_dir = TemporaryDirectory()
config_file = open(os.path.join(temp_dir.name, 'mkdocs.yml'), 'w')
os.mkdir(os.path.join(temp_dir.name, 'docs'))
try:
config_file.write("site_name: MkDocs Test\n")
config_file.flush()
......@@ -64,7 +72,12 @@ class ConfigBaseTests(unittest.TestCase):
`load_config` can accept an open file descriptor.
"""
config_file = tempfile.NamedTemporaryFile('r+', delete=False)
temp_dir = TemporaryDirectory()
temp_path = temp_dir.name
config_fname = os.path.join(temp_path, 'mkdocs.yml')
config_file = open(config_fname, 'w+')
os.mkdir(os.path.join(temp_path, 'docs'))
try:
config_file.write("site_name: MkDocs Test\n")
config_file.flush()
......@@ -75,7 +88,7 @@ class ConfigBaseTests(unittest.TestCase):
# load_config will always close the file
self.assertTrue(config_file.closed)
finally:
os.remove(config_file.name)
temp_dir.cleanup()
def test_load_from_closed_file(self):
"""
......@@ -83,7 +96,10 @@ class ConfigBaseTests(unittest.TestCase):
Ensure `load_config` reloads the closed file.
"""
config_file = tempfile.NamedTemporaryFile('w', delete=False)
temp_dir = TemporaryDirectory()
config_file = open(os.path.join(temp_dir.name, 'mkdocs.yml'), 'w')
os.mkdir(os.path.join(temp_dir.name, 'docs'))
try:
config_file.write("site_name: MkDocs Test\n")
config_file.flush()
......@@ -93,7 +109,7 @@ class ConfigBaseTests(unittest.TestCase):
self.assertTrue(isinstance(cfg, base.Config))
self.assertEqual(cfg['site_name'], 'MkDocs Test')
finally:
os.remove(config_file.name)
temp_dir.cleanup()
def test_load_from_deleted_file(self):
"""
......@@ -234,3 +250,28 @@ class ConfigBaseTests(unittest.TestCase):
('invalid_option', 'run_validation warning'),
('invalid_option', 'post_validation warning'),
])
def test_load_from_file_with_relative_paths(self):
"""
When explicitly setting a config file, paths should be relative to the
config file, not the working directory.
"""
config_dir = TemporaryDirectory()
config_fname = os.path.join(config_dir.name, 'mkdocs.yml')
docs_dir = os.path.join(config_dir.name, 'src')
os.mkdir(docs_dir)
config_file = open(config_fname, 'w')
try:
config_file.write("docs_dir: src\nsite_name: MkDocs Test\n")
config_file.flush()
config_file.close()
cfg = base.load_config(config_file=config_file)
self.assertTrue(isinstance(cfg, base.Config))
self.assertEqual(cfg['site_name'], 'MkDocs Test')
self.assertEqual(cfg['docs_dir'], docs_dir)
finally:
config_dir.cleanup()
......@@ -6,6 +6,7 @@ import unittest
import mkdocs
from mkdocs import utils
from mkdocs.config import config_options
from mkdocs.config.base import Config
class OptionallyRequiredTest(unittest.TestCase):
......@@ -272,18 +273,21 @@ class DirTest(unittest.TestCase):
option.validate, [])
def test_doc_dir_is_config_dir(self):
cfg = Config(
[('docs_dir', config_options.Dir())],
config_file_path=os.path.join(os.path.abspath('.'), 'mkdocs.yml'),
)
test_config = {
'config_file_path': os.path.join(os.path.abspath('.'), 'mkdocs.yml'),
'docs_dir': '.'
}
docs_dir = config_options.Dir()
cfg.load_dict(test_config)
test_config['docs_dir'] = docs_dir.validate(test_config['docs_dir'])
fails, warns = cfg.validate()
self.assertRaises(config_options.ValidationError,
docs_dir.post_validation, test_config, 'docs_dir')
self.assertEqual(len(fails), 1)
self.assertEqual(len(warns), 0)
class SiteDirTest(unittest.TestCase):
......@@ -293,12 +297,23 @@ class SiteDirTest(unittest.TestCase):
site_dir = config_options.SiteDir()
docs_dir = config_options.Dir()
config['config_file_path'] = os.path.join(os.path.abspath('..'), 'mkdocs.yml')
fname = os.path.join(os.path.abspath('..'), 'mkdocs.yml')
config['docs_dir'] = docs_dir.validate(config['docs_dir'])
config['site_dir'] = site_dir.validate(config['site_dir'])
site_dir.post_validation(config, 'site_dir')
return True # No errors were raised
schema = [
('site_dir', site_dir),
('docs_dir', docs_dir),
]
cfg = Config(schema, fname)
cfg.load_dict(config)
failed, warned = cfg.validate()
if failed:
raise config_options.ValidationError(failed)
return True
def test_doc_dir_in_site_dir(self):
......
......@@ -3,10 +3,16 @@
from __future__ import unicode_literals
import os
import shutil
import tempfile
import unittest
try:
# py>=3.2
from tempfile import TemporaryDirectory
except ImportError:
from backports.tempfile import TemporaryDirectory
import mkdocs
from mkdocs import config
from mkdocs import utils
......@@ -81,8 +87,11 @@ class ConfigTests(unittest.TestCase):
pages:
- 'Introduction': 'index.md'
""")
config_file = tempfile.NamedTemporaryFile('w', delete=False)
try:
with TemporaryDirectory() as temp_path:
os.mkdir(os.path.join(temp_path, 'docs'))
config_path = os.path.join(temp_path, 'mkdocs.yml')
config_file = open(config_path, 'w')
config_file.write(ensure_utf(file_contents))
config_file.flush()
config_file.close()
......@@ -90,93 +99,87 @@ class ConfigTests(unittest.TestCase):
result = config.load_config(config_file=config_file.name)
self.assertEqual(result['site_name'], expected_result['site_name'])
self.assertEqual(result['pages'], expected_result['pages'])
finally:
os.remove(config_file.name)
def test_theme(self):
mytheme = tempfile.mkdtemp()
custom = tempfile.mkdtemp()
configs = [
dict(), # default theme
{"theme": "readthedocs"}, # builtin theme
{"theme_dir": mytheme}, # custom only
{"theme": "readthedocs", "theme_dir": custom}, # builtin and custom
{"theme": {'name': 'readthedocs'}}, # builtin as complex
{"theme": {'name': None, 'custom_dir': mytheme}}, # custom only as complex
{"theme": {'name': 'readthedocs', 'custom_dir': custom}}, # builtin and custom as complex
{ # user defined variables
'theme': {
'name': 'mkdocs',
'static_templates': ['foo.html'],
'show_sidebar': False,
'some_var': 'bar'
with TemporaryDirectory() as mytheme, TemporaryDirectory() as custom:
configs = [
dict(), # default theme
{"theme": "readthedocs"}, # builtin theme
{"theme_dir": mytheme}, # custom only
{"theme": "readthedocs", "theme_dir": custom}, # builtin and custom
{"theme": {'name': 'readthedocs'}}, # builtin as complex
{"theme": {'name': None, 'custom_dir': mytheme}}, # custom only as complex
{"theme": {'name': 'readthedocs', 'custom_dir': custom}}, # builtin and custom as complex
{ # user defined variables
'theme': {
'name': 'mkdocs',
'static_templates': ['foo.html'],
'show_sidebar': False,
'some_var': 'bar'
}
}
}
]
mkdocs_dir = os.path.abspath(os.path.dirname(mkdocs.__file__))
mkdocs_templates_dir = os.path.join(mkdocs_dir, 'templates')
theme_dir = os.path.abspath(os.path.join(mkdocs_dir, 'themes'))
results = (
{
'dirs': [os.path.join(theme_dir, 'mkdocs'), mkdocs_templates_dir],
'static_templates': ['404.html', 'sitemap.xml'],
'vars': {'include_search_page': False, 'search_index_only': False}
}, {
'dirs': [os.path.join(theme_dir, 'readthedocs'), mkdocs_templates_dir],
'static_templates': ['404.html', 'sitemap.xml'],
'vars': {'include_search_page': True, 'search_index_only': False}
}, {
'dirs': [mytheme, mkdocs_templates_dir],
'static_templates': ['sitemap.xml'],
'vars': {}
}, {
'dirs': [