diff --git a/doc/06-config.md b/doc/06-config.md index f231bcb47..9807e6197 100644 --- a/doc/06-config.md +++ b/doc/06-config.md @@ -17,7 +17,26 @@ in the PHP include path. ## preferred-install Defaults to `auto` and can be any of `source`, `dist` or `auto`. This option -allows you to set the install method Composer will prefer to use. +allows you to set the install method Composer will prefer to use. Can +optionally be a hash of patterns for more granular install preferences. + +```json +{ + "config": { + "preferred-install": { + "my-organization/stable-package": "dist", + "my-organization/*": "source", + "partner-organization/*": "auto", + "*": "dist" + } + } +} +``` + +> **Note:** Order matters. More specific patterns should be earlier than +> more relaxed patterns. When mixing the string notation with the hash +> configuration in global and package configurations the string notation +> is translated to a `*` package pattern. ## store-auths diff --git a/res/composer-schema.json b/res/composer-schema.json index 9d36b6721..68cb415f9 100644 --- a/res/composer-schema.json +++ b/res/composer-schema.json @@ -117,8 +117,8 @@ "description": "If true, the Composer autoloader will also look for classes in the PHP include path." }, "preferred-install": { - "type": "string", - "description": "The install method Composer will prefer to use, defaults to auto and can be any of source, dist or auto." + "type": ["string", "object"], + "description": "The install method Composer will prefer to use, defaults to auto and can be any of source, dist, auto, or a hash of {\"pattern\": \"preference\"}." }, "notify-on-install": { "type": "boolean", diff --git a/src/Composer/Config.php b/src/Composer/Config.php index 98d463ea4..3cfc9653a 100644 --- a/src/Composer/Config.php +++ b/src/Composer/Config.php @@ -21,6 +21,10 @@ class Config { const RELATIVE_PATHS = 1; + const INSTALL_PREFERENCE_AUTO = 'auto'; + const INSTALL_PREFERENCE_DIST = 'dist'; + const INSTALL_PREFERENCE_SOURCE = 'source'; + public static $defaultConfig = array( 'process-timeout' => 300, 'use-include-path' => false, @@ -120,6 +124,24 @@ class Config foreach ($config['config'] as $key => $val) { if (in_array($key, array('github-oauth', 'gitlab-oauth', 'http-basic')) && isset($this->config[$key])) { $this->config[$key] = array_merge($this->config[$key], $val); + } elseif ('preferred-install' === $key && isset($this->config[$key])) { + if (is_array($val) || is_array($this->config[$key])) { + if (is_string($val)) { + $val = array('*' => $val); + } + if (is_string($this->config[$key])) { + $this->config[$key] = array('*' => $this->config[$key]); + } + $this->config[$key] = array_merge($this->config[$key], $val); + // the full match pattern needs to be last + if (isset($this->config[$key]['*'])) { + $wildcard = $this->config[$key]['*']; + unset($this->config[$key]['*']); + $this->config[$key]['*'] = $wildcard; + } + } else { + $this->config[$key] = $val; + } } else { $this->config[$key] = $val; } diff --git a/src/Composer/Downloader/DownloadManager.php b/src/Composer/Downloader/DownloadManager.php index 9abd19f7f..2793469b2 100644 --- a/src/Composer/Downloader/DownloadManager.php +++ b/src/Composer/Downloader/DownloadManager.php @@ -12,6 +12,7 @@ namespace Composer\Downloader; +use Composer\Config; use Composer\Package\PackageInterface; use Composer\IO\IOInterface; use Composer\Util\Filesystem; @@ -26,6 +27,7 @@ class DownloadManager private $io; private $preferDist = false; private $preferSource = false; + private $packagePreferences = array(); private $filesystem; private $downloaders = array(); @@ -69,6 +71,19 @@ class DownloadManager return $this; } + /** + * Sets fine tuned preference settings for package level source/dist selection. + * + * @param array $preferences array of preferences by package patterns + * @return DownloadManager + */ + public function setPreferences(array $preferences) + { + $this->packagePreferences = $preferences; + + return $this; + } + /** * Sets whether to output download progress information for all registered * downloaders @@ -182,7 +197,7 @@ class DownloadManager throw new \InvalidArgumentException('Package '.$package.' must have a source or dist specified'); } - if ((!$package->isDev() || $this->preferDist) && !$preferSource) { + if (!$preferSource && ($this->preferDist || Config::INSTALL_PREFERENCE_DIST === $this->resolvePackageInstallPreference($package))) { $sources = array_reverse($sources); } @@ -282,4 +297,26 @@ class DownloadManager $downloader->remove($package, $targetDir); } } + + /** + * Determines the install preference of a package + * + * @param PackageInterface $package package instance + * + * @return string + */ + protected function resolvePackageInstallPreference(PackageInterface $package) + { + foreach ($this->packagePreferences as $pattern => $preference) { + $pattern = '{^'.str_replace('*', '.*', $pattern).'$}i'; + if (preg_match($pattern, $package->getName())) { + if (Config::INSTALL_PREFERENCE_DIST === $preference || (!$package->isDev() && Config::INSTALL_PREFERENCE_AUTO === $preference)) { + return Config::INSTALL_PREFERENCE_DIST; + } + return Config::INSTALL_PREFERENCE_SOURCE; + } + } + + return $package->isDev() ? Config::INSTALL_PREFERENCE_SOURCE : Config::INSTALL_PREFERENCE_DIST; + } } diff --git a/src/Composer/Factory.php b/src/Composer/Factory.php index 1f77554ac..2085adbce 100644 --- a/src/Composer/Factory.php +++ b/src/Composer/Factory.php @@ -466,7 +466,7 @@ class Factory } $dm = new Downloader\DownloadManager($io); - switch ($config->get('preferred-install')) { + switch ($preferred = $config->get('preferred-install')) { case 'dist': $dm->setPreferDist(true); break; @@ -479,6 +479,10 @@ class Factory break; } + if (is_array($preferred)) { + $dm->setPreferences($preferred); + } + $executor = new ProcessExecutor($io); $fs = new Filesystem($executor); diff --git a/tests/Composer/Test/ConfigTest.php b/tests/Composer/Test/ConfigTest.php index ca3e54ce7..abce384c2 100644 --- a/tests/Composer/Test/ConfigTest.php +++ b/tests/Composer/Test/ConfigTest.php @@ -112,6 +112,27 @@ class ConfigTest extends \PHPUnit_Framework_TestCase return $data; } + public function testPreferredInstallAsString() + { + $config = new Config(false); + $config->merge(array('config' => array('preferred-install' => 'source'))); + $config->merge(array('config' => array('preferred-install' => 'dist'))); + + $this->assertEquals('dist', $config->get('preferred-install')); + } + + public function testMergePreferredInstall() + { + $config = new Config(false); + $config->merge(array('config' => array('preferred-install' => 'dist'))); + $config->merge(array('config' => array('preferred-install' => array('foo/*' => 'source')))); + + // This assertion needs to make sure full wildcard preferences are placed last + // Handled by composer because we convert string preferences for BC, all other + // care for ordering and collision prevention is up to the user + $this->assertEquals(array('foo/*' => 'source', '*' => 'dist'), $config->get('preferred-install')); + } + public function testMergeGithubOauth() { $config = new Config(false); diff --git a/tests/Composer/Test/Downloader/DownloadManagerTest.php b/tests/Composer/Test/Downloader/DownloadManagerTest.php index 1728c583e..fa3e546d3 100644 --- a/tests/Composer/Test/Downloader/DownloadManagerTest.php +++ b/tests/Composer/Test/Downloader/DownloadManagerTest.php @@ -757,6 +757,366 @@ class DownloadManagerTest extends \PHPUnit_Framework_TestCase $manager->remove($package, 'vendor/bundles/FOS/UserBundle'); } + /** + * @covers Composer\Downloader\DownloadManager::resolvePackageInstallPreference + */ + public function testInstallPreferenceWithoutPreferenceDev() + { + $package = $this->createPackageMock(); + $package + ->expects($this->once()) + ->method('getSourceType') + ->will($this->returnValue('git')); + $package + ->expects($this->once()) + ->method('getDistType') + ->will($this->returnValue('pear')); + $package + ->expects($this->once()) + ->method('isDev') + ->will($this->returnValue(true)); + + $package + ->expects($this->once()) + ->method('setInstallationSource') + ->with('source'); + + $downloader = $this->createDownloaderMock(); + $downloader + ->expects($this->once()) + ->method('download') + ->with($package, 'target_dir'); + + $manager = $this->getMockBuilder('Composer\Downloader\DownloadManager') + ->setConstructorArgs(array($this->io, false, $this->filesystem)) + ->setMethods(array('getDownloaderForInstalledPackage')) + ->getMock(); + $manager + ->expects($this->once()) + ->method('getDownloaderForInstalledPackage') + ->with($package) + ->will($this->returnValue($downloader)); + + $manager->download($package, 'target_dir'); + } + + /** + * @covers Composer\Downloader\DownloadManager::resolvePackageInstallPreference + */ + public function testInstallPreferenceWithoutPreferenceNoDev() + { + $package = $this->createPackageMock(); + $package + ->expects($this->once()) + ->method('getSourceType') + ->will($this->returnValue('git')); + $package + ->expects($this->once()) + ->method('getDistType') + ->will($this->returnValue('pear')); + $package + ->expects($this->once()) + ->method('isDev') + ->will($this->returnValue(false)); + + $package + ->expects($this->once()) + ->method('setInstallationSource') + ->with('dist'); + + $downloader = $this->createDownloaderMock(); + $downloader + ->expects($this->once()) + ->method('download') + ->with($package, 'target_dir'); + + $manager = $this->getMockBuilder('Composer\Downloader\DownloadManager') + ->setConstructorArgs(array($this->io, false, $this->filesystem)) + ->setMethods(array('getDownloaderForInstalledPackage')) + ->getMock(); + $manager + ->expects($this->once()) + ->method('getDownloaderForInstalledPackage') + ->with($package) + ->will($this->returnValue($downloader)); + + $manager->download($package, 'target_dir'); + } + + /** + * @covers Composer\Downloader\DownloadManager::resolvePackageInstallPreference + */ + public function testInstallPreferenceWithoutMatchDev() + { + $package = $this->createPackageMock(); + $package + ->expects($this->once()) + ->method('getSourceType') + ->will($this->returnValue('git')); + $package + ->expects($this->once()) + ->method('getDistType') + ->will($this->returnValue('pear')); + $package + ->expects($this->once()) + ->method('isDev') + ->will($this->returnValue(true)); + $package + ->expects($this->once()) + ->method('getName') + ->will($this->returnValue('bar/package')); + $package + ->expects($this->once()) + ->method('setInstallationSource') + ->with('source'); + + $downloader = $this->createDownloaderMock(); + $downloader + ->expects($this->once()) + ->method('download') + ->with($package, 'target_dir'); + + $manager = $this->getMockBuilder('Composer\Downloader\DownloadManager') + ->setConstructorArgs(array($this->io, false, $this->filesystem)) + ->setMethods(array('getDownloaderForInstalledPackage')) + ->getMock(); + $manager + ->expects($this->once()) + ->method('getDownloaderForInstalledPackage') + ->with($package) + ->will($this->returnValue($downloader)); + $manager->setPreferences(array('foo/*' => 'source')); + + $manager->download($package, 'target_dir'); + } + + /** + * @covers Composer\Downloader\DownloadManager::resolvePackageInstallPreference + */ + public function testInstallPreferenceWithoutMatchNoDev() + { + $package = $this->createPackageMock(); + $package + ->expects($this->once()) + ->method('getSourceType') + ->will($this->returnValue('git')); + $package + ->expects($this->once()) + ->method('getDistType') + ->will($this->returnValue('pear')); + $package + ->expects($this->once()) + ->method('isDev') + ->will($this->returnValue(false)); + $package + ->expects($this->once()) + ->method('getName') + ->will($this->returnValue('bar/package')); + $package + ->expects($this->once()) + ->method('setInstallationSource') + ->with('dist'); + + $downloader = $this->createDownloaderMock(); + $downloader + ->expects($this->once()) + ->method('download') + ->with($package, 'target_dir'); + + $manager = $this->getMockBuilder('Composer\Downloader\DownloadManager') + ->setConstructorArgs(array($this->io, false, $this->filesystem)) + ->setMethods(array('getDownloaderForInstalledPackage')) + ->getMock(); + $manager + ->expects($this->once()) + ->method('getDownloaderForInstalledPackage') + ->with($package) + ->will($this->returnValue($downloader)); + $manager->setPreferences(array('foo/*' => 'source')); + + $manager->download($package, 'target_dir'); + } + + /** + * @covers Composer\Downloader\DownloadManager::resolvePackageInstallPreference + */ + public function testInstallPreferenceWithMatchAutoDev() + { + $package = $this->createPackageMock(); + $package + ->expects($this->once()) + ->method('getSourceType') + ->will($this->returnValue('git')); + $package + ->expects($this->once()) + ->method('getDistType') + ->will($this->returnValue('pear')); + $package + ->expects($this->once()) + ->method('isDev') + ->will($this->returnValue(true)); + $package + ->expects($this->once()) + ->method('getName') + ->will($this->returnValue('foo/package')); + $package + ->expects($this->once()) + ->method('setInstallationSource') + ->with('source'); + + $downloader = $this->createDownloaderMock(); + $downloader + ->expects($this->once()) + ->method('download') + ->with($package, 'target_dir'); + + $manager = $this->getMockBuilder('Composer\Downloader\DownloadManager') + ->setConstructorArgs(array($this->io, false, $this->filesystem)) + ->setMethods(array('getDownloaderForInstalledPackage')) + ->getMock(); + $manager + ->expects($this->once()) + ->method('getDownloaderForInstalledPackage') + ->with($package) + ->will($this->returnValue($downloader)); + $manager->setPreferences(array('foo/*' => 'auto')); + + $manager->download($package, 'target_dir'); + } + + /** + * @covers Composer\Downloader\DownloadManager::resolvePackageInstallPreference + */ + public function testInstallPreferenceWithMatchAutoNoDev() + { + $package = $this->createPackageMock(); + $package + ->expects($this->once()) + ->method('getSourceType') + ->will($this->returnValue('git')); + $package + ->expects($this->once()) + ->method('getDistType') + ->will($this->returnValue('pear')); + $package + ->expects($this->once()) + ->method('isDev') + ->will($this->returnValue(false)); + $package + ->expects($this->once()) + ->method('getName') + ->will($this->returnValue('foo/package')); + $package + ->expects($this->once()) + ->method('setInstallationSource') + ->with('dist'); + + $downloader = $this->createDownloaderMock(); + $downloader + ->expects($this->once()) + ->method('download') + ->with($package, 'target_dir'); + + $manager = $this->getMockBuilder('Composer\Downloader\DownloadManager') + ->setConstructorArgs(array($this->io, false, $this->filesystem)) + ->setMethods(array('getDownloaderForInstalledPackage')) + ->getMock(); + $manager + ->expects($this->once()) + ->method('getDownloaderForInstalledPackage') + ->with($package) + ->will($this->returnValue($downloader)); + $manager->setPreferences(array('foo/*' => 'auto')); + + $manager->download($package, 'target_dir'); + } + + /** + * @covers Composer\Downloader\DownloadManager::resolvePackageInstallPreference + */ + public function testInstallPreferenceWithMatchSource() + { + $package = $this->createPackageMock(); + $package + ->expects($this->once()) + ->method('getSourceType') + ->will($this->returnValue('git')); + $package + ->expects($this->once()) + ->method('getDistType') + ->will($this->returnValue('pear')); + $package + ->expects($this->once()) + ->method('getName') + ->will($this->returnValue('foo/package')); + $package + ->expects($this->once()) + ->method('setInstallationSource') + ->with('source'); + + $downloader = $this->createDownloaderMock(); + $downloader + ->expects($this->once()) + ->method('download') + ->with($package, 'target_dir'); + + $manager = $this->getMockBuilder('Composer\Downloader\DownloadManager') + ->setConstructorArgs(array($this->io, false, $this->filesystem)) + ->setMethods(array('getDownloaderForInstalledPackage')) + ->getMock(); + $manager + ->expects($this->once()) + ->method('getDownloaderForInstalledPackage') + ->with($package) + ->will($this->returnValue($downloader)); + $manager->setPreferences(array('foo/*' => 'source')); + + $manager->download($package, 'target_dir'); + } + + /** + * @covers Composer\Downloader\DownloadManager::resolvePackageInstallPreference + */ + public function testInstallPreferenceWithMatchDist() + { + $package = $this->createPackageMock(); + $package + ->expects($this->once()) + ->method('getSourceType') + ->will($this->returnValue('git')); + $package + ->expects($this->once()) + ->method('getDistType') + ->will($this->returnValue('pear')); + $package + ->expects($this->once()) + ->method('getName') + ->will($this->returnValue('foo/package')); + $package + ->expects($this->once()) + ->method('setInstallationSource') + ->with('dist'); + + $downloader = $this->createDownloaderMock(); + $downloader + ->expects($this->once()) + ->method('download') + ->with($package, 'target_dir'); + + $manager = $this->getMockBuilder('Composer\Downloader\DownloadManager') + ->setConstructorArgs(array($this->io, false, $this->filesystem)) + ->setMethods(array('getDownloaderForInstalledPackage')) + ->getMock(); + $manager + ->expects($this->once()) + ->method('getDownloaderForInstalledPackage') + ->with($package) + ->will($this->returnValue($downloader)); + $manager->setPreferences(array('foo/*' => 'dist')); + + $manager->download($package, 'target_dir'); + } + private function createDownloaderMock() { return $this->getMockBuilder('Composer\Downloader\DownloaderInterface')