Skip to content

Commit 748f1a4

Browse files
committed
Add support for data_files python-poetry#890
1 parent f4492d2 commit 748f1a4

File tree

17 files changed

+186
-8
lines changed

17 files changed

+186
-8
lines changed

docs/docs/pyproject.md

+53
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,59 @@ packages = [
133133
Poetry is clever enough to detect Python subpackages.
134134

135135
Thus, you only have to specify the directory where your root package resides.
136+
137+
## data_files
138+
139+
A list of files to be installed using the [data_files](https://docs.python.org/2/distutils/setupscript.html#installing-additional-files)
140+
installation mechanism. Data files are particularly useful when shipping non-code artifacts that may need to be used
141+
by other packages at a well-known location. Example uses include distribution of Protobuf proto definition files and
142+
Avro avsc schemas.
143+
144+
```toml
145+
[tool.poetry.data_files]
146+
my_package_name = ["a.txt", "subdir/subsubdir/b.txt"]
147+
"my_target_dir.has.dots" = ["**/*.txt"]
148+
"my_package_name/with/subdirectories" = ["subdir/subsubdir/b.txt"]
149+
```
150+
151+
### Effect on produced wheel archives
152+
153+
The above TOML snippet will result in the addition of a [.data](https://www.python.org/dev/peps/pep-0427/#the-data-directory)
154+
directory to your wheel. For example, given my package, `my_package_name`, and a version of `2.3.4`, the wheel will now
155+
contain the following:
156+
157+
```
158+
my_package_name-2.3.4.data/my_package_name/a.txt
159+
my_package_name-2.3.4.data/my_package_name/b.txt
160+
my_package_name-2.3.4.data/my_target_dir.has.dots/a.txt
161+
my_package_name-2.3.4.data/my_target_dir.has.dots/b.txt
162+
my_package_name-2.3.4.data/my_packge_name/with/subdirectories/b.txt
163+
```
164+
165+
All of the entries added to the wheel are also added to the RECORD with their appropriate secure hashes.
166+
167+
### Effect on produced sdist archives
168+
169+
The above TOML snippet will result in the addition of the following `data_files` element to the generated setup.py:
170+
171+
```python
172+
# [...]
173+
data_files = \
174+
[('my_package_name', ['a.txt', 'subdir/subsubdir/b.txt']),
175+
('my_target_dir.has.dots', ['a.txt', 'subdir/subsubdir/b.txt']),
176+
('my_package_name/with/subdirectories', ['subdir/subsubdir/b.txt'])]
177+
178+
setup_kwargs = {
179+
# [...]
180+
'data_files': data_files,
181+
}
182+
```
183+
184+
185+
!!!note
186+
187+
The path information in the files or globs is discarded during installation. If you need your files to be placed
188+
in a nested directory, it must be a part of the "name" of the `data_files` element.
136189

137190
## include and exclude
138191

poetry/masonry/builders/builder.py

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def __init__(self, poetry, env, io):
4040
self._path.as_posix(),
4141
packages=self._package.packages,
4242
includes=self._package.include,
43+
data_files=self._package.data_files,
4344
)
4445
self._meta = Metadata.from_package(self._package)
4546

poetry/masonry/builders/sdist.py

+16-2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from poetry.utils._compat import to_str
1616

1717
from ..utils.helpers import normalize_file_permissions
18+
from ..utils.data_file_include import DataFileInclude
1819
from ..utils.package_include import PackageInclude
1920

2021
from .builder import Builder
@@ -119,6 +120,7 @@ def build_setup(self): # type: () -> bytes
119120
modules = []
120121
packages = []
121122
package_data = {}
123+
data_files = {}
122124
for include in self._module.includes:
123125
if isinstance(include, PackageInclude):
124126
if include.is_package():
@@ -137,6 +139,10 @@ def build_setup(self): # type: () -> bytes
137139

138140
if module not in modules:
139141
modules.append(module)
142+
elif isinstance(include, DataFileInclude):
143+
data_files.setdefault(include.data_file_path_prefix, []).extend(
144+
str(element.relative_to(self._path)) for element in include.elements
145+
)
140146
else:
141147
pass
142148

@@ -153,8 +159,16 @@ def build_setup(self): # type: () -> bytes
153159
extra.append("'package_data': package_data,")
154160

155161
if modules:
156-
before.append("modules = \\\n{}".format(pformat(modules)))
157-
extra.append("'py_modules': modules,".format())
162+
before.append("modules = \\\n{}\n".format(pformat(modules)))
163+
extra.append("'py_modules': modules,")
164+
165+
if data_files:
166+
before.append(
167+
"data_files = \\\n{}\n".format(
168+
pformat([(k, v) for k, v in data_files.items()])
169+
)
170+
)
171+
extra.append("'data_files': data_files,")
158172

159173
dependencies, extras = self.convert_dependencies(
160174
self._package, self._package.requires

poetry/masonry/builders/wheel.py

+15-3
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@
1919
from poetry.semver import parse_constraint
2020

2121
from ..utils.helpers import normalize_file_permissions
22+
from ..utils.data_file_include import DataFileInclude
2223
from ..utils.package_include import PackageInclude
2324
from ..utils.tags import get_abbr_impl
2425
from ..utils.tags import get_abi_tag
2526
from ..utils.tags import get_impl_ver
2627
from ..utils.tags import get_platform
2728
from .builder import Builder
2829

30+
from poetry.utils._compat import Path
2931

3032
wheel_file_template = """\
3133
Wheel-Version: 1.0
@@ -137,6 +139,14 @@ def _copy_module(self, wheel):
137139

138140
if isinstance(include, PackageInclude) and include.source:
139141
rel_file = file.relative_to(include.base)
142+
elif isinstance(include, DataFileInclude):
143+
rel_file = Path(
144+
self.wheel_meta_dir_name(
145+
self._package.name, self._meta.version, "data"
146+
),
147+
include.data_file_path_prefix,
148+
file.name,
149+
)
140150
else:
141151
rel_file = file.relative_to(self._path)
142152

@@ -192,7 +202,7 @@ def find_excluded_files(self): # type: () -> Set
192202

193203
@property
194204
def dist_info(self): # type: () -> str
195-
return self.dist_info_name(self._package.name, self._meta.version)
205+
return self.wheel_meta_dir_name(self._package.name, self._meta.version)
196206

197207
@property
198208
def wheel_filename(self): # type: () -> str
@@ -207,11 +217,13 @@ def supports_python2(self):
207217
parse_constraint(">=2.0.0 <3.0.0")
208218
)
209219

210-
def dist_info_name(self, distribution, version): # type: (...) -> str
220+
def wheel_meta_dir_name(
221+
self, distribution, version, suffix="dist-info"
222+
): # type: (...) -> str
211223
escaped_name = re.sub(r"[^\w\d.]+", "_", distribution, flags=re.UNICODE)
212224
escaped_version = re.sub(r"[^\w\d.]+", "_", version, flags=re.UNICODE)
213225

214-
return "{}-{}.dist-info".format(escaped_name, escaped_version)
226+
return "{}-{}.{}".format(escaped_name, escaped_version, suffix)
215227

216228
@property
217229
def tag(self):
+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from .include import Include
2+
3+
# noinspection PyProtectedMember
4+
from poetry.utils._compat import Path
5+
6+
7+
class DataFileInclude(Include):
8+
def __init__(
9+
self, base, include, data_file_path_prefix
10+
): # type: (Path, str, str) -> None
11+
super(DataFileInclude, self).__init__(base, include)
12+
self._data_file_path_prefix = data_file_path_prefix
13+
14+
@property
15+
def data_file_path_prefix(self): # type: () -> str
16+
return self._data_file_path_prefix

poetry/masonry/utils/include.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def __init__(self, base, include): # type: (Path, str) -> None
2121
self._base = base
2222
self._include = str(include)
2323

24-
self._elements = sorted(list(self._base.glob(str(self._include))))
24+
self._elements = sorted(list(self._base.glob(self._include)))
2525

2626
@property
2727
def base(self): # type: () -> Path

poetry/masonry/utils/module.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from poetry.utils.helpers import module_name
33

44
from .include import Include
5+
from .data_file_include import DataFileInclude
56
from .package_include import PackageInclude
67

78

@@ -11,14 +12,17 @@ class ModuleOrPackageNotFound(ValueError):
1112

1213

1314
class Module:
14-
def __init__(self, name, directory=".", packages=None, includes=None):
15+
def __init__(
16+
self, name, directory=".", packages=None, includes=None, data_files=None
17+
):
1518
self._name = module_name(name)
1619
self._in_src = False
1720
self._is_package = False
1821
self._path = Path(directory)
1922
self._includes = []
2023
packages = packages or []
2124
includes = includes or []
25+
data_files = data_files or {}
2226

2327
if not packages:
2428
# It must exist either as a .py file or a directory, but not both
@@ -62,6 +66,10 @@ def __init__(self, name, directory=".", packages=None, includes=None):
6266
PackageInclude(self._path, package["include"], package.get("from"))
6367
)
6468

69+
for base, globs in data_files.items():
70+
for glob in globs:
71+
self._includes.append(DataFileInclude(self._path, glob, base))
72+
6573
for include in includes:
6674
self._includes.append(Include(self._path, include))
6775

poetry/masonry/utils/package_include.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def source(self): # type: () -> str
2626
def is_package(self): # type: () -> bool
2727
return self._is_package
2828

29-
def is_module(self): # type: ()
29+
def is_module(self): # type: () -> bool
3030
return self._is_module
3131

3232
def refresh(self): # type: () -> PackageInclude

poetry/packages/project_package.py

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def __init__(self, name, version, pretty_version=None):
1414
self.packages = []
1515
self.include = []
1616
self.exclude = []
17+
self.data_files = {}
1718

1819
if self._python_versions == "*":
1920
self._python_constraint = parse_constraint("~2.7 || >=3.4")

poetry/poetry.py

+3
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,9 @@ def create(cls, cwd): # type: (Path) -> Poetry
184184
if "packages" in local_config:
185185
package.packages = local_config["packages"]
186186

187+
if "data_files" in local_config:
188+
package.data_files = local_config["data_files"]
189+
187190
# Moving lock if necessary (pyproject.lock -> poetry.lock)
188191
lock = poetry_file.parent / "poetry.lock"
189192
if not lock.exists():

tests/masonry/builders/fixtures/data_files/a.txt

Whitespace-only changes.

tests/masonry/builders/fixtures/data_files/data_files_example/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[tool.poetry]
2+
name = "data_files_example"
3+
version = "0.1.0"
4+
description = "An example TOML file describing a package with data_files"
5+
authors = ["delphyne <[email protected]>"]
6+
7+
[tool.poetry.data_files]
8+
easy = ["a.txt", "**/c.csv"]
9+
"a.little.more.difficult" = ["**/*.txt"]
10+
"nested/directories" = ["subdir/subsubdir/b.txt"]

tests/masonry/builders/fixtures/data_files/subdir/subsubdir/b.txt

Whitespace-only changes.

tests/masonry/builders/fixtures/data_files/subdir/subsubdir/c.csv

Whitespace-only changes.

tests/masonry/builders/test_sdist.py

+35
Original file line numberDiff line numberDiff line change
@@ -510,3 +510,38 @@ def test_proper_python_requires_if_three_digits_precision_version_specified():
510510
parsed = p.parsestr(to_str(pkg_info))
511511

512512
assert parsed["Requires-Python"] == "==2.7.15"
513+
514+
515+
def test_with_data_files():
516+
poetry = Poetry.create(project("data_files"))
517+
builder = SdistBuilder(poetry, NullEnv(), NullIO())
518+
519+
# Check setup.py
520+
setup = builder.build_setup()
521+
setup_ast = ast.parse(setup)
522+
523+
setup_ast.body = [n for n in setup_ast.body if isinstance(n, ast.Assign)]
524+
ns = {}
525+
exec(compile(setup_ast, filename="setup.py", mode="exec"), ns)
526+
527+
assert [
528+
("easy", ["a.txt", "subdir/subsubdir/c.csv"]),
529+
("a.little.more.difficult", ["a.txt", "subdir/subsubdir/b.txt"]),
530+
("nested/directories", ["subdir/subsubdir/b.txt"]),
531+
] == ns.get("data_files")
532+
533+
assert ns.get("setup_kwargs", {}).get("data_files") == ns.get("data_files")
534+
535+
builder.build()
536+
537+
sdist = fixtures_dir / "data_files" / "dist" / "data_files_example-0.1.0.tar.gz"
538+
539+
assert sdist.exists()
540+
541+
with tarfile.open(str(sdist), "r") as tar:
542+
names = tar.getnames()
543+
assert "data_files_example-0.1.0/data_files_example/__init__.py" in names
544+
assert "data_files_example-0.1.0/a.txt" in names
545+
assert "data_files_example-0.1.0/subdir/subsubdir/b.txt" in names
546+
assert "data_files_example-0.1.0/subdir/subsubdir/c.csv" in names
547+
assert "data_files_example-0.1.0/setup.py" in names

tests/masonry/builders/test_wheel.py

+25
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,28 @@ def test_write_metadata_file_license_homepage_default(mocker):
145145
# Assertion
146146
mocked_file.write.assert_any_call("Home-page: UNKNOWN\n")
147147
mocked_file.write.assert_any_call("License: UNKNOWN\n")
148+
149+
150+
def test_with_data_files():
151+
module_path = fixtures_dir / "data_files"
152+
p = Poetry.create(str(module_path))
153+
WheelBuilder.make(p, NullEnv(), NullIO())
154+
whl = module_path / "dist" / "data_files_example-0.1.0-py2.py3-none-any.whl"
155+
assert whl.exists()
156+
157+
with zipfile.ZipFile(str(whl)) as z:
158+
names = z.namelist()
159+
with z.open("data_files_example-0.1.0.dist-info/RECORD") as record_file:
160+
record = record_file.readlines()
161+
162+
def validate(path):
163+
assert path in names
164+
assert (
165+
len([r for r in record if r.decode("utf-8").startswith(path)]) == 1
166+
)
167+
168+
validate("data_files_example-0.1.0.data/easy/a.txt")
169+
validate("data_files_example-0.1.0.data/easy/c.csv")
170+
validate("data_files_example-0.1.0.data/a.little.more.difficult/a.txt")
171+
validate("data_files_example-0.1.0.data/a.little.more.difficult/b.txt")
172+
validate("data_files_example-0.1.0.data/nested/directories/b.txt")

0 commit comments

Comments
 (0)