Skip to content

Commit

Permalink
Implement snapcraft search (#608)
Browse files Browse the repository at this point in the history
LP: #1596222

Signed-off-by: Sergio Schvezov <sergio.schvezov@ubuntu.com>
  • Loading branch information
sergiusens committed Jun 27, 2016
1 parent 8a4d204 commit ea6595e
Show file tree
Hide file tree
Showing 9 changed files with 233 additions and 48 deletions.
1 change: 1 addition & 0 deletions .bzrignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ dist
htmlcov
__pycache__
docs/**.html
*.swp
10 changes: 10 additions & 0 deletions snapcraft/internal/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,16 @@ def _search_and_replace_contents(file_path, search_pattern, replacement):
f.write(replaced)


def get_terminal_width(max_width=MAX_CHARACTERS_WRAP):
if os.isatty(sys.stdout.fileno()):
width = shutil.get_terminal_size().columns
else:
width = MAX_CHARACTERS_WRAP
if max_width:
width = min(max_width, width)
return width


def format_output_in_columns(elements_list, max_width=MAX_CHARACTERS_WRAP,
num_col_spaces=2):
"""Return a formatted list of strings ready to be printed line by line
Expand Down
48 changes: 48 additions & 0 deletions snapcraft/internal/parts.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import difflib
import logging
import os
import sys
Expand All @@ -29,7 +30,13 @@
)
from xdg import BaseDirectory

from snapcraft.internal.common import get_terminal_width


PARTS_URI = 'https://parts.snapcraft.io/v1/parts.yaml'
_MATCH_RATIO = 0.6
_HEADER_PART_NAME = 'PART NAME'
_HEADER_DESCRIPTION = 'DESCRIPTION'

logging.getLogger("urllib3").setLevel(logging.CRITICAL)
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -117,6 +124,22 @@ def get_part(self, part_name, full=False):
remote_part.pop(key)
return remote_part

def matches_for(self, part_match, max_len=0):
matcher = difflib.SequenceMatcher(isjunk=None, autojunk=False)
matcher.set_seq2(part_match)

matching_parts = {}
for part_name in self._parts.keys():
matcher.set_seq1(part_name)
add_part_name = matcher.ratio() >= _MATCH_RATIO

if add_part_name or (part_match in part_name):
matching_parts[part_name] = self._parts[part_name]
if len(part_name) > max_len:
max_len = len(part_name)

return matching_parts, max_len

def compose(self, part_name, properties):
"""Return properties composed with the ones from part name in the wiki.
:param str part_name: The name of the part to query from the wiki
Expand Down Expand Up @@ -151,5 +174,30 @@ def define(part_name):
default_flow_style=False, stream=sys.stdout)


def search(part_match):
header_len = len(_HEADER_PART_NAME)
matches, part_length = _RemoteParts().matches_for(part_match, header_len)

terminal_width = get_terminal_width(max_width=None)
part_length = max(part_length, header_len)
# <space> + <space> + <description> + ... = 5
description_space = terminal_width - part_length - 5

if not matches:
# apt search does not return error, we probably shouldn't either.
logger.info('No matches found, try to run `snapcraft update` to '
'refresh the remote parts cache.')
return

print('{} {}'.format(
_HEADER_PART_NAME.ljust(part_length, ' '), _HEADER_DESCRIPTION))
for part_key in matches.keys():
description = matches[part_key]['description']
if len(description) > description_space:
description = '{}...'.format(description[0:description_space])
print('{} {}'.format(
part_key.ljust(part_length, ' '), description))


def get_remote_parts():
return _RemoteParts()
21 changes: 8 additions & 13 deletions snapcraft/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
snapcraft [options] tour [<directory>]
snapcraft [options] update
snapcraft [options] define <part-name>
snapcraft [options] search [<query> ...]
snapcraft [options] help (topics | <plugin> | <topic>) [--devel]
snapcraft (-h | --help)
snapcraft --version
Expand Down Expand Up @@ -93,6 +94,7 @@
Parts ecosystem commands
update Updates the parts listing from the cloud.
define Shows the definition for the cloud part.
search Searches the remotes part cache for matching parts.
Calling snapcraft without a COMMAND will default to 'snap'
Expand All @@ -106,22 +108,22 @@
http://developer.ubuntu.com/snappy/snapcraft
"""

from contextlib import suppress
import logging
import os
import pkg_resources
import pkgutil
import shutil
import sys
import subprocess
import textwrap

from docopt import docopt

import snapcraft
from snapcraft.internal import lifecycle, log, parts
from snapcraft.internal.common import (
format_output_in_columns, MAX_CHARACTERS_WRAP, get_tourdir)
format_output_in_columns,
get_terminal_width,
get_tourdir)


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -169,16 +171,7 @@ def _list_plugins():
plugins.append(modname.replace('_', '-'))

# we wrap the output depending on terminal size
width = MAX_CHARACTERS_WRAP
with suppress(OSError):
with suppress(subprocess.CalledProcessError):
# this is the only way to get current terminal size reliably
# without duplicating a bunch of logic
command = ['tput', 'cols']
candidate_width = \
subprocess.check_output(command, stderr=subprocess.DEVNULL)
width = min(int(candidate_width), width)

width = get_terminal_width()
for line in format_output_in_columns(plugins, max_width=width):
print(line)

Expand Down Expand Up @@ -260,6 +253,8 @@ def run(args, project_options): # noqa
parts.update()
elif args['define']:
parts.define(args['<part-name>'])
elif args['search']:
parts.search(' '.join(args['<query>']))
else: # snap by default:
lifecycle.snap(project_options, args['<directory>'], args['--output'])

Expand Down
8 changes: 7 additions & 1 deletion snapcraft/tests/fake_servers.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,16 @@ def do_GET(self):
'description': 'test entry for part1',
'maintainer': 'none',
},
'long-described-part': {
'plugin': 'go',
'source': 'http://source.tar.gz',
'description': 'this is a repetitive description ' * 3,
'maintainer': 'none',
},
}
self.send_header('Content-Type', 'text/plain')
if 'NO_CONTENT_LENGTH' not in os.environ:
self.send_header('Content-Length', '300')
self.send_header('Content-Length', '1000')
self.send_header('ETag', '1111')
self.end_headers()
self.wfile.write(yaml.dump(response).encode())
Expand Down
39 changes: 39 additions & 0 deletions snapcraft/tests/fixture_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import os
import io
import threading
import urllib.parse
from unittest import mock
Expand Down Expand Up @@ -79,6 +80,44 @@ def setUp(self):
self.addCleanup(os.environ.update, current_environment)


class _FakeStdout(io.StringIO):
"""A fake stdout using StringIO implementing the missing fileno attrib."""

def fileno(self):
return 1


class _FakeTerminalSize:

def __init__(self, columns=80):
self.columns = columns


class FakeTerminal(fixtures.Fixture):

def __init__(self, columns=80, isatty=True):
self.columns = columns
self.isatty = isatty

def _setUp(self):
patcher = mock.patch('shutil.get_terminal_size')
mock_terminal_size = patcher.start()
mock_terminal_size.return_value = _FakeTerminalSize(self.columns)
self.addCleanup(patcher.stop)

patcher = mock.patch('sys.stdout', new_callable=_FakeStdout)
self.mock_stdout = patcher.start()
self.addCleanup(patcher.stop)

patcher = mock.patch('os.isatty')
mock_isatty = patcher.start()
mock_isatty.return_value = self.isatty
self.addCleanup(patcher.stop)

def getvalue(self):
return self.mock_stdout.getvalue()


class FakeParts(fixtures.Fixture):

def setUp(self):
Expand Down
52 changes: 18 additions & 34 deletions snapcraft/tests/test_commands_list_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,9 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import io
import subprocess
from unittest import mock

from snapcraft.main import main
from snapcraft import tests
from snapcraft.tests import fixture_setup


class ListPluginsCommandTestCase(tests.TestCase):
Expand All @@ -31,41 +28,28 @@ class ListPluginsCommandTestCase(tests.TestCase):
'autotools cmake go jdk kernel maven nodejs python3 '
'scons\n')

@mock.patch('sys.stdout', new_callable=io.StringIO)
@mock.patch('subprocess.check_output')
def test_list_plugins_large_terminal(self, mock_subprocess, mock_stdout):
mock_subprocess.return_value = "999"
def test_list_plugins_non_tty(self):
fake_terminal = fixture_setup.FakeTerminal(isatty=False)
self.useFixture(fake_terminal)

main(['list-plugins'])
self.assertEqual(fake_terminal.getvalue(), self.default_plugin_output)

def test_list_plugins_large_terminal(self):
fake_terminal = fixture_setup.FakeTerminal(columns=999)
self.useFixture(fake_terminal)

main(['list-plugins'])
self.assertEqual(mock_stdout.getvalue(), self.default_plugin_output)
self.assertEqual(fake_terminal.getvalue(), self.default_plugin_output)

def test_list_plugins_small_terminal(self):
fake_terminal = fixture_setup.FakeTerminal(columns=60)
self.useFixture(fake_terminal)

@mock.patch('sys.stdout', new_callable=io.StringIO)
@mock.patch('subprocess.check_output')
def test_list_plugins_small_terminal(self, mock_subprocess, mock_stdout):
mock_subprocess.return_value = "60"
expected_output = (
'ant copy kbuild nil qmake \n'
'autotools go kernel nodejs scons \n'
'catkin gulp make python2 tar-content\n'
'cmake jdk maven python3\n')
main(['list-plugins'])
self.assertEqual(mock_stdout.getvalue(), expected_output)

@mock.patch('sys.stdout', new_callable=io.StringIO)
@mock.patch('subprocess.check_output')
def test_list_plugins_error_invalid_terminal_size(self, mock_subprocess,
mock_stdout):
def raise_error(cmd, stderr):
raise OSError()
mock_subprocess.side_effect = raise_error
main(['list-plugins'])
self.assertEqual(mock_stdout.getvalue(), self.default_plugin_output)

@mock.patch('sys.stdout', new_callable=io.StringIO)
@mock.patch('subprocess.check_output')
def test_list_plugins_error_invalid_subprocess_call(self, mock_subprocess,
mock_stdout):
def raise_error(cmd, stderr):
raise subprocess.CalledProcessError(returncode=1, cmd=cmd)
mock_subprocess.side_effect = raise_error
main(['list-plugins'])
self.assertEqual(mock_stdout.getvalue(), self.default_plugin_output)
self.assertEqual(fake_terminal.getvalue(), expected_output)

0 comments on commit ea6595e

Please sign in to comment.