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

Hook: export metadata #21

Merged
merged 9 commits into from
Feb 27, 2019
Merged
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
87 changes: 87 additions & 0 deletions hooks/export_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# coding=utf-8

"""
Export metadata
---------------

This hook generates a dictionary with the repository data where the conanfile.py resides,
stores it in a file and exports it with the recipe. It is executed in the `export` command:

$ conan export . package/0.1.0@user/channel
[HOOK - /Users/jgsogo/dev/conan/conan-hooks/tests/test_hooks/../../hooks/export_metadata.py] pre_export(): Exported metadata to file 'metadata.json'
Exporting package recipe
name/version@jgsogo/test exports: Copied 1 '.json' file: metadata.json
name/version@jgsogo/test: A new conanfile.py version was exported

This information could be retrieved using the command `get`

$ conan get package/0.1.0@user/channel metadata.json


Note.- The default filename is 'metadata.json', although the user can change it using the
environment variable 'CONAN_HOOK_METADATA_FILENAME'

"""

import json
import os
import sys

import semver

from conans import __version__
from conans.errors import ConanException
from conans.tools import Git, save, SVN

_CONAN_HOOK_METADATA_FILENAME_ENV_VAR = 'CONAN_HOOK_METADATA_FILENAME'
CONAN_HOOK_METADATA_FILENAME = 'metadata.json'


def _try_repo_data(path, repo_class):
repo = repo_class(path)
try:
kwargs = {}
if not semver.satisfies(__version__, "<=1.12.x", loose=True):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not direct comparison using Version class? Is loose necessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try it a little bit, it was not easy to get a working expression, but I would be very pleased if you suggest something simpler.

Copy link
Member

@danimtb danimtb Feb 1, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure, but something like this?

Suggested change
if not semver.satisfies(__version__, "<=1.12.x", loose=True):
if Version(__version__) >= "1.12.0":

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Version("1.12.0-dev") >= "1.12.0"

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about... ?

>>> Version("1.12.0-dev") >= "1.12"
True

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could work, but conans.model.version.Version doesn't belong to the public Conan API 😝

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😆 😆 😆 😆

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still don't know why you are not using the Version class as it is something we are somehow recommending fo recipes...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an open issue about conans.model.Version that is proposing to define an API for that class. The operator >= could dissapear in favor of a satisfies function like in SemVer, and then this hook (production code) will be broken requiring an update from the user... I prefer to rely on public APIs, once it is defined we can change it.

kwargs.update({'remove_credentials': True})
danimtb marked this conversation as resolved.
Show resolved Hide resolved
return {'type': repo_class.cmd_command,
'url': repo.get_remote_url(**kwargs),
'revision': repo.get_revision(),
'dirty': bool(not repo.is_pristine())}
except ConanException:
pass
except Exception as e:
sys.stderr.write("Unhandled error using '{}': {}".format(repo_class, e))


def pre_export(output, conanfile, conanfile_path, *args, **kwargs):
danimtb marked this conversation as resolved.
Show resolved Hide resolved
""" Grab some meta-information from the local folder, write it to a file
and upload it within the recipe
"""
# Check that we are not overriding any file
filename = os.getenv(_CONAN_HOOK_METADATA_FILENAME_ENV_VAR, CONAN_HOOK_METADATA_FILENAME)
target_path = os.path.join(os.path.dirname(conanfile_path), filename)
if os.path.exists(target_path):
output.error("Target file to write metadata already exists: '{}'. Use"
" environment variable '{}' to set a different filename".
format(target_path, _CONAN_HOOK_METADATA_FILENAME_ENV_VAR))
return

# Look for the repo
path = os.path.dirname(conanfile_path)
scm_data = _try_repo_data(path, Git) or _try_repo_data(path, SVN)
if not scm_data:
output.warn("Cannot identify a repository system in "
"directory '{}' (tried SVN and Git)".format(path))
return

# Build the json
json_data = {'repo': scm_data}

# Dump information to file and export it with the recipe
content = json.dumps(json_data)
save(target_path, content)
if not getattr(conanfile, 'exports'):
setattr(conanfile, 'exports', os.path.basename(filename))
else:
conanfile.exports = conanfile.exports, os.path.basename(filename)
jgsogo marked this conversation as resolved.
Show resolved Hide resolved
output.info("Exported metadata to file '{}'".format(filename))
136 changes: 136 additions & 0 deletions tests/test_hooks/test_export_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# coding=utf-8

import json
import os
import subprocess
import sys
import textwrap
import unittest

import six
from parameterized import parameterized
from six.moves.urllib.parse import quote

from conans import tools
from conans.paths import CONAN_MANIFEST
from tests.utils import capabilities
from tests.utils.test_cases.conan_client import ConanClientTestCase

here = os.path.dirname(__file__)


def get_export_metadata_vars():
sys.path.insert(0, os.path.join(here, '..', '..', 'hooks'))
try:
from export_metadata import CONAN_HOOK_METADATA_FILENAME, \
_CONAN_HOOK_METADATA_FILENAME_ENV_VAR
return CONAN_HOOK_METADATA_FILENAME, _CONAN_HOOK_METADATA_FILENAME_ENV_VAR
finally:
sys.path.pop(0)


METADATA_FILENAME, METADATA_FILENAME_ENV_VAR = get_export_metadata_vars()


class ExportMetadataTests(ConanClientTestCase):
conanfile_base = textwrap.dedent("""\
from conans import ConanFile

class AConan(ConanFile):
{exports}
pass
""")
conanfile_plain = conanfile_base.format(exports="")

def _get_environ(self, **kwargs):
kwargs = super(ExportMetadataTests, self)._get_environ(**kwargs)
kwargs.update({'CONAN_HOOKS': os.path.join(here, '..', '..', 'hooks', 'export_metadata')})
return kwargs

def test_no_repo(self):
tools.save('conanfile.py', content=self.conanfile_plain)
output = self.conan(['export', '.', 'name/version@jgsogo/test'])
self.assertIn("pre_export(): WARN: Cannot identify a repository system in directory", output)

def test_conflicting_file(self):
tools.save('conanfile.py', content=self.conanfile_plain)
tools.save(METADATA_FILENAME, content='whatever')
output = self.conan(['export', '.', 'name/version@jgsogo/test'])
self.assertIn("ERROR: Target file to write metadata already exists", output)
self.assertIn("Use environment variable '{}' to set a different "
"filename".format(METADATA_FILENAME_ENV_VAR), output)

with tools.environment_append({METADATA_FILENAME_ENV_VAR: "other.json"}):
output = self.conan(['export', '.', 'name/version@jgsogo/test'])
self.assertNotIn("ERROR: Target file to write metadata already exists", output)

@parameterized.expand([('"myfile.txt"',), ('("myfile1", "myfile2")',), (None, )])
def test_git_repository(self, exports):
conanfile = self.conanfile_base.format(exports=exports) if exports else self.conanfile_plain
reference = 'name/version@jgsogo/test'
url = 'http://some.url'

git = tools.Git()
git.run("init .")
git.run('config user.email "you@example.com"')
git.run('config user.name "Your Name"')
tools.save('conanfile.py', content=conanfile)
git.run('add --all')
git.run('commit -am "initial"')
git.run('remote add origin {}'.format(url))

output = self.conan(['export', '.', reference])
self.assertIn("pre_export(): Exported metadata to file '{}'".format(METADATA_FILENAME),
output)

# Check that the file is in the export folder, contains expected data and is in the manifest
output = self.conan(['get', reference, METADATA_FILENAME])
data = json.loads(output)
self.assertDictEqual(data['repo'], {'type': 'git',
'url': url,
'revision': git.get_commit(),
'dirty': False})
output = self.conan(['get', reference, CONAN_MANIFEST])
self.assertIn(METADATA_FILENAME, output)

@parameterized.expand([(True,), (False,)])
@unittest.skipUnless(capabilities.svn(), "SVN not available")
def test_svn_repository(self, pristine_repo):
if pristine_repo and not bool(tools.SVN.get_version() >= tools.SVN.API_CHANGE_VERSION):
raise unittest.SkipTest("Required SVN >= {} to test for pristine "
"repo".format(tools.SVN.API_CHANGE_VERSION))

reference = 'name/version@jgsogo/test'

# Create the SVN repo
repo_url = self._gimme_tmp()
subprocess.check_output('svnadmin create "{}"'.format(repo_url), shell=True)
repo_url = tools.SVN.file_protocol + quote(repo_url.replace("\\", "/"), safe='/:')

# Create the working repo
svn = tools.SVN()
svn.checkout(url=repo_url)
tools.save('conanfile.py', content=self.conanfile_plain)
svn.run("add conanfile.py")
svn.run('commit -m "initial"')
if pristine_repo:
svn.run('update')
self.assertEqual(svn.is_pristine(), pristine_repo)

output = self.conan(['export', '.', reference])
self.assertIn("pre_export(): Exported metadata to file '{}'".format(METADATA_FILENAME),
output)

# Check that the file is in the export folder, contains expected data and is in the manifest
output = self.conan(['get', reference, METADATA_FILENAME])
data = json.loads(output)
self.assertListEqual(sorted(data['repo'].keys()),
sorted([six.u('type'), six.u('url'),
six.u('revision'), six.u('dirty')]))
self.assertEqual(data['repo']['type'], six.u('svn'))
self.assertEqual(data['repo']['url'].lower(), six.u(repo_url).lower())
self.assertEqual(data['repo']['revision'], svn.get_revision())
self.assertEqual(data['repo']['dirty'], bool(not pristine_repo))

output = self.conan(['get', reference, CONAN_MANIFEST])
self.assertIn(METADATA_FILENAME, output)
22 changes: 22 additions & 0 deletions tests/utils/conan_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# coding=utf-8

from contextlib import contextmanager

from conans import __version__ as conan_version
from conans.client.command import Conan, CommandOutputer, Command
from conans.model.version import Version


@contextmanager
def conan_command(output_stream):
# This snippet reproduces code from conans.client.command.main, we cannot directly
# use it because in case of error it is exiting the python interpreter :/
conan_api, cache, user_io = Conan.factory()
user_io.out._stream = output_stream
outputer = CommandOutputer(user_io, cache)
cmd = Command(conan_api, cache, user_io, outputer)
try:
yield cmd
finally:
if Version(conan_version) < "1.13":
conan_api._remote_manager._auth_manager._localdb.connection.close() # Close sqlite3
49 changes: 25 additions & 24 deletions tests/utils/test_cases/conan_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@
import uuid
from io import StringIO

from conans.__init__ import __version__ as conan_version
from conans.client.command import Conan, CommandOutputer, Command, SUCCESS
from conans.model.version import Version
from conans.client.command import SUCCESS
from tests.utils.conan_command import conan_command
from tests.utils.environ_vars import context_env


Expand All @@ -27,26 +26,18 @@ def _get_environ(self, **kwargs):

def conan(self, command, expected_return_code=SUCCESS):
with context_env(**self._get_environ()):
# This snippet reproduces code from conans.client.command.main, we cannot directly
# use it because in case of error it is exiting the python interpreter :/
conan_api, cache, user_io = Conan.factory()
output_stream = StringIO()
user_io.out._stream = output_stream
outputer = CommandOutputer(user_io, cache)
cmd = Command(conan_api, cache, user_io, outputer)
try:
return_code = cmd.run(command)
except Exception as e:
# Conan execution failed
self.fail("Conan execution for this test failed with {}: {}".format(type(e), e))
else:
# Check return code
self.assertEqual(return_code, expected_return_code,
msg="Unexpected return code\n\n{}".format(output_stream.getvalue()))
finally:
if Version(conan_version) < "1.13":
conan_api._remote_manager._auth_manager._localdb.connection.close() # Close sqlite3
return output_stream.getvalue()
stream = StringIO()
with conan_command(stream) as cmd:
try:
return_code = cmd.run(command)
except Exception as e:
# Conan execution failed
self.fail("Conan execution for this test failed with {}: {}".format(type(e), e))
else:
# Check return code
self.assertEqual(return_code, expected_return_code,
msg="Unexpected return code\n\n{}".format(stream.getvalue()))
return stream.getvalue()

def setUp(self):
testcase_dir = os.path.join(self._working_dir, str(uuid.uuid4()))
Expand All @@ -61,7 +52,17 @@ def setUpClass(cls):
@classmethod
def tearDownClass(cls):
os.chdir(cls._old_cwd)
shutil.rmtree(cls._working_dir)

def handleRemoveReadonly(func, path, exc):
import errno, stat
excvalue = exc[1]
if func in (os.rmdir, os.remove, os.unlink) and excvalue.errno == errno.EACCES:
os.chmod(path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO) # 0777
func(path)
else:
raise Exception("Failed to remove '{}'".format(path))

shutil.rmtree(cls._working_dir, onerror=handleRemoveReadonly)

def _gimme_tmp(self):
return os.path.join(self._working_dir, str(uuid.uuid4()))