Skip to content

Commit

Permalink
Initial support for resolve.npm.
Browse files Browse the repository at this point in the history
This is pants implementing `npm install` for node projects modeled in
pants.  The driving concern is primary control by pants of Node.js
packages via BUILD files, and this necessitates generating package.json
files under `.pants.d/` to describe dependencies (and later more) to
`npm` so it can resolve both local and remote dependencies.  A second
fallout is copying pants controlled node module sourcecode up into
`.pants.d/` since `node` has a documented behavior of resolving against
`realpath`[1], foiling any attempt to use symlinks.

The basic targets needed to support `resolve.npm` - aka `npm install`
are added along with basic tests for the functionality.

[1] https://nodejs.org/api/modules.html#modules_addenda_package_manager_tips

Testing Done:
I manually confirmed that with the NpmResolve execution of `npm dedupe`
commented out, the dedupe portion of the
`NpmResolveTest.test_resolve_simple_graph` test failed like so:
```console
E     AssertionError: Expected to find exactly 1 de-duped `typ` package, but found these:
E     	node_modules/typ/package.json
E     	node_modules/util/node_modules/typ/package.json
```

CI went green here:
  https://travis-ci.org/pantsbuild/pants/builds/77852199

Bugs closed: 2073, 2087

Reviewed at https://rbcommons.com/s/twitter/r/2723/
  • Loading branch information
jsirois committed Aug 30, 2015
1 parent 561b31c commit d0a701d
Show file tree
Hide file tree
Showing 22 changed files with 776 additions and 0 deletions.
3 changes: 3 additions & 0 deletions contrib/node/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@

source_root('src/python', python_library)
source_root('tests/python', python_library, python_tests)

source_root('examples/3rdparty/node', node_remote_module)
source_root('examples/src/node', node_module)
Empty file.
18 changes: 18 additions & 0 deletions contrib/node/src/python/pants/contrib/node/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Copyright 2015 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

# TODO(John Sirois): Tie this into the contrib/release_packages.sh the minute the plugin can do
# one useful thing, for example `./pants test contrib/node/examples::`.
contrib_plugin(
name='plugin',
dependencies=[
'contrib/node/src/python/pants/contrib/node/targets',
'contrib/node/src/python/pants/contrib/node/tasks',
'src/python/pants/base:build_file_aliases',
'src/python/pants/goal:task_registrar',
],
distribution_name='pantsbuild.pants.contrib.node',
description='Node.js support for pants.',
build_file_aliases=True,
register_goals=True,
)
26 changes: 26 additions & 0 deletions contrib/node/src/python/pants/contrib/node/register.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# coding=utf-8
# Copyright 2015 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import (absolute_import, division, generators, nested_scopes, print_function,
unicode_literals, with_statement)

from pants.base.build_file_aliases import BuildFileAliases
from pants.goal.task_registrar import TaskRegistrar as task

from pants.contrib.node.targets.node_module import NodeModule
from pants.contrib.node.targets.node_remote_module import NodeRemoteModule
from pants.contrib.node.tasks.npm_resolve import NpmResolve


def build_file_aliases():
return BuildFileAliases.create(
targets={
'node_module': NodeModule,
'node_remote_module': NodeRemoteModule,
},
)


def register_goals():
task(name='npm', action=NpmResolve).install('resolve')
39 changes: 39 additions & 0 deletions contrib/node/src/python/pants/contrib/node/targets/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Copyright 2015 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

target(
name='targets',
dependencies=[
':node_module',
':node_remote_module',
]
)

python_library(
name='node_module',
sources=['node_module.py'],
dependencies=[
':npm_package',
'src/python/pants/base:payload',
],
)

python_library(
name='node_remote_module',
sources=['node_remote_module.py'],
dependencies=[
':npm_package',
'src/python/pants/base:payload',
'src/python/pants/base:payload_field',
],
)

python_library(
name='npm_package',
sources=['npm_package.py'],
dependencies=[
'src/python/pants/base:payload',
'src/python/pants/base:payload_field',
'src/python/pants/base:target',
]
)
Empty file.
41 changes: 41 additions & 0 deletions contrib/node/src/python/pants/contrib/node/targets/node_module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# coding=utf-8
# Copyright 2015 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import (absolute_import, division, generators, nested_scopes, print_function,
unicode_literals, with_statement)

from pants.base.payload import Payload

from pants.contrib.node.targets.npm_package import NpmPackage


class NodeModule(NpmPackage):
"""Represents a Node module."""

def __init__(self,
sources=None,
sources_rel_path=None,
address=None,
payload=None,
**kwargs):
"""
:param sources: Javascript and other source code files that make up this module; paths are
relative to the BUILD file's directory.
:type sources: `globs` , `rglobs` or a list of strings
"""
# TODO(John Sirois): Support devDependencies, etc. The devDependencies case is not
# clear-cut since pants controlled builds would provide devDependencies as needed to perform
# tasks. The reality is likely to be though that both pants will never cover all cases, and a
# back door to execute new tools during development will be desirable and supporting conversion
# of pre-existing package.json files as node_module targets will require this.

if sources_rel_path is None:
sources_rel_path = address.spec_path
payload = payload or Payload()
payload.add_fields({
'sources': self.create_sources_field(sources=sources,
sources_rel_path=sources_rel_path,
key_arg='sources'),
})
super(NodeModule, self).__init__(address=address, payload=payload, **kwargs)
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# coding=utf-8
# Copyright 2015 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import (absolute_import, division, generators, nested_scopes, print_function,
unicode_literals, with_statement)

from pants.base.payload import Payload
from pants.base.payload_field import PrimitiveField

from pants.contrib.node.targets.npm_package import NpmPackage


class NodeRemoteModule(NpmPackage):
"""Represents a remote Node module."""

def __init__(self, version=None, address=None, payload=None, **kwargs):
"""
:param string version: The version constraint for the remote node module. Any of the forms
accepted by npm including '' or '*' for unconstrained (the default) are
acceptable. See: https://docs.npmjs.com/files/package.json#dependencies
"""
payload = payload or Payload()
payload.add_fields({
'version': PrimitiveField(version or '*'), # Guard against/allow `None`.
})
super(NodeRemoteModule, self).__init__(address=address, payload=payload, **kwargs)

@property
def version(self):
"""The version constraint of the remote package.
:rtype: string
"""
return self.payload.version
33 changes: 33 additions & 0 deletions contrib/node/src/python/pants/contrib/node/targets/npm_package.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# coding=utf-8
# Copyright 2015 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import (absolute_import, division, generators, nested_scopes, print_function,
unicode_literals, with_statement)

from pants.base.payload import Payload
from pants.base.payload_field import PrimitiveField
from pants.base.target import Target


class NpmPackage(Target):
"""Represents an NPM package."""

def __init__(self, package_name=None, address=None, payload=None, **kwargs):
"""
:param string package_name: The remote module package name, if not supplied the target name is
used.
"""
payload = payload or Payload()
payload.add_fields({
'package_name': PrimitiveField(package_name or address.target_name),
})
super(NpmPackage, self).__init__(address=address, payload=payload, **kwargs)

@property
def package_name(self):
"""The name of the remote module package.
:rtype: string
"""
return self.payload.package_name
35 changes: 35 additions & 0 deletions contrib/node/src/python/pants/contrib/node/tasks/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copyright 2015 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

target(
name='tasks',
dependencies=[
':npm_resolve',
]
)

python_library(
name='node_task',
sources=['node_task.py'],
dependencies=[
'contrib/node/src/python/pants/contrib/node/subsystems:node_distribution',
'contrib/node/src/python/pants/contrib/node/targets:node_module',
'contrib/node/src/python/pants/contrib/node/targets:node_remote_module',
'src/python/pants/backend/core/tasks:task',
'src/python/pants/base:workunit',
'src/python/pants/util:memo',
]
)

python_library(
name='npm_resolve',
sources=['npm_resolve.py'],
dependencies=[
':node_task',
'src/python/pants/base:build_environment',
'src/python/pants/base:exceptions',
'src/python/pants/base:workunit',
'src/python/pants/util:contextutil',
'src/python/pants/util:dirutil',
]
)
Empty file.
83 changes: 83 additions & 0 deletions contrib/node/src/python/pants/contrib/node/tasks/node_task.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# coding=utf-8
# Copyright 2015 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import (absolute_import, division, generators, nested_scopes, print_function,
unicode_literals, with_statement)

from pants.backend.core.tasks.task import Task
from pants.base.workunit import WorkUnit, WorkUnitLabel
from pants.util.memo import memoized_property

from pants.contrib.node.subsystems.node_distribution import NodeDistribution
from pants.contrib.node.targets.node_module import NodeModule
from pants.contrib.node.targets.node_remote_module import NodeRemoteModule


class NodeTask(Task):

@classmethod
def subsystem_dependencies(cls):
return (NodeDistribution.Factory,)

@memoized_property
def node_distribution(self):
"""A bootstrapped node distribution for use by node tasks."""
return NodeDistribution.Factory.global_instance().create()

@classmethod
def is_node_module(cls, target):
"""Returns `True` if the given target is a `NodeModule`."""
return isinstance(target, NodeModule)

@classmethod
def is_node_remote_module(cls, target):
"""Returns `True` if the given target is a `NodeRemoteModule`."""
return isinstance(target, NodeRemoteModule)

def execute_node(self, args, workunit_name=None, workunit_labels=None, **kwargs):
"""Executes node passing the given args.
:param list args: The command line args to pass to `node`.
:param string workunit_name: A name for the execution's work unit; defaults to 'node'.
:param list workunit_labels: Any extra :class:`pants.base.workunit.WorkUnitLabel`s to apply.
:param **kwargs: Any extra args to pass to :class:`subprocess.Popen`.
:returns: A tuple of (returncode, command).
:rtype: A tuple of (int,
:class:`pants.contrib.node.subsystems.node_distribution.NodeDistribution.Command`)
"""
npm_command = self.node_distribution.node_command(args=args)
return self._execute_command(npm_command,
workunit_name=workunit_name,
workunit_labels=workunit_labels,
**kwargs)

def execute_npm(self, args, workunit_name=None, workunit_labels=None, **kwargs):
"""Executes npm passing the given args.
:param list args: The command line args to pass to `npm`.
:param string workunit_name: A name for the execution's work unit; defaults to 'npm'.
:param list workunit_labels: Any extra :class:`pants.base.workunit.WorkUnitLabel`s to apply.
:param **kwargs: Any extra args to pass to :class:`subprocess.Popen`.
:returns: A tuple of (returncode, command).
:rtype: A tuple of (int,
:class:`pants.contrib.node.subsystems.node_distribution.NodeDistribution.Command`)
"""

npm_command = self.node_distribution.npm_command(args=args)
return self._execute_command(npm_command,
workunit_name=workunit_name,
workunit_labels=workunit_labels,
**kwargs)

def _execute_command(self, command, workunit_name=None, workunit_labels=None, **kwargs):
workunit_name = workunit_name or command.executable
workunit_labels = {WorkUnitLabel.TOOL} | set(workunit_labels or ())
with self.context.new_workunit(name=workunit_name,
labels=workunit_labels,
cmd=str(command)) as workunit:
process = command.run(stdout=workunit.output('stdout'), stderr=workunit.output('stderr'),
**kwargs)
returncode = process.wait()
workunit.set_outcome(WorkUnit.SUCCESS if returncode == 0 else WorkUnit.FAILURE)
return returncode, command
Loading

0 comments on commit d0a701d

Please sign in to comment.