Commit bf2d515a by Sébastien Eustace Committed by GitHub

Improve git dependencies support (#1549)

* Improve the InstalledRepository class

* Improve support for git dependencies
parent b2189691
......@@ -2,7 +2,8 @@
# Packages
*.egg
*.egg-info
/*.egg-info
/tests/fixtures/**/*.egg-info
/dist/*
build
_build
......
......@@ -20,5 +20,7 @@ repos:
rev: v2.3.0
hooks:
- id: trailing-whitespace
exclude: ^tests/.*/fixtures/.*
- id: end-of-file-fixer
exclude: ^tests/.*/fixtures/.*
- id: debug-statements
......@@ -208,8 +208,8 @@ If you need to checkout a specific branch, tag or revision,
you can specify it when using `add`:
```bash
poetry add git+https://github.com/sdispater/pendulum.git@develop
poetry add git+https://github.com/sdispater/pendulum.git@2.0.5
poetry add git+https://github.com/sdispater/pendulum.git#develop
poetry add git+https://github.com/sdispater/pendulum.git#2.0.5
```
or make them point to a local directory or file:
......
......@@ -1255,7 +1255,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
testing = ["pathlib2", "contextlib2", "unittest2"]
[metadata]
content-hash = "0a019fe9f27e3e3fc226c506b4c9de219d7b9d021e998ce88563f7b9c945aa35"
content-hash = "a4df5ba98a54bb2d869cba83dd16d970a5da429185a72ca1dd837d5e5c7a4335"
python-versions = "~2.7 || ^3.4"
[metadata.files]
......
......@@ -147,8 +147,8 @@ The <c1>init</c1> command creates a basic <comment>pyproject.toml</> file in the
"You can specify a package in the following forms:\n"
" - A single name (<b>requests</b>)\n"
" - A name and a constraint (<b>requests ^2.23.0</b>)\n"
" - A git url (<b>https://github.com/sdispater/poetry.git</b>)\n"
" - A git url with a revision (<b>https://github.com/sdispater/poetry.git@develop</b>)\n"
" - A git url (<b>git+https://github.com/sdispater/poetry.git</b>)\n"
" - A git url with a revision (<b>git+https://github.com/sdispater/poetry.git#develop</b>)\n"
" - A file path (<b>../my-package/my-package.whl</b>)\n"
" - A directory (<b>../my-package/</b>)\n"
" - An url (<b>https://example.com/packages/my-package-0.1.0.tar.gz</b>)\n"
......@@ -365,22 +365,21 @@ The <c1>init</c1> command creates a basic <comment>pyproject.toml</> file in the
if url_parsed.scheme and url_parsed.netloc:
# Url
if url_parsed.scheme in ["git+https", "git+ssh"]:
url = requirement.lstrip("git+")
rev = None
if "@" in url:
url, rev = url.split("@")
from poetry.vcs.git import Git
from poetry.vcs.git import ParsedUrl
pair = OrderedDict(
[("name", url.split("/")[-1].rstrip(".git")), ("git", url)]
)
if rev:
pair["rev"] = rev
parsed = ParsedUrl.parse(requirement)
url = Git.normalize_url(requirement)
pair = OrderedDict([("name", parsed.name), ("git", url.url)])
if parsed.rev:
pair["rev"] = url.revision
if extras:
pair["extras"] = extras
package = Provider.get_package_from_vcs(
"git", url, reference=pair.get("rev")
"git", url.url, reference=pair.get("rev")
)
pair["name"] = package.name
result.append(pair)
......
......@@ -56,12 +56,17 @@ class Solver:
installed = True
if pkg.source_type == "git" and package.source_type == "git":
from poetry.vcs.git import Git
# Trying to find the currently installed version
pkg_source_url = Git.normalize_url(pkg.source_url)
package_source_url = Git.normalize_url(package.source_url)
for locked in self._locked.packages:
locked_source_url = Git.normalize_url(locked.source_url)
if (
locked.name == pkg.name
and locked.source_type == pkg.source_type
and locked.source_url == pkg.source_url
and locked_source_url == pkg_source_url
and locked.source_reference == pkg.source_reference
):
pkg = Package(pkg.name, locked.version)
......@@ -70,7 +75,7 @@ class Solver:
pkg.source_reference = locked.source_reference
break
if pkg.source_url != package.source_url or (
if pkg_source_url != package_source_url or (
pkg.source_reference != package.source_reference
and not pkg.source_reference.startswith(
package.source_reference
......@@ -84,6 +89,8 @@ class Solver:
elif package.version != pkg.version:
# Checking version
operations.append(Update(pkg, package))
elif package.source_type != pkg.source_type:
operations.append(Update(pkg, package))
else:
operations.append(Install(package).skip("Already installed"))
......
import re
from importlib_metadata import distributions
from poetry.packages import Package
from poetry.utils._compat import Path
from poetry.utils.env import Env
from .repository import Repository
......@@ -16,29 +16,45 @@ class InstalledRepository(Repository):
"""
repo = cls()
freeze_output = env.run("python", "-m", "pip", "freeze")
for line in freeze_output.split("\n"):
if "==" in line:
name, version = re.split("={2,3}", line)
repo.add_package(Package(name, version, version))
elif line.startswith("-e "):
line = line[3:].strip()
if line.startswith("git+"):
url = line.lstrip("git+")
if "@" in url:
url, rev = url.rsplit("@", 1)
else:
rev = "master"
name = url.split("/")[-1].rstrip(".git")
if "#egg=" in rev:
rev, name = rev.split("#egg=")
package = Package(name, "0.0.0")
package.source_type = "git"
package.source_url = url
package.source_reference = rev
repo.add_package(package)
for distribution in sorted(
distributions(path=env.sys_path), key=lambda d: str(d._path),
):
metadata = distribution.metadata
name = metadata["name"]
version = metadata["version"]
package = Package(name, version, version)
package.description = metadata.get("summary", "")
repo.add_package(package)
path = Path(distribution._path)
is_standard_package = True
try:
path.relative_to(env.site_packages)
except ValueError:
is_standard_package = False
if is_standard_package:
continue
src_path = env.path / "src"
# A VCS dependency should have been installed
# in the src directory. If not, it's a path dependency
try:
path.relative_to(src_path)
from poetry.vcs.git import Git
git = Git()
revision = git.rev_parse("HEAD", src_path / package.name).strip()
url = git.remote_url(src_path / package.name)
package.source_type = "git"
package.source_url = url
package.source_reference = revision
except ValueError:
package.source_type = "directory"
package.source_url = str(path.parent)
return repo
......@@ -94,6 +94,13 @@ import sys
print('.'.join([str(s) for s in sys.version_info[:3]]))
"""
GET_SYS_PATH = """\
import json
import sys
print(json.dumps(sys.path))
"""
CREATE_VENV_COMMAND = """\
path = {!r}
......@@ -742,6 +749,10 @@ class Env(object):
/ "site-packages"
)
@property
def sys_path(self): # type: () -> List[str]
raise NotImplementedError()
@classmethod
def get_base_prefix(cls): # type: () -> Path
if hasattr(sys, "real_prefix"):
......@@ -865,6 +876,10 @@ class SystemEnv(Env):
A system (i.e. not a virtualenv) Python environment.
"""
@property
def sys_path(self): # type: () -> List[str]
return sys.path
def get_version_info(self): # type: () -> Tuple[int]
return sys.version_info
......@@ -931,6 +946,13 @@ class VirtualEnv(Env):
if base is None:
self._base = Path(self.run("python", "-", input_=GET_BASE_PREFIX).strip())
@property
def sys_path(self): # type: () -> List[str]
output = self.run("python", "-", input_=GET_SYS_PATH)
print(output)
return json.loads(output)
def get_version_info(self): # type: () -> Tuple[int]
output = self.run("python", "-", input_=GET_PYTHON_VERSION)
......
......@@ -2,9 +2,99 @@
import re
import subprocess
from collections import namedtuple
from poetry.utils._compat import decode
PATTERNS = [
re.compile(
r"^(git\+)?"
r"(?P<protocol>https?|git|ssh|rsync|file)://"
r"(?:(?P<user>.+)@)*"
r"(?P<resource>[a-z0-9_.-]*)"
r"(:?P<port>[\d]+)?"
r"(?P<pathname>[:/]((?P<owner>[\w\-]+)/)?"
r"((?P<name>[\w\-.]+?)(\.git|/)?)?)"
r"([@#](?P<rev>[^@#]+))?"
r"$"
),
re.compile(
r"(git\+)?"
r"((?P<protocol>\w+)://)"
r"((?P<user>\w+)@)?"
r"(?P<resource>[\w.\-]+)"
r"(:(?P<port>\d+))?"
r"(?P<pathname>(/(?P<owner>\w+)/)"
r"(/?(?P<name>[\w\-]+)(\.git|/)?)?)"
r"([@#](?P<rev>[^@#]+))?"
r"$"
),
re.compile(
r"^(?:(?P<user>.+)@)*"
r"(?P<resource>[a-z0-9_.-]*)[:]*"
r"(?P<port>[\d]+)?"
r"(?P<pathname>/?(?P<owner>.+)/(?P<name>.+).git)"
r"([@#](?P<rev>[^@#]+))?"
r"$"
),
re.compile(
r"((?P<user>\w+)@)?"
r"(?P<resource>[\w.\-]+)"
r"[:/]{1,2}"
r"(?P<pathname>((?P<owner>\w+)/)?"
r"((?P<name>[\w\-]+)(\.git|/)?)?)"
r"([@#](?P<rev>[^@#]+))?"
r"$"
),
]
class ParsedUrl:
def __init__(self, protocol, resource, pathname, user, port, name, rev):
self.protocol = protocol
self.resource = resource
self.pathname = pathname
self.user = user
self.port = port
self.name = name
self.rev = rev
@classmethod
def parse(cls, url): # type: () -> ParsedUrl
for pattern in PATTERNS:
m = pattern.match(url)
if m:
groups = m.groupdict()
return ParsedUrl(
groups.get("protocol"),
groups.get("resource"),
groups.get("pathname"),
groups.get("user"),
groups.get("port"),
groups.get("name"),
groups.get("rev"),
)
raise ValueError('Invalid git url "{}"'.format(url))
def format(self):
return "{}{}{}{}{}".format(
"{}://".format(self.protocol) if self.protocol else "",
"{}@".format(self.user) if self.user else "",
self.resource,
":{}".format(self.port) if self.port else "",
"/" + self.pathname if self.pathname.startswith(":") else self.pathname,
"#{}".format(self.rev) if self.rev else "",
)
def __str__(self): # type: () -> str
return self.format()
GitUrl = namedtuple("GitUrl", ["url", "revision"])
class GitConfig:
def __init__(self, requires_git_presence=False):
self._config = {}
......@@ -36,6 +126,30 @@ class Git:
self._config = GitConfig(requires_git_presence=True)
self._work_dir = work_dir
@classmethod
def normalize_url(cls, url): # type: (str) -> GitUrl
parsed = ParsedUrl.parse(url)
formatted = re.sub(r"^git\+", "", url)
if parsed.rev:
formatted = re.sub(r"[#@]{}$".format(parsed.rev), "", formatted)
altered = parsed.format() != formatted
if altered:
if re.match(r"^git\+https?", url) and re.match(
r"^/?:[^0-9]", parsed.pathname
):
normalized = re.sub(r"git\+(.*:[^:]+):(.*)", "\\1/\\2", url)
elif re.match(r"^git\+file", url):
normalized = re.sub(r"git\+", "", url)
else:
normalized = re.sub(r"^(?:git\+)?ssh://", "", url)
else:
normalized = parsed.format()
return GitUrl(re.sub(r"#[^#]*$", "", normalized), parsed.rev)
@property
def config(self): # type: () -> GitConfig
return self._config
......@@ -95,7 +209,33 @@ class Git:
return output.split("\n")
def run(self, *args): # type: (...) -> str
def remote_urls(self, folder=None): # type: (...) -> dict
output = self.run(
"config", "--get-regexp", r"remote\..*\.url", folder=folder
).strip()
urls = {}
for url in output.splitlines():
name, url = url.split(" ", 1)
urls[name.strip()] = url.strip()
return urls
def remote_url(self, folder=None): # type: (...) -> str
urls = self.remote_urls(folder=folder)
return urls.get("remote.origin.url", urls[list(urls.keys())[0]])
def run(self, *args, **kwargs): # type: (...) -> str
folder = kwargs.pop("folder", None)
if folder:
args = (
"--git-dir",
(folder / ".git").as_posix(),
"--work-tree",
folder.as_posix(),
) + args
return decode(
subprocess.check_output(["git"] + list(args), stderr=subprocess.STDOUT)
)
).strip()
......@@ -55,6 +55,7 @@ keyring = [
]
# Use subprocess32 for Python 2.7 and 3.4
subprocess32 = { version = "^3.5", python = "~2.7 || ~3.4" }
importlib-metadata = {version = "^0.23", python = "<=3.8"}
[tool.poetry.dev-dependencies]
pytest = "^4.1"
......
......@@ -264,6 +264,42 @@ Package operations: 4 installs, 0 updates, 0 removals
}
def test_add_git_ssh_constraint(app, repo, installer):
command = app.find("add")
tester = CommandTester(command)
repo.add_package(get_package("pendulum", "1.4.4"))
repo.add_package(get_package("cleo", "0.6.5"))
tester.execute("git+ssh://git@github.com/demo/demo.git@develop")
expected = """\
Updating dependencies
Resolving dependencies...
Writing lock file
Package operations: 2 installs, 0 updates, 0 removals
- Installing pendulum (1.4.4)
- Installing demo (0.1.2 9cf87a2)
"""
assert expected == tester.io.fetch_output()
assert len(installer.installs) == 2
content = app.poetry.file.read()["tool"]["poetry"]
assert "demo" in content["dependencies"]
assert content["dependencies"]["demo"] == {
"git": "ssh://git@github.com/demo/demo.git",
"rev": "develop",
}
def test_add_directory_constraint(app, repo, installer, mocker):
p = mocker.patch("poetry.utils._compat.Path.cwd")
p.return_value = Path(__file__) / ".."
......
......@@ -17,6 +17,7 @@ from poetry.utils._compat import PY2
from poetry.utils._compat import WINDOWS
from poetry.utils._compat import Path
from poetry.utils.toml_file import TomlFile
from poetry.vcs.git import ParsedUrl
try:
......@@ -32,14 +33,14 @@ def installer():
def mock_clone(self, source, dest):
# Checking source to determine which folder we need to copy
parts = urlparse.urlparse(source)
parsed = ParsedUrl.parse(source)
folder = (
Path(__file__).parent.parent
/ "fixtures"
/ "git"
/ parts.netloc
/ parts.path.lstrip("/").rstrip(".git")
/ parsed.resource
/ parsed.pathname.lstrip("/").rstrip(".git")
)
shutil.rmtree(str(dest))
......
Metadata-Version: 2.1
Name: cleo
Version: 0.7.6
Summary: Cleo allows you to create beautiful and testable command-line interfaces.
Home-page: https://github.com/sdispater/cleo
License: MIT
Keywords: cli,commands
Author: Sébastien Eustace
Author-email: sebastien@eustace.io
Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 2
Classifier: Programming Language :: Python :: 2.7
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.4
Classifier: Programming Language :: Python :: 3.5
Classifier: Programming Language :: Python :: 3.6
Classifier: Programming Language :: Python :: 3.7
Classifier: Programming Language :: Python :: 3.8
Requires-Dist: clikit (>=0.4.0,<0.5.0)
Description-Content-Type: text/x-rst
Cleo
####
.. image:: https://travis-ci.org/sdispater/cleo.png
:alt: Cleo Build status
:target: https://travis-ci.org/sdispater/cleo
Create beautiful and testable command-line interfaces.
Cleo is mostly a higher level wrapper for `CliKit <https://github.com/sdispater/clikit>`_, so
a lot of the components and utilities comes from it. Refer to its documentation for more
information.
Resources
=========
* `Documentation <http://cleo.readthedocs.io>`_
* `Issue Tracker <https://github.com/sdispater/cleo/issues>`_
Usage
=====
To make a command that greets you from the command line,
create ``greet_command.py`` and add the following to it:
.. code-block:: python
from cleo import Command
class GreetCommand(Command):
"""
Greets someone
greet
{name? : Who do you want to greet?}
{--y|yell : If set, the task will yell in uppercase letters}
"""
def handle(self):
name = self.argument('name')
if name:
text = 'Hello {}'.format(name)
else:
text = 'Hello'
if self.option('yell'):
text = text.upper()
self.line(text)
You also need to create the file to run at the command line which creates
an ``Application`` and adds commands to it:
.. code-block:: python
#!/usr/bin/env python
from greet_command import GreetCommand
from cleo import Application
application = Application()
application.add(GreetCommand())
if __name__ == '__main__':
application.run()
Test the new command by running the following
.. code-block:: bash
$ python application.py greet John
This will print the following to the command line:
.. code-block:: text
Hello John
You can also use the ``--yell`` option to make everything uppercase:
.. code-block:: bash
$ python application.py greet John --yell
This prints:
.. code-block:: text
HELLO JOHN
As you may have already seen, Cleo uses the command docstring to determine
the command definition.
The docstring must be in the following form :
.. code-block:: python
"""
Command description
Command signature
"""
The signature being in the following form:
.. code-block:: python
"""
command:name {argument : Argument description} {--option : Option description}
"""
The signature can span multiple lines.
.. code-block:: python
"""
command:name
{argument : Argument description}
{--option : Option description}
"""
Coloring the Output
-------------------
Whenever you output text, you can surround the text with tags to color its
output. For example:
.. code-block:: python
# green text
self.line('<info>foo</info>')
# yellow text
self.line('<comment>foo</comment>')
# black text on a cyan background
self.line('<question>foo</question>')
# white text on a red background
self.line('<error>foo</error>')
The closing tag can be replaced by ``</>``, which revokes all formatting options established by the last opened tag.
It is possible to define your own styles using the ``add_style()`` method:
.. code-block:: python
self.add_style('fire', fg='red', bg='yellow', options=['bold', 'blink'])
self.line('<fire>foo</fire>')
Available foreground and background colors are: ``black``, ``red``, ``green``,
``yellow``, ``blue``, ``magenta``, ``cyan`` and ``white``.
And available options are: ``bold``, ``underscore``, ``blink``, ``reverse`` and ``conceal``.
You can also set these colors and options inside the tag name:
.. code-block:: python
# green text
self.line('<fg=green>foo</>')
# black text on a cyan background
self.line('<fg=black;bg=cyan>foo</>')
# bold text on a yellow background
self.line('<bg=yellow;options=bold>foo</>')
Verbosity Levels
----------------
Cleo has four verbosity levels. These are defined in the ``Output`` class:
======================================= ================================== ======================
Mode Meaning Console option
======================================= ================================== ======================
``NA`` Do not output any messages ``-q`` or ``--quiet``
``clikit.VERBOSITY_NORMAL`` The default verbosity level (none)
``clikit.VERBOSITY_VERBOSE`` Increased verbosity of messages ``-v``
``clikit.VERBOSITY_VERY_VERBOSE`` Informative non essential messages ``-vv``
``clikit.VERBOSITY_DEBUG`` Debug messages ``-vvv``
======================================= ================================== ======================
It is possible to print a message in a command for only a specific verbosity
level. For example:
.. code-block:: python
if clikit.VERBOSITY_VERBOSE <= self.io.verbosity:
self.line(...)
There are also more semantic methods you can use to test for each of the
verbosity levels:
.. code-block:: python
if self.output.is_quiet():
# ...
if self.output.is_verbose():
# ...
You can also pass the verbosity flag directly to `line()`.
.. code-block:: python
self.line("", verbosity=clikit.VERBOSITY_VERBOSE)
When the quiet level is used, all output is suppressed.
Using Arguments
---------------
The most interesting part of the commands are the arguments and options that
you can make available. Arguments are the strings - separated by spaces - that
come after the command name itself. They are ordered, and can be optional
or required. For example, add an optional ``last_name`` argument to the command
and make the ``name`` argument required:
.. code-block:: python
class GreetCommand(Command):
"""
Greets someone
greet
{name : Who do you want to greet?}
{last_name? : Your last name?}
{--y|yell : If set, the task will yell in uppercase letters}
"""
You now have access to a ``last_name`` argument in your command:
.. code-block:: python
last_name = self.argument('last_name')
if last_name:
text += ' {}'.format(last_name)
The command can now be used in either of the following ways:
.. code-block:: bash
$ python application.py greet John
$ python application.py greet John Doe
It is also possible to let an argument take a list of values (imagine you want
to greet all your friends). For this it must be specified at the end of the
argument list:
.. code-block:: python
class GreetCommand(Command):
"""
Greets someone
greet
{names* : Who do you want to greet?}
{--y|yell : If set, the task will yell in uppercase letters}
"""
To use this, just specify as many names as you want:
.. code-block:: bash
$ python application.py demo:greet John Jane
You can access the ``names`` argument as a list:
.. code-block:: python
names = self.argument('names')
if names:
text += ' {}'.format(', '.join(names))
There are 3 argument variants you can use:
================================ ==================================== ===============================================================================================================
Mode Notation Value
================================ ==================================== ===============================================================================================================
``clikit.ARGUMENT_REQUIRED`` none (just write the argument name) The argument is required
``clikit.ARGUMENT_OPTIONAL`` ``argument?`` The argument is optional and therefore can be omitted
``clikit.ARGUMENT_MULTI_VALUED`` ``argument*`` The argument can contain an indefinite number of arguments and must be used at the end of the argument list
================================ ==================================== ===============================================================================================================
You can combine them like this:
.. code-block:: python
class GreetCommand(Command):
"""
Greets someone
greet
{names?* : Who do you want to greet?}
{--y|yell : If set, the task will yell in uppercase letters}
"""
If you want to set a default value, you can it like so:
.. code-block:: text
argument=default
The argument will then be considered optional.
Using Options
-------------
Unlike arguments, options are not ordered (meaning you can specify them in any
order) and are specified with two dashes (e.g. ``--yell`` - you can also
declare a one-letter shortcut that you can call with a single dash like
``-y``). Options are *always* optional, and can be setup to accept a value
(e.g. ``--dir=src``) or simply as a boolean flag without a value (e.g.
``--yell``).
.. tip::
It is also possible to make an option *optionally* accept a value (so that
``--yell`` or ``--yell=loud`` work). Options can also be configured to
accept a list of values.
For example, add a new option to the command that can be used to specify
how many times in a row the message should be printed:
.. code-block:: python
class GreetCommand(Command):
"""
Greets someone
greet
{name? : Who do you want to greet?}
{--y|yell : If set, the task will yell in uppercase letters}
{--iterations=1 : How many times should the message be printed?}
"""
Next, use this in the command to print the message multiple times:
.. code-block:: python
for _ in range(0, self.option('iterations')):
self.line(text)
Now, when you run the task, you can optionally specify a ``--iterations``
flag:
.. code-block:: bash
$ python application.py demo:greet John
$ python application.py demo:greet John --iterations=5
The first example will only print once, since ``iterations`` is empty and
defaults to ``1``. The second example will print five times.
Recall that options don't care about their order. So, either of the following
will work:
.. code-block:: bash
$ python application.py demo:greet John --iterations=5 --yell
$ python application.py demo:greet John --yell --iterations=5
There are 4 option variants you can use:
================================ =================================== ======================================================================================
Option Notation Value
================================ =================================== ======================================================================================
``clikit.OPTION_MULTI_VALUED`` ``--option=*`` This option accepts multiple values (e.g. ``--dir=/foo --dir=/bar``)
``clikit.OPTION_NO_VALUE`` ``--option`` Do not accept input for this option (e.g. ``--yell``)
``clikit.OPTION_REQUIRED_VALUE`` ``--option=`` This value is required (e.g. ``--iterations=5``), the option itself is still optional
``clikit.OPTION_OPTIONAL_VALUE`` ``--option=?`` This option may or may not have a value (e.g. ``--yell`` or ``--yell=loud``)
================================ =================================== ======================================================================================
You can combine them like this:
.. code-block:: python
class GreetCommand(Command):
"""
Greets someone
greet
{name? : Who do you want to greet?}
{--y|yell : If set, the task will yell in uppercase letters}
{--iterations=?*1 : How many times should the message be printed?}
"""
Testing Commands
----------------
Cleo provides several tools to help you test your commands. The most
useful one is the ``CommandTester`` class.
It uses a special IO class to ease testing without a real
console:
.. code-block:: python
import pytest
from cleo import Application
from cleo import CommandTester
def test_execute(self):
application = Application()
application.add(GreetCommand())
command = application.find('demo:greet')
command_tester = CommandTester(command)
command_tester.execute()
assert "..." == tester.io.fetch_output()
The ``CommandTester.io.fetch_output()`` method returns what would have been displayed
during a normal call from the console. ``CommandTester.io.fetch_error()`` is also available
to get what you have been written to the stderr.
You can test sending arguments and options to the command by passing them
as a string to the ``CommandTester.execute()`` method:
.. code-block:: python
import pytest
from cleo import Application
from cleo import CommandTester
def test_execute(self):
application = Application()
application.add(GreetCommand())
command = application.find('demo:greet')
command_tester = CommandTester(command)
command_tester.execute("John")
assert "John" in tester.io.fetch_output()
You can also test a whole console application by using the ``ApplicationTester`` class.
Calling an existing Command
---------------------------
If a command depends on another one being run before it, instead of asking the
user to remember the order of execution, you can call it directly yourself.
This is also useful if you want to create a "meta" command that just runs a
bunch of other commands.
Calling a command from another one is straightforward:
.. code-block:: python
def handle(self):
return_code = self.call('demo:greet', "John --yell")
# ...
If you want to suppress the output of the executed command,
you can use the ``call_silent()`` method instead.
Autocompletion
--------------
Cleo supports automatic (tab) completion in ``bash``, ``zsh`` and ``fish``.
To activate support for autocompletion, pass a ``complete`` keyword when initializing
your application:
.. code-block:: python
application = Application('My Application', '0.1', complete=True)
Now, register completion for your application by running one of the following in a terminal,
replacing ``[program]`` with the command you use to run your application:
.. code-block:: bash
# BASH - Ubuntu / Debian
[program] completions bash | sudo tee /etc/bash_completion.d/[program].bash-completion
# BASH - Mac OSX (with Homebrew "bash-completion")
[program] completions bash > $(brew --prefix)/etc/bash_completion.d/[program].bash-completion
# ZSH - Config file
mkdir ~/.zfunc
echo "fpath+=~/.zfunc" >> ~/.zshrc
[program] completions zsh > ~/.zfunc/_test
# FISH
[program] completions fish > ~/.config/fish/completions/[program].fish
Metadata-Version: 1.2
Name: pendulum
Version: 2.0.5
Summary: Python datetimes made easy
Home-page: https://pendulum.eustace.io
Author: Sébastien Eustace
Author-email: sebastien@eustace.io
License: UNKNOWN
Description: Pendulum
########
.. image:: https://img.shields.io/pypi/v/pendulum.svg
:target: https://pypi.python.org/pypi/pendulum
.. image:: https://img.shields.io/pypi/l/pendulum.svg
:target: https://pypi.python.org/pypi/pendulum
.. image:: https://img.shields.io/codecov/c/github/sdispater/pendulum/master.svg
:target: https://codecov.io/gh/sdispater/pendulum/branch/master
.. image:: https://travis-ci.org/sdispater/pendulum.svg
:alt: Pendulum Build status
:target: https://travis-ci.org/sdispater/pendulum
Python datetimes made easy.
Supports Python **2.7** and **3.4+**.
.. code-block:: python
>>> import pendulum
>>> now_in_paris = pendulum.now('Europe/Paris')
>>> now_in_paris
'2016-07-04T00:49:58.502116+02:00'
# Seamless timezone switching
>>> now_in_paris.in_timezone('UTC')
'2016-07-03T22:49:58.502116+00:00'
>>> tomorrow = pendulum.now().add(days=1)
>>> last_week = pendulum.now().subtract(weeks=1)
>>> past = pendulum.now().subtract(minutes=2)
>>> past.diff_for_humans()
>>> '2 minutes ago'
>>> delta = past - last_week
>>> delta.hours
23
>>> delta.in_words(locale='en')
'6 days 23 hours 58 minutes'
# Proper handling of datetime normalization
>>> pendulum.datetime(2013, 3, 31, 2, 30, tz='Europe/Paris')
'2013-03-31T03:30:00+02:00' # 2:30 does not exist (Skipped time)
# Proper handling of dst transitions
>>> just_before = pendulum.datetime(2013, 3, 31, 1, 59, 59, 999999, tz='Europe/Paris')
'2013-03-31T01:59:59.999999+01:00'
>>> just_before.add(microseconds=1)
'2013-03-31T03:00:00+02:00'
Why Pendulum?
=============
Native ``datetime`` instances are enough for basic cases but when you face more complex use-cases
they often show limitations and are not so intuitive to work with.
``Pendulum`` provides a cleaner and more easy to use API while still relying on the standard library.
So it's still ``datetime`` but better.
Unlike other datetime libraries for Python, Pendulum is a drop-in replacement
for the standard ``datetime`` class (it inherits from it), so, basically, you can replace all your ``datetime``
instances by ``DateTime`` instances in you code (exceptions exist for libraries that check
the type of the objects by using the ``type`` function like ``sqlite3`` or ``PyMySQL`` for instance).
It also removes the notion of naive datetimes: each ``Pendulum`` instance is timezone-aware
and by default in ``UTC`` for ease of use.
Pendulum also improves the standard ``timedelta`` class by providing more intuitive methods and properties.
Why not Arrow?
==============
Arrow is the most popular datetime library for Python right now, however its behavior
and API can be erratic and unpredictable. The ``get()`` method can receive pretty much anything
and it will try its best to return something while silently failing to handle some cases:
.. code-block:: python
arrow.get('2016-1-17')
# <Arrow [2016-01-01T00:00:00+00:00]>
pendulum.parse('2016-1-17')
# <Pendulum [2016-01-17T00:00:00+00:00]>
arrow.get('20160413')
# <Arrow [1970-08-22T08:06:53+00:00]>
pendulum.parse('20160413')
# <Pendulum [2016-04-13T00:00:00+00:00]>
arrow.get('2016-W07-5')
# <Arrow [2016-01-01T00:00:00+00:00]>
pendulum.parse('2016-W07-5')
# <Pendulum [2016-02-19T00:00:00+00:00]>
# Working with DST
just_before = arrow.Arrow(2013, 3, 31, 1, 59, 59, 999999, 'Europe/Paris')
just_after = just_before.replace(microseconds=1)
'2013-03-31T02:00:00+02:00'
# Should be 2013-03-31T03:00:00+02:00
(just_after.to('utc') - just_before.to('utc')).total_seconds()
-3599.999999
# Should be 1e-06
just_before = pendulum.datetime(2013, 3, 31, 1, 59, 59, 999999, 'Europe/Paris')
just_after = just_before.add(microseconds=1)
'2013-03-31T03:00:00+02:00'
(just_after.in_timezone('utc') - just_before.in_timezone('utc')).total_seconds()
1e-06
Those are a few examples showing that Arrow cannot always be trusted to have a consistent
behavior with the data you are passing to it.
Limitations
===========
Even though the ``DateTime`` class is a subclass of ``datetime`` there are some rare cases where
it can't replace the native class directly. Here is a list (non-exhaustive) of the reported cases with
a possible solution, if any:
* ``sqlite3`` will use the ``type()`` function to determine the type of the object by default. To work around it you can register a new adapter:
.. code-block:: python
from pendulum import DateTime
from sqlite3 import register_adapter
register_adapter(DateTime, lambda val: val.isoformat(' '))
* ``mysqlclient`` (former ``MySQLdb``) and ``PyMySQL`` will use the ``type()`` function to determine the type of the object by default. To work around it you can register a new adapter:
.. code-block:: python
import MySQLdb.converters
import pymysql.converters
from pendulum import DateTime
MySQLdb.converters.conversions[DateTime] = MySQLdb.converters.DateTime2literal
pymysql.converters.conversions[DateTime] = pymysql.converters.escape_datetime
* ``django`` will use the ``isoformat()`` method to store datetimes in the database. However since ``pendulum`` is always timezone aware the offset information will always be returned by ``isoformat()`` raising an error, at least for MySQL databases. To work around it you can either create your own ``DateTimeField`` or use the previous workaround for ``MySQLdb``:
.. code-block:: python
from django.db.models import DateTimeField as BaseDateTimeField
from pendulum import DateTime
class DateTimeField(BaseDateTimeField):
def value_to_string(self, obj):
val = self.value_from_object(obj)
if isinstance(value, DateTime):
return value.to_datetime_string()
return '' if val is None else val.isoformat()
Resources
=========
* `Official Website <https://pendulum.eustace.io>`_
* `Documentation <https://pendulum.eustace.io/docs/>`_
* `Issue Tracker <https://github.com/sdispater/pendulum/issues>`_
Contributing
============
Contributions are welcome, especially with localization.
Getting started
---------------
To work on the Pendulum codebase, you'll want to clone the project locally
and install the required depedendencies via `poetry <https://poetry.eustace.io>`_.
.. code-block:: bash
$ git clone git@github.com:sdispater/pendulum.git
$ poetry install
Localization
------------
If you want to help with localization, there are two different cases: the locale already exists
or not.
If the locale does not exist you will need to create it by using the ``clock`` utility:
.. code-block:: bash
./clock locale create <your-locale>
It will generate a directory in ``pendulum/locales`` named after your locale, with the following
structure:
.. code-block:: text
<your-locale>/
- custom.py
- locale.py
The ``locale.py`` file must not be modified. It contains the translations provided by
the CLDR database.
The ``custom.py`` file is the one you want to modify. It contains the data needed
by Pendulum that are not provided by the CLDR database. You can take the `en <https://github.com/sdispater/pendulum/tree/master/pendulum/locales/en/custom.py>`_
data as a reference to see which data is needed.
You should also add tests for the created or modified locale.
Platform: UNKNOWN
Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*
python-dateutil<3.0,>=2.6
pytzdata>=2018.3
[:python_version < "3.5"]
typing<4.0,>=3.6
from importlib_metadata import PathDistribution
from poetry.repositories.installed_repository import InstalledRepository
from poetry.utils._compat import Path
from poetry.utils.env import MockEnv as BaseMockEnv
FREEZE_RESULTS = """cleo==0.6.8
-e git+https://github.com/sdispater/pendulum.git@bb058f6b78b2d28ef5d9a5e759cfa179a1a713d6#egg=pendulum
orator===0.9.8
"""
FIXTURES_DIR = Path(__file__).parent / "fixtures"
ENV_DIR = (FIXTURES_DIR / "installed").resolve()
SITE_PACKAGES = ENV_DIR / "lib" / "python3.7" / "site-packages"
SRC = ENV_DIR / "src"
INSTALLED_RESULTS = [
PathDistribution(SITE_PACKAGES / "cleo-0.7.6.dist-info"),
PathDistribution(SRC / "pendulum" / "pendulum.egg-info"),
]
class MockEnv(BaseMockEnv):
def run(self, bin, *args):
if bin == "python" and args[:3] == ("-m", "pip", "freeze"):
return FREEZE_RESULTS
return super(MockEnv, self).run(bin, *args)
def test_load():
repository = InstalledRepository.load(MockEnv())
assert len(repository.packages) == 3
@property
def site_packages(self): # type: () -> Path
return SITE_PACKAGES
def test_load(mocker):
mocker.patch(
"importlib_metadata.Distribution.discover", return_value=INSTALLED_RESULTS
)
mocker.patch(
"poetry.vcs.git.Git.rev_parse",
return_value="bb058f6b78b2d28ef5d9a5e759cfa179a1a713d6",
)
mocker.patch(
"poetry.vcs.git.Git.remote_urls",
side_effect=[
{"remote.origin.url": "https://github.com/sdispater/pendulum.git"},
{"remote.origin.url": "git@github.com:sdispater/pendulum.git"},
],
)
repository = InstalledRepository.load(MockEnv(path=ENV_DIR))
assert len(repository.packages) == 2
cleo = repository.packages[0]
assert cleo.name == "cleo"
assert cleo.version.text == "0.6.8"
assert cleo.version.text == "0.7.6"
assert (
cleo.description
== "Cleo allows you to create beautiful and testable command-line interfaces."
)
pendulum = repository.packages[1]
assert pendulum.name == "pendulum"
assert pendulum.version.text == "0.0.0"
assert pendulum.version.text == "2.0.5"
assert pendulum.description == "Python datetimes made easy"
assert pendulum.source_type == "git"
assert pendulum.source_url == "https://github.com/sdispater/pendulum.git"
assert pendulum.source_reference == "bb058f6b78b2d28ef5d9a5e759cfa179a1a713d6"
orator = repository.packages[2]
assert orator.name == "orator"
assert orator.version.text == "0.9.8"
import pytest
from poetry.vcs.git import Git
from poetry.vcs.git import GitUrl
@pytest.mark.parametrize(
"url, normalized",
[
(
"git+ssh://user@hostname:project.git#commit",
GitUrl("user@hostname:project.git", "commit"),
),
(
"git+http://user@hostname/project/blah.git@commit",
GitUrl("http://user@hostname/project/blah.git", "commit"),
),
(
"git+https://user@hostname/project/blah.git",
GitUrl("https://user@hostname/project/blah.git", None),
),
(
"git+https://user@hostname:project/blah.git",
GitUrl("https://user@hostname/project/blah.git", None),
),
(
"git+ssh://git@github.com:sdispater/poetry.git#v1.0.27",
GitUrl("git@github.com:sdispater/poetry.git", "v1.0.27"),
),
(
"git+ssh://git@github.com:/sdispater/poetry.git",
GitUrl("git@github.com:/sdispater/poetry.git", None),
),
("git+ssh://git@github.com:org/repo", GitUrl("git@github.com:org/repo", None),),
(
"git+ssh://git@github.com/org/repo",
GitUrl("ssh://git@github.com/org/repo", None),
),
("git+ssh://foo:22/some/path", GitUrl("ssh://foo:22/some/path", None)),
("git@github.com:org/repo", GitUrl("git@github.com:org/repo", None)),
(
"git+https://github.com/sdispater/pendulum",
GitUrl("https://github.com/sdispater/pendulum", None),
),
(
"git+https://github.com/sdispater/pendulum#7a018f2d075b03a73409e8356f9b29c9ad4ea2c5",
GitUrl(
"https://github.com/sdispater/pendulum",
"7a018f2d075b03a73409e8356f9b29c9ad4ea2c5",
),
),
(
"git+ssh://git@git.example.com:b/b.git#v1.0.0",
GitUrl("git@git.example.com:b/b.git", "v1.0.0"),
),
(
"git+ssh://git@github.com:sdispater/pendulum.git#foo/bar",
GitUrl("git@github.com:sdispater/pendulum.git", "foo/bar"),
),
("git+file:///foo/bar.git", GitUrl("file:///foo/bar.git", None)),
(
"git+file://C:\\Users\\hello\\testing.git#zkat/windows-files",
GitUrl("file://C:\\Users\\hello\\testing.git", "zkat/windows-files"),
),
],
)
def test_normalize_url(url, normalized):
assert normalized == Git.normalize_url(url)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment