Repository: pantsbuild/pants

Issue:
Add a `urls` field to `files` and `resources` targets
**Is your feature request related to a problem? Please describe.**

In order to fetch a URL as part of a build, you would currently need to use an `experimental_shell_command`. But without additionally encoding a `shasum` check, this is a potential hermeticity problem in your build (as well as being more verbose than necessary).

**Describe the solution you'd like**

Native support for fetching URLs as inputs to a build. In order to allow marking the files as either loose [`files`](https://www.pantsbuild.org/docs/reference-files), or application-level [`resources`](https://www.pantsbuild.org/docs/reference-resources) ([more info on the distinction](https://www.pantsbuild.org/docs/resources)), this should probably be implemented as a `urls` argument/field on `resources` and `files` targets. 

**Describe alternatives you've considered**

Using `experimental_shell_command` and a `shasum` check.



Discussion:
An alternate implementation might look like having an explicit `urls` target type, which might be effectively equivalent to a `files` target (i.e., would create loose files, rather than `resources`). Then you would either 1) have a separate `resource_urls` target type, or 2) add a mechanism whereby depending on `files`/`urls` from a `resources` target would "convert" them into [`resources`](https://www.pantsbuild.org/docs/reference-resources).

----

But, assuming the API in the description, resolving this might involve:
1. Adding a new `UrlsField` type, and using it on [`FilesGeneratorTarget`](https://github.com/pantsbuild/pants/blob/a655638b1c22155e5f028fcd40af151ad0a5e525/src/python/pants/core/target_types.py#L91-L99) and [`ResourcesGeneratorTarget`](https://github.com/pantsbuild/pants/blob/a655638b1c22155e5f028fcd40af151ad0a5e525/src/python/pants/core/target_types.py#L301-L309)
2. adjusting the [target generator rules](https://github.com/pantsbuild/pants/blob/a655638b1c22155e5f028fcd40af151ad0a5e525/src/python/pants/core/target_types.py#L316-L321) for those types to create a new target type per URL, in addition to the per-source glob target
3. Adding [codegen rules](https://www.pantsbuild.org/docs/plugins-codegen) on the new target type added in step 2 that will download the URL using [`DownloadFile`](https://www.pantsbuild.org/docs/rules-api-file-system#downloadfile)
I think I like the first API of using a `url` field - I'm semi concerned about a proliferation of target types and I don't like having `file_url` vs `resource_url`. 

It looks like URLs cannot have `|` in them? https://stackoverflow.com/questions/5649730/what-characters-can-one-use-in-a-url If so, we can implement the checksum like this to keep the surface area small:

```
resource(name="download", url="https://pantsbuild.org/127|foo|abcjfaja")
```

Where it's `url|num_bytes|sha256`.

--

Note that I recommend putting this `url` field on the `resource` and `file` target. We would change `source` to no longer be required, but you must exactly one of `source` or `url`. 

`files` and `resources` would have a `urls: list[str]` field which generates one target per entry.

--

I was thinking about how you might want more complex download logic, like where you download X on macOS and Y on Linux. (E.g. our `ExternalTool` code). I'm thinking we should probably rely on the plugin API for that...let users implement a codegen rule if they need more complex logic than "Just download this exact file".
+1 for a url field. I don't think it's a good idea to cram additional info into the url string itself though. If more data is needed, i think either have a collection of fields that you add (like the common fields, tags and description) or let the url field take a dict as value. 
Oh, a typed dict is an interesting idea. More boilerplate, but much more readable. I'd rather optimize for reading than writing.

```python
resource(
   name="download",
   url={
       "url": "https://pantsbuild.org",
       "bytes": 124,
        "checksum": "abc131",
   },
)
```

Or a middle ground is a 3-tuple:

```python
resource(
   name="download",
   url=("https://pantsbuild.org", 124, "abc131"),
)
```

I definitely like that the most, especially because it's less boilerplate when defining multiple `urls` on the `files` target generator. you wouldn't want to repeat those same keys.
Yeah, both versions look ok to me. For the dict, the field could be "download" to avoid the double url key.. so the tuple is slightly better in that respect as well.
I forget the reasoning on why objects are a bad idea. Is it solely because we never hooked up docs / help for them?
So with dedicated fields, we get the validation already done, where as with a object structure there would be custom validation.. +1 for dedicated fields, with all ft that comes with that. Only thing "missing" would be that some fields only make sense when used together, so should warn/err when used wrong. 
Hi, I'd like to make a PR for this, but my initial work is starting to feel like a bad approach. I see [Eric's concern](https://github.com/pantsbuild/pants/issues/13589#issuecomment-966596419) about "proliferation of targets" which makes sense, but this really feels like it should be a separate target to keep the semantics clear:

```
download_file(
  name:str, 
  description:Optional[str], 
  url:str, 
  expected_digest:str, 
  expected_size:Optional[str], 
  output_path:str)
```

That will then materialize the file to be used afterwards in `file(s)`  or `resource(s)` targets after the fact. Overloading file and resource with optional URL/digest/etc options feels like we'd be skipping all the nice typing/auto-validation and adding mutually exclusive params. 
I like this approach with a dedicated target for the download.

Perhaps it would make sense even to be able to select a specific set of files from an archive, in case the url points at a zip/tar archive and you need to consume what's inside that archive.

To me, the "decompress / select / rearrange" seems like yet another separate target, because there are other times you'd have an archive you need to pull files out of and rearrange. However, I'm brand new to pants so my instinct could be wrong
A nice thing about this idea is that the downloaded content of one or more `download_file` targets could be used as the sources for *any* target, not just files/resources.

For example, I could imagine wanting to compile .scala files that live at some URL, instead of in the repo, for some reason. 
> To me, the "decompress / select / rearrange" seems like yet another separate target, because there are other times you'd have an archive you need to pull files out of and rearrange.

Fair point. I feel however, that if the `url` field supports also `file://` style urls, the `download` part may not have to be so much downloading in that case, avoiding yet another target type.
> A nice thing about this idea is that the downloaded content of one or more `download_file` targets could be used as the sources for _any_ target, not just files/resources.

Maybe eventually, but not trivially... a target like `scala_sources` is not expecting to find its input sources via its `dependencies` field.

No, I think `download_file` would not be a target but an object that `sources=` can consume.
> No, I think `download_file` would not be a target but an object that `sources=` can consume.

Maybe, but that doesn't allow for:

> To me, the "decompress / select / rearrange" seems like yet another separate target, because there are other times you'd have an archive you need to pull files out of and rearrange.
It does if that thing is also not a target but an object. 

I think having full separation between "where a file comes from" and "how the file is used" can be handy.

> I forget the reasoning on why objects are a bad idea. Is it solely because we never hooked up docs / help for them?

Yeah, in thinking more about it I think objects are fine. Context-aware object factories == bad because of breaking the engine, but those are separate.

--

So something like this?

```python
file(
  name="foo",
  source=url_source(
     url="https://pantsbuild.org",
     bytes=123,
     checksum="abc",
  ),
)
```

If the field is `sources: list[str]` rather than `source: str`, allow multiple `url_source` objects?

--

One thing I'm wondering about is how you'd handle platform-specific files, like if you want a different file on macOS vs Linux? Maybe that's not a concern, or you work around by downloading both via two distinct targets?
> So something like this?

Yes. Simply put, instead of restricting field values to Python primitives, allow them to be (immutable) python objects. If you care about "boilerplate", then the primary difference would be to not force kwargs but allow positional.
> instead of restricting field values to Python primitives

Or `url_source` could inherit from `str` so the sources field needn't be aware of the difference..
I'm trying to think of the pros/cons of making this a target/object.

A Target implementation (technically a target generator) would be very simple (which likely says something about this), and would allow discoverability (we don't document objects in the "reference" section). On the downside we'd maybe want both `http_file` and `http_resource`?

An object implementation is harder to implement (it fuzzes the normal `XSourceField` code), and wouldn't be reference-documented. However, once it is implemented we can re-use it for both `files` and `resources`.

I lean towards target, but what do y'all think?
> On the downside we'd maybe want both http_file and http_resource?

That's my concern. Proliferation of targets. It's already confusing `files` vs `resources`, and now we have even more targets that are assets!

> and wouldn't be reference-documented. 

See my proposal at https://github.com/pantsbuild/pants/issues/14832. That is a short-term thing until I have the time to implement that proposal, which oh how I want to do.

I think my vote is `object`.

```
file(
  name="foo",
  source=url_source(
     url="https://pantsbuild.org",
     bytes=123,
     checksum="abc",
  ),
)
```

> An object implementation is harder to implement (it fuzzes the normal XSourceField code)

Yeah...I'm not sure how to pull that off :O 

Related, what if `url_source` worked for _any_ target type with a `source: str` field? You could use it with `python_source`?

> Related, what if url_source worked for any target type with a source: str field? You could use it with python_source?

I had that thought as well, but thought better of it. It kind certainly allows for some very interesting cases, but also opens some questionable ones as well. What is the behavior of `fmt` or `lint` on a `python-source` which comes from a URL? Maybe it doesn't matter, but it's something that needs deciding.

Let's get it working for assets, then we can revisit.
> but also opens some questionable ones as well. What is the behavior of fmt or lint on a python-source which comes from a URL? 

I had the same thought. My thinking was more: this is gonna be hard to get to play nicely with the `SingleSourceField`. Idea: make it generic in `engine/target.py` etc rather than trying to hodge-podge something in `core/target_types.py`.

It still might make sense to implement in a generic way, but use a classvar `enable_url_source: bool` w/ default to `False`.
@thejcannon were discussing more over DM how this might be implemented all on the `source: str` field. We concluded that it would be really difficult to do—we have heavily baked in assumptions that `source: str` is a concrete path to a file. Those assumptions are good and a big win from the "atom target" split in Pants 2.8.

Instead, @kaos's work with `docker_image` is inspiration. It has a `source: str | None` field, along with `instructions` as a way to synthesize the `source` via codegen. We would do the same thing here with `url: HttpSource`. We avoid target proliferation and avoid super confusing implementation.

Resolving patch:
diff --git a/src/python/pants/core/register.py b/src/python/pants/core/register.py
--- a/src/python/pants/core/register.py
+++ b/src/python/pants/core/register.py
@@ -26,6 +26,7 @@
     FilesGeneratorTarget,
     FileTarget,
     GenericTarget,
+    HTTPSource,
     RelocatedFiles,
     ResourcesGeneratorTarget,
     ResourceTarget,
@@ -94,5 +95,8 @@ def target_types():
 
 def build_file_aliases():
     return BuildFileAliases(
-        objects={"parametrize": Parametrize},
+        objects={
+            "http_source": HTTPSource,
+            "parametrize": Parametrize,
+        },
     )
diff --git a/src/python/pants/core/target_types.py b/src/python/pants/core/target_types.py
--- a/src/python/pants/core/target_types.py
+++ b/src/python/pants/core/target_types.py
@@ -2,9 +2,14 @@
 # Licensed under the Apache License, Version 2.0 (see LICENSE).
 from __future__ import annotations
 
+import builtins
+import dataclasses
+import os
+import urllib.parse
 from collections import defaultdict
 from dataclasses import dataclass
 from pathlib import PurePath
+from typing import Optional, Sequence, Union, cast
 
 from pants.core.goals.package import (
     BuiltPackage,
@@ -13,9 +18,19 @@
     PackageFieldSet,
 )
 from pants.core.util_rules.archive import ArchiveFormat, CreateArchive
-from pants.engine.addresses import UnparsedAddressInputs
-from pants.engine.fs import AddPrefix, Digest, MergeDigests, RemovePrefix, Snapshot
-from pants.engine.rules import Get, MultiGet, collect_rules, rule
+from pants.engine.addresses import Address, UnparsedAddressInputs
+from pants.engine.fs import (
+    AddPrefix,
+    CreateDigest,
+    Digest,
+    DownloadFile,
+    FileEntry,
+    MergeDigests,
+    RemovePrefix,
+    Snapshot,
+)
+from pants.engine.internals.native_engine import FileDigest
+from pants.engine.rules import Get, MultiGet, collect_rules, rule, rule_helper
 from pants.engine.target import (
     COMMON_TARGET_FIELDS,
     AllTargets,
@@ -27,6 +42,7 @@
     GenerateSourcesRequest,
     HydratedSources,
     HydrateSourcesRequest,
+    InvalidFieldTypeException,
     MultipleSourcesField,
     OverridesField,
     SingleSourceField,
@@ -42,14 +58,137 @@
 from pants.util.docutil import bin_name
 from pants.util.frozendict import FrozenDict
 from pants.util.logging import LogLevel
+from pants.util.meta import frozen_after_init
 from pants.util.strutil import softwrap
 
 # -----------------------------------------------------------------------------------------------
-# `file` and`files` targets
+# Asset target helpers
 # -----------------------------------------------------------------------------------------------
 
 
-class FileSourceField(SingleSourceField):
+@dataclass(unsafe_hash=True)
+@frozen_after_init
+class HTTPSource:
+    url: str
+    len: int
+    sha256: str
+    # Defaults to last part of the URL path (E.g. `index.html`)
+    filename: str
+
+    def __init__(self, url: str, *, len: int, sha256: str, filename: str = ""):
+        for field in dataclasses.fields(self):
+            value = locals()[field.name]
+            if not isinstance(value, getattr(builtins, cast(str, field.type))):
+                raise TypeError(f"`{field.name}` must be a `{field.type}`, got `{type(value)!r}`.")
+
+        self.url = url
+        self.len = len
+        self.sha256 = sha256
+        self.filename = filename or urllib.parse.urlparse(url).path.rsplit("/", 1)[-1]
+
+        if not self.filename:
+            raise ValueError(
+                softwrap(
+                    f"""
+                    Couldn't deduce filename from `url`: '{self.url}'.
+
+                    Please specify the `filename` argument.
+                    """
+                )
+            )
+        if "\\" in self.filename or "/" in self.filename:
+            raise ValueError(
+                f"`filename` cannot contain a path separator, but was set to '{self.filename}'"
+            )
+
+
+class AssetSourceField(SingleSourceField):
+    value: str | HTTPSource  # type: ignore[assignment]
+    # @TODO: Don't document http_source, link to it once https://github.com/pantsbuild/pants/issues/14832
+    # is implemented.
+    help = softwrap(
+        """
+        The source of this target.
+
+        If a string is provided, represents a path that is relative to the BUILD file's directory,
+        e.g. `source='example.ext'`.
+
+        If an http_source is provided, represents the network location to download the source from.
+        The downloaded file will exist in the sandbox in the same directory as the target.
+        `http_source` has the following signature:
+            http_source(url: str, *, len: int, sha256: str, filename: str = "")
+        The filename defaults to the last part of the URL path (E.g. `example.ext`), but can also be
+        specified if you wish to have control over the file name. You cannot, however, specify a
+        path separator to download the file into a subdirectory (you must declare a target in desired
+        subdirectory).
+        You can easily get the len and checksum with the following command:
+            `curl -L $URL | tee >(wc -c) >(shasum -a 256) >/dev/null`
+        """
+    )
+
+    @classmethod
+    def compute_value(  # type: ignore[override]
+        cls, raw_value: Optional[Union[str, HTTPSource]], address: Address
+    ) -> Optional[Union[str, HTTPSource]]:
+        if raw_value is None or isinstance(raw_value, str):
+            return super().compute_value(raw_value, address)
+        elif not isinstance(raw_value, HTTPSource):
+            raise InvalidFieldTypeException(
+                address,
+                cls.alias,
+                raw_value,
+                expected_type="a string or an `http_source` object",
+            )
+        return raw_value
+
+    def validate_resolved_files(self, files: Sequence[str]) -> None:
+        if isinstance(self.value, str):
+            super().validate_resolved_files(files)
+
+    @property
+    def globs(self) -> tuple[str, ...]:
+        if isinstance(self.value, str):
+            return (self.value,)
+        return ()
+
+    @property
+    def file_path(self) -> str:
+        assert self.value
+        filename = self.value if isinstance(self.value, str) else self.value.filename
+        return os.path.join(self.address.spec_path, filename)
+
+
+@rule_helper
+async def _hydrate_asset_source(request: GenerateSourcesRequest) -> GeneratedSources:
+    target = request.protocol_target
+    source_field = target[AssetSourceField]
+    if isinstance(source_field.value, str):
+        return GeneratedSources(request.protocol_sources)
+
+    http_source = source_field.value
+    file_digest = FileDigest(http_source.sha256, http_source.len)
+    # NB: This just has to run, we don't actually need the result because we know the Digest's
+    # FileEntry metadata.
+    await Get(Digest, DownloadFile(http_source.url, file_digest))
+    snapshot = await Get(
+        Snapshot,
+        CreateDigest(
+            [
+                FileEntry(
+                    path=source_field.file_path,
+                    file_digest=file_digest,
+                )
+            ]
+        ),
+    )
+
+    return GeneratedSources(snapshot)
+
+
+# -----------------------------------------------------------------------------------------------
+# `file` and`files` targets
+# -----------------------------------------------------------------------------------------------
+class FileSourceField(AssetSourceField):
     uses_source_roots = False
 
 
@@ -67,6 +206,17 @@ class FileTarget(Target):
     )
 
 
+class GenerateFileSourceRequest(GenerateSourcesRequest):
+    input = FileSourceField
+    output = FileSourceField
+    exportable = False
+
+
+@rule
+async def hydrate_file_source(request: GenerateFileSourceRequest) -> GeneratedSources:
+    return cast(GeneratedSources, await _hydrate_asset_source(request))
+
+
 class FilesGeneratingSourcesField(MultipleSourcesField):
     required = True
     uses_source_roots = False
@@ -249,7 +399,7 @@ async def relocate_files(request: RelocateFilesViaCodegenRequest) -> GeneratedSo
 # -----------------------------------------------------------------------------------------------
 
 
-class ResourceSourceField(SingleSourceField):
+class ResourceSourceField(AssetSourceField):
     uses_source_roots = True
 
 
@@ -268,6 +418,17 @@ class ResourceTarget(Target):
     )
 
 
+class GenerateResourceSourceRequest(GenerateSourcesRequest):
+    input = ResourceSourceField
+    output = ResourceSourceField
+    exportable = False
+
+
+@rule
+async def hydrate_resource_source(request: GenerateResourceSourceRequest) -> GeneratedSources:
+    return cast(GeneratedSources, await _hydrate_asset_source(request))
+
+
 class ResourcesGeneratingSourcesField(MultipleSourcesField):
     required = True
 
@@ -370,10 +531,10 @@ class AllAssetTargetsByPath:
 def map_assets_by_path(
     all_asset_targets: AllAssetTargets,
 ) -> AllAssetTargetsByPath:
+
     resources_by_path: defaultdict[PurePath, set[Target]] = defaultdict(set)
     for resource_tgt in all_asset_targets.resources:
-        path = PurePath(resource_tgt[ResourceSourceField].file_path)
-        resources_by_path[path].add(resource_tgt)
+        resources_by_path[PurePath(resource_tgt[ResourceSourceField].file_path)].add(resource_tgt)
 
     files_by_path: defaultdict[PurePath, set[Target]] = defaultdict(set)
     for file_tgt in all_asset_targets.files:
@@ -543,6 +704,8 @@ async def package_archive_target(field_set: ArchiveFieldSet) -> BuiltPackage:
 def rules():
     return (
         *collect_rules(),
+        UnionRule(GenerateSourcesRequest, GenerateResourceSourceRequest),
+        UnionRule(GenerateSourcesRequest, GenerateFileSourceRequest),
         UnionRule(GenerateSourcesRequest, RelocateFilesViaCodegenRequest),
         UnionRule(PackageFieldSet, ArchiveFieldSet),
     )
diff --git a/src/python/pants/engine/target.py b/src/python/pants/engine/target.py
--- a/src/python/pants/engine/target.py
+++ b/src/python/pants/engine/target.py
@@ -1473,7 +1473,7 @@ class Example(ScalarField):
 
             @classmethod
             def compute_value(
-                cls, raw_value: Optional[MyPluginObject], *, address: Address
+                cls, raw_value: Optional[MyPluginObject], address: Address
             ) -> Optional[MyPluginObject]:
                 return super().compute_value(raw_value, address=address)
     """


Test patch:
diff --git a/src/python/pants/core/target_types_test.py b/src/python/pants/core/target_types_test.py
--- a/src/python/pants/core/target_types_test.py
+++ b/src/python/pants/core/target_types_test.py
@@ -5,14 +5,18 @@
 
 import os
 import tarfile
+import textwrap
 import zipfile
 from io import BytesIO
 from textwrap import dedent
 
+import pytest
+
 from pants.backend.python import target_types_rules as python_target_type_rules
-from pants.backend.python.goals import package_pex_binary
-from pants.backend.python.target_types import PexBinary
+from pants.backend.python.goals import package_pex_binary, run_pex_binary
+from pants.backend.python.target_types import PexBinary, PythonSourceTarget
 from pants.backend.python.util_rules import pex_from_targets
+from pants.core.goals import run
 from pants.core.goals.package import BuiltPackage
 from pants.core.target_types import (
     ArchiveFieldSet,
@@ -20,6 +24,7 @@
     FilesGeneratorTarget,
     FileSourceField,
     FileTarget,
+    HTTPSource,
     RelocatedFiles,
     RelocateFilesViaCodegenRequest,
     ResourcesGeneratorTarget,
@@ -39,7 +44,7 @@
     TransitiveTargets,
     TransitiveTargetsRequest,
 )
-from pants.testutil.rule_runner import QueryRule, RuleRunner
+from pants.testutil.rule_runner import QueryRule, RuleRunner, mock_console
 
 
 def test_relocated_files() -> None:
@@ -367,3 +372,125 @@ def gen_resource_tgt(rel_fp: str, tags: list[str] | None = None) -> ResourceTarg
         gen_resource_tgt("f2.ext"),
         gen_resource_tgt("subdir/f.ext"),
     }
+
+
+@pytest.mark.parametrize("asset_type", ("file", "resource"))
+def test_url_assets(asset_type) -> None:
+    rule_runner = RuleRunner(
+        rules=[
+            *target_type_rules(),
+            *pex_from_targets.rules(),
+            *package_pex_binary.rules(),
+            *run_pex_binary.rules(),
+            *python_target_type_rules.rules(),
+            *run.rules(),
+        ],
+        target_types=[FileTarget, ResourceTarget, PythonSourceTarget, PexBinary],
+        objects={"http_source": HTTPSource},
+    )
+    http_source_info = (
+        'url="https://raw.githubusercontent.com/python/cpython/7e46ae33bd522cf8331052c3c8835f9366599d8d/Lib/antigravity.py",'
+        "len=500,"
+        'sha256="8a5ee63e1b79ba2733e7ff4290b6eefea60e7f3a1ccb6bb519535aaf92b44967"'
+    )
+    rule_runner.write_files(
+        {
+            "assets/BUILD": dedent(
+                f"""\
+                {asset_type}(
+                    name='antigravity',
+                    source=http_source(
+                        {http_source_info},
+                    ),
+                )
+                {asset_type}(
+                    name='antigravity_renamed',
+                    source=http_source(
+                        {http_source_info},
+                        filename="antigravity_renamed.py",
+                    ),
+                )
+                """
+            ),
+            "app/app.py": textwrap.dedent(
+                """\
+                import pathlib
+
+                assets_path = pathlib.Path(__file__).parent.parent / "assets"
+                for path in assets_path.iterdir():
+                    print(path.name)
+                    assert "https://xkcd.com/353/" in path.read_text()
+                """
+            ),
+            "app/BUILD": textwrap.dedent(
+                """\
+                python_source(
+                    source="app.py",
+                    dependencies=[
+                        "assets:antigravity",
+                        "assets:antigravity_renamed",
+                    ]
+                )
+                pex_binary(name="app.py", entry_point='app.py')
+                """
+            ),
+        }
+    )
+    with mock_console(rule_runner.options_bootstrapper) as (console, stdout_reader):
+        rule_runner.run_goal_rule(
+            run.Run,
+            args=["app/app.py"],
+            env_inherit={"PATH", "PYENV_ROOT", "HOME"},
+        )
+        stdout = stdout_reader.get_stdout()
+        assert "antigravity.py" in stdout
+        assert "antigravity_renamed.py" in stdout
+
+
+@pytest.mark.parametrize(
+    "url, expected",
+    [
+        ("http://foo/bar", "bar"),
+        ("http://foo/bar.baz", "bar.baz"),
+        ("http://foo/bar.baz?query=yes/no", "bar.baz"),
+        ("http://foo/bar/baz/file.ext", "file.ext"),
+        ("www.foo.bar", "www.foo.bar"),
+        ("www.foo.bar?query=yes/no", "www.foo.bar"),
+    ],
+)
+def test_http_source_filename(url, expected):
+    assert HTTPSource(url, len=0, sha256="").filename == expected
+
+
+@pytest.mark.parametrize(
+    "kwargs, exc_match",
+    [
+        (
+            dict(url=None, len=0, sha256=""),
+            pytest.raises(TypeError, match=r"`url` must be a `str`"),
+        ),
+        (
+            dict(url="http://foo/bar", len="", sha256=""),
+            pytest.raises(TypeError, match=r"`len` must be a `int`"),
+        ),
+        (
+            dict(url="http://foo/bar", len=0, sha256=123),
+            pytest.raises(TypeError, match=r"`sha256` must be a `str`"),
+        ),
+        (
+            dict(url="http://foo/bar", len=0, sha256="", filename=10),
+            pytest.raises(TypeError, match=r"`filename` must be a `str`"),
+        ),
+        (
+            dict(url="http://foo/bar/", len=0, sha256=""),
+            pytest.raises(ValueError, match=r"Couldn't deduce filename"),
+        ),
+        (
+            dict(url="http://foo/bar/", len=0, sha256="", filename="../foobar.txt"),
+            pytest.raises(ValueError, match=r"`filename` cannot contain a path separator."),
+        ),
+    ],
+)
+def test_invalid_http_source(kwargs, exc_match):
+    with exc_match:
+        HTTPSource(**kwargs)