From 77a74b6974a07f10b492c62fc0e99ea599f62e08 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 15 Nov 2017 14:09:52 -0800 Subject: [PATCH] Allow x-project-name property in Compose file to define project name Signed-off-by: Joffrey F --- compose/cli/command.py | 30 +++++++++++++++++--- compose/config/config.py | 5 ++++ tests/unit/cli_test.py | 61 ++++++++++++++++++++++++++++++++++------ 3 files changed, 84 insertions(+), 12 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index e1ae690c0e..ab98b2ed21 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -85,9 +85,7 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, if not environment: environment = Environment.from_env_file(project_dir) config_details = config.find(project_dir, config_path, environment, override_dir) - project_name = get_project_name( - config_details.working_dir, project_name, environment - ) + project_name = get_project_name(config_details, project_name, environment) config_data = config.load(config_details) api_version = environment.get( @@ -103,13 +101,37 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, return Project.from_config(project_name, config_data, client) -def get_project_name(working_dir, project_name=None, environment=None): +def get_project_name(config_details, project_name=None, environment=None): def normalize_name(name): return re.sub(r'[^a-z0-9]', '', name.lower()) + def get_name_from_config(config_details): + x_name = None + for file in config_details.config_files: + x_properties = file.get_x_properties() + if 'x-project-name' in x_properties: + if x_name and x_properties['x-project-name'] != x_name[0]: + raise errors.UserError( + 'Conflicting x-project-name declarations: ' + '"{}" ({}) does not match "{}" ({})'.format( + x_name[0], x_name[1], x_properties['x-project-name'], + file.filename + ) + ) + x_name = (x_properties['x-project-name'], file.filename) + + return x_name[0] if x_name else None + + working_dir = config_details.working_dir + if not environment: environment = Environment.from_env_file(working_dir) + project_name = project_name or environment.get('COMPOSE_PROJECT_NAME') + + if environment.get_boolean('COMPOSE_X_PROJECT_NAME') and not project_name: + project_name = get_name_from_config(config_details) + if project_name: return normalize_name(project_name) diff --git a/compose/config/config.py b/compose/config/config.py index 4c3f93ddb2..7792e6564a 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -225,6 +225,11 @@ def get_secrets(self): def get_configs(self): return {} if self.version < const.COMPOSEFILE_V3_3 else self.config.get('configs', {}) + def get_x_properties(self): + if self.version == V1: + return {} + return dict((k, v) for k, v in self.config.items() if k.startswith('x-')) + class Config(namedtuple('_Config', 'version services volumes networks secrets configs')): """ diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 1a324f50a4..92e949e20b 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -20,45 +20,54 @@ from compose.cli.docopt_command import NoSuchCommand from compose.cli.errors import UserError from compose.cli.main import TopLevelCommand +from compose.config.config import ConfigDetails +from compose.config.config import ConfigFile from compose.const import IS_WINDOWS_PLATFORM from compose.project import Project class CLITestCase(unittest.TestCase): + @staticmethod + def get_config_details(working_dir, x_project_name=None): + config = {'version': '2.3'} + if x_project_name: + config['x-project-name'] = x_project_name + return ConfigDetails(working_dir, [ConfigFile('base.yml', config)]) + def test_default_project_name(self): test_dir = py._path.local.LocalPath('tests/fixtures/simple-composefile') with test_dir.as_cwd(): - project_name = get_project_name('.') + project_name = get_project_name(self.get_config_details('.')) self.assertEqual('simplecomposefile', project_name) def test_project_name_with_explicit_base_dir(self): base_dir = 'tests/fixtures/simple-composefile' - project_name = get_project_name(base_dir) + project_name = get_project_name(self.get_config_details(base_dir)) self.assertEqual('simplecomposefile', project_name) def test_project_name_with_explicit_uppercase_base_dir(self): base_dir = 'tests/fixtures/UpperCaseDir' - project_name = get_project_name(base_dir) + project_name = get_project_name(self.get_config_details(base_dir)) self.assertEqual('uppercasedir', project_name) def test_project_name_with_explicit_project_name(self): name = 'explicit-project-name' - project_name = get_project_name(None, project_name=name) + project_name = get_project_name(self.get_config_details('.'), project_name=name) self.assertEqual('explicitprojectname', project_name) @mock.patch.dict(os.environ) def test_project_name_from_environment_new_var(self): name = 'namefromenv' os.environ['COMPOSE_PROJECT_NAME'] = name - project_name = get_project_name(None) + project_name = get_project_name(self.get_config_details('.')) self.assertEqual(project_name, name) def test_project_name_with_empty_environment_var(self): base_dir = 'tests/fixtures/simple-composefile' with mock.patch.dict(os.environ): os.environ['COMPOSE_PROJECT_NAME'] = '' - project_name = get_project_name(base_dir) + project_name = get_project_name(self.get_config_details(base_dir)) self.assertEqual('simplecomposefile', project_name) @mock.patch.dict(os.environ) @@ -68,15 +77,51 @@ def test_project_name_with_environment_file(self): name = 'namefromenvfile' with open(os.path.join(base_dir, '.env'), 'w') as f: f.write('COMPOSE_PROJECT_NAME={}'.format(name)) - project_name = get_project_name(base_dir) + project_name = get_project_name(self.get_config_details(base_dir)) assert project_name == name # Environment has priority over .env file os.environ['COMPOSE_PROJECT_NAME'] = 'namefromenv' - assert get_project_name(base_dir) == os.environ['COMPOSE_PROJECT_NAME'] + assert get_project_name( + self.get_config_details(base_dir)) == os.environ['COMPOSE_PROJECT_NAME'] finally: shutil.rmtree(base_dir) + @mock.patch.dict(os.environ) + def test_project_name_from_config_file(self): + base_dir = 'tests/fixtures/simple-composefile' + config_details = self.get_config_details(base_dir, 'namefromcomposefile') + # Ignored if env switch is unset + assert get_project_name(config_details) == 'simplecomposefile' + + # Ignored if env switch is set to falsy value + os.environ['COMPOSE_X_PROJECT_NAME'] = 'false' + assert get_project_name(config_details) == 'simplecomposefile' + + # Env switch and no higher precedence value + os.environ['COMPOSE_X_PROJECT_NAME'] = '1' + assert get_project_name(config_details) == 'namefromcomposefile' + + # --project-name takes precedence + assert get_project_name(config_details, 'cliname') == 'cliname' + + # COMPOSE_PROJECT_NAME env takes precedence + os.environ['COMPOSE_PROJECT_NAME'] = 'namefromenv' + assert get_project_name(config_details) == 'namefromenv' + + @mock.patch.dict(os.environ) + def test_project_name_conflict_from_config_file(self): + base_dir = 'tests/fixtures/simple-composefile' + config_details = ConfigDetails(base_dir, [ + ConfigFile('base.yml', {'version': '2.3', 'x-project-name': 'foo'}), + ConfigFile('override.yml', {'version': '2.3', 'x-project-name': 'bar'}) + ]) + + os.environ['COMPOSE_X_PROJECT_NAME'] = 'true' + with pytest.raises(UserError) as excinfo: + get_project_name(config_details) + assert '"foo" (base.yml) does not match "bar" (override.yml)' in str(excinfo) + def test_get_project(self): base_dir = 'tests/fixtures/longer-filename-composefile' project = get_project(base_dir)