Repository: PrefectHQ/prefect

Issue:
`prefect register flow` cannot register modules containing relative imports
## Description
Using the prefect CLI to register flows runs into issues when using modules (as in `storage.Module`) instead of scripts (like `storage.Local`, `storage.Github`, etc.) to as flow storage. Using modules enables flows to have tasks moved to other files to encourage task reuse either privately within a project or across the whole agent for more generic tasks.

The problem with using modules this way is that `prefect register flow` currently [only expects to recieve individual files](https://github.com/PrefectHQ/prefect/blob/20cca8a7491079053725526570c5289cf905a751/src/prefect/cli/register.py#L92-L100), so if I have a module like this:

```py
## my_prefect/flows/Module-import/foo.py
from .bar import bar

from prefect import Flow
from prefect.storage.module import Module

script_storage_location = Module("my_prefect.flows.Module-import.foo")

with Flow('my-flow') as flow:
	bar()

if __name__ == '__main__':
	flow.run()
```

```py
## my_prefect/flows/Module-import/bar.py
from prefect import task

@task
def bar():
	pass
```

I can run it in either of these manners:
```
$ python -m my_prefect.flows.Module-import.foo
```
```
$ python my_prefect/flows/Module-import/foo.py
```

But this fails at registration-time because of the way Prefect reads flow files:

```
$ prefect register flow -f my_prefect/flows/Module-import/foo.py -p Module-import
```
```
Traceback (most recent call last):
  File "C:\Program Files\Python\Python38\lib\runpy.py", line 193, in _run_module_as_main
    return _run_code(code, main_globals, None,
  File "C:\Program Files\Python\Python38\lib\runpy.py", line 86, in _run_code
    exec(code, run_globals)
  File ".pyenv\Scripts\prefect.exe\__main__.py", line 9, in <module>

  -- snip --

  File ".pyenv\lib\site-packages\prefect\cli\register.py", line 94, in flow
    flow = extract_flow_from_file(file_path=file_path, flow_name=name)
  File ".pyenv\lib\site-packages\prefect\utilities\storage.py", line 86, in extract_flow_from_file
    exec(contents, exec_vals)
  File "<string>", line 1, in <module>
KeyError: "'__name__' not in globals"
```

While it makes sense to register flows by reading them as standalone files like this for store-as-script backends, this is somewhat of an issue with modules when it comes to relative imports. A (local) agent is perfectly capable of working with this sort of module once it has been registered, assuming the module is on its search path. There simply is no way to register modules that don't turn out to be fully self-contained in a single script without rolling up your own `register flow` command.

## Expected Behavior
The simple fix for this would be to have a `-m` option that uses `extract_flow_from_module` when loading the flow for interpretation instead of `extract_flow_from_file` like `-f` does.

```
$ prefect register flow -m my_prefect.flows.Module-import.foo -p Module-import
```



## Reproduction
See code example in description.

## Environment
I'm using a LocalAgent to run this module, whose working directory is a git working tree that contains the `my_prefect` module, meaning that module is in the agent's search path once it is running.

```
PrefectAgentWorkDir
├── .git
├── .pyenv
├── my_prefect
│   ├── shared
│   └── flows
│       └── Module-import
│           ├── bar.py
│           └── foo.py
├── register_module.py
└── requirements.txt
```

As with a local agent, someone working on the flows is expected to run them from the root of `PrefectAgentWorkDir` in order to have visibility on the same modules.

I know it's a far cry from the nice and clean docker deployments most people probably use, but the flows I'm working on at the moment are ported from preexisting scripts and a number of them are far too complex to neatly fit in a single file.


Discussion:
Thanks for the excellent bug report. I agree this is an issue, hopefully will get a fix out later today.
We actually want to wrap this fix into a larger set of updates to `prefect register flow`, so a fix for this probably won't be out until the next release (sometime next week).

For now you can work around this by adding a `flow.register(...)` call at the bottom of your scripts within a `if __name__ == "__main__":` block, and register as `python -m my.module.name`:

```python
# build flow up here

if __name__ == "__main__":
    flow.register(project_name="my-project")
```

Resolving patch:
diff --git a/src/prefect/cli/register.py b/src/prefect/cli/register.py
--- a/src/prefect/cli/register.py
+++ b/src/prefect/cli/register.py
@@ -1,34 +1,552 @@
 import os
+import importlib
+import multiprocessing
+import sys
+import time
+import traceback
+from collections import Counter, defaultdict
+from typing import NamedTuple, List, Dict, Iterator, Tuple
 
 import click
+from click.exceptions import ClickException
 
 import prefect
 from prefect.utilities.storage import extract_flow_from_file
+from prefect.utilities.graphql import with_args, EnumValue
+from prefect.storage import Local, Module
+from prefect.run_configs import UniversalRun
 
 
-@click.group(hidden=True)
-def register():
+class TerminalError(Exception):
+    """An error indicating the CLI should exit with a non-zero exit code"""
+
+    pass
+
+
+def log_exception(exc: Exception, indent: int = 0) -> None:
+    """Log an exception with traceback"""
+    prefix = " " * indent
+    lines = traceback.format_exception(
+        type(exc), exc, getattr(exc, "__traceback__", None)
+    )
+    click.echo("".join(prefix + l for l in lines))
+
+
+def get_module_paths(modules: List[str]) -> List[str]:
+    """Given a list of modules, return their file paths."""
+    out = []
+    for name in modules:
+        try:
+            spec = importlib.util.find_spec(name)
+        except Exception as exc:
+            click.secho(f"Error loading module {name}:", fg="red")
+            log_exception(exc, indent=2)
+            raise TerminalError
+        if spec is None:
+            raise TerminalError(f"No module named {name!r}")
+        else:
+            out.append(spec.origin)
+    return out
+
+
+def expand_paths(paths: List[str]) -> List[str]:
+    """Given a list of paths, expand any directories to find all contained
+    python files."""
+    out = []
+    for path in paths:
+        if not os.path.exists(path):
+            raise TerminalError(f"Path {path!r} doesn't exist")
+        elif os.path.isdir(path):
+            with os.scandir(path) as directory:
+                out.extend(
+                    e.path for e in directory if e.is_file() and e.path.endswith(".py")
+                )
+        else:
+            out.append(path)
+    return out
+
+
+def load_flows_from_path(path: str) -> "List[prefect.Flow]":
+    """Given a file path, load all flows found in the file"""
+    with open(path, "rb") as fil:
+        contents = fil.read()
+
+    namespace = {}
+    try:
+        with prefect.context({"loading_flow": True, "local_script_path": path}):
+            exec(contents, namespace)
+    except Exception as exc:
+        click.secho(f"Error loading {path!r}:", fg="red")
+        log_exception(exc, 2)
+        raise TerminalError
+
+    flows = [f for f in namespace.values() if isinstance(f, prefect.Flow)]
+    if flows:
+        for f in flows:
+            if f.storage is None:
+                f.storage = Local(path=path, stored_as_script=True)
+    return flows
+
+
+def load_flows_from_module(name: str) -> "List[prefect.Flow]":
+    """Given a module name, load all flows found in the module"""
+    try:
+        with prefect.context({"loading_flow": True}):
+            mod = importlib.import_module(name)
+    except Exception as exc:
+        # If the requested module (or any parent module) isn't found, log
+        # without a traceback, otherwise log a general message with the
+        # traceback.
+        if isinstance(exc, ModuleNotFoundError) and (
+            name == exc.name
+            or (name.startswith(exc.name) and name[len(exc.name)] == ".")
+        ):
+            raise TerminalError(str(exc))
+        else:
+            click.secho(f"Error loading {name!r}:", fg="red")
+            log_exception(exc, 2)
+            raise TerminalError
+
+    flows = [f for f in vars(mod).values() if isinstance(f, prefect.Flow)]
+    if flows:
+        for f in flows:
+            if f.storage is None:
+                f.storage = Module(name)
+    return flows
+
+
+class Source(NamedTuple):
+    location: str
+    is_module: bool = False
+
+
+def collect_flows(
+    paths: List[str], modules: List[str], in_watch: bool = False
+) -> "Dict[Source, List[prefect.Flow]]":
+    """Load all flows found in `paths` & `modules`.
+
+    Args:
+        - paths (List[str]): file paths to load flows from.
+        - modules (List[str]): modules to load flows from.
+        - in_watch (bool): If true, any errors in loading the flows will be
+            logged but won't abort execution. Default is False.
     """
-    Register flows
+    sources = [Source(p, False) for p in paths]
+    sources.extend(Source(m, True) for m in modules)
+
+    out = {}
+    for s in sources:
+        try:
+            if s.is_module:
+                flows = load_flows_from_module(s.location)
+            else:
+                flows = load_flows_from_path(s.location)
+        except TerminalError:
+            # If we're running with --watch, bad files are logged and skipped
+            # rather than aborting early
+            if not in_watch:
+                raise
+        out[s] = flows
+    return out
 
-    \b
-    Usage:
-        $ prefect register [OBJECT]
 
-    \b
-    Arguments:
-        flow    Register flows with a backend API
+def build_and_register(
+    client: "prefect.Client",
+    flows: "List[prefect.Flow]",
+    project: str,
+    labels: List[str] = None,
+    force: bool = False,
+) -> Counter:
+    """Build and register all flows.
 
-    \b
-    Examples:
-        $ prefect register flow --file my_flow.py --name My-Flow
+    Args:
+        - client (prefect.Client): the prefect client to use
+        - flows (List[prefect.Flow]): the flows to register
+        - project (str): the project in which to register the flows
+        - labels (List[str], optional): Any extra labels to set on all flows
+        - force (bool, optional): If false (default), an idempotency key will
+            be used to avoid unnecessary register calls.
+
+    Returns:
+        - Counter: stats about the number of successful, failed, and skipped flows.
     """
+    labels = set(labels) if labels else None
 
+    # Finish setting up all flows before building, to ensure a stable hash
+    # for flows sharing storage instances
+    for flow in flows:
+        # Set the default flow result if not specified
+        if not flow.result:
+            flow.result = flow.storage.result
 
-@register.command(
-    hidden=True,
-    context_settings=dict(ignore_unknown_options=True, allow_extra_args=True),
+        # Add a `run_config` if not configured explicitly
+        # Also add any extra labels to the flow
+        if flow.run_config is None:
+            if flow.environment is not None:
+                flow.environment.labels.update(labels)
+            else:
+                flow.run_config = UniversalRun(labels=labels)
+        else:
+            flow.run_config.labels.update(labels)
+
+    # Group flows by storage instance.
+    # Also adds all flows to their respective storage instance.
+    storage_to_flows = defaultdict(list)
+    for flow in flows:
+        flow.storage.add_flow(flow)
+        storage_to_flows[flow.storage].append(flow)
+
+    # Register each flow, building storage as needed.
+    # Stats on success/fail/skip rates are kept for later display
+    stats = Counter(registered=0, errored=0, skipped=0)
+    for storage, flows in storage_to_flows.items():
+        # Build storage
+        click.echo(f"  Building `{type(storage).__name__}` storage...")
+        try:
+            storage.build()
+        except Exception as exc:
+            click.secho("    Error building storage:", fg="red")
+            log_exception(exc, indent=6)
+            red_error = click.style("Error", fg="red")
+            for flow in flows:
+                click.echo(f"  Registering {flow.name!r}... {red_error}")
+                stats["errored"] += 1
+            continue
+
+        for flow in flows:
+            click.echo(f"  Registering {flow.name!r}...", nl=False)
+            try:
+                # Get most recent flow id for this flow. This can be removed once
+                # the registration graphql routes return more information
+                resp = client.graphql(
+                    {
+                        "query": {
+                            with_args(
+                                "flow",
+                                {
+                                    "where": {
+                                        "_and": {
+                                            "name": {"_eq": flow.name},
+                                            "project": {"name": {"_eq": project}},
+                                        }
+                                    },
+                                    "order_by": {"version": EnumValue("desc")},
+                                    "limit": 1,
+                                },
+                            ): {"id", "version"}
+                        }
+                    }
+                )
+                if resp.data.flow:
+                    prev_id = resp.data.flow[0].id
+                    prev_version = resp.data.flow[0].version
+                else:
+                    prev_id = None
+                    prev_version = 0
+                new_id = client.register(
+                    flow=flow,
+                    project_name=project,
+                    build=False,
+                    no_url=True,
+                    idempotency_key=(None if force else flow.serialized_hash()),
+                )
+            except Exception as exc:
+                click.secho(" Error", fg="red")
+                log_exception(exc, indent=4)
+                stats["errored"] += 1
+            else:
+                if new_id == prev_id:
+                    click.secho(" Skipped", fg="yellow")
+                    stats["skipped"] += 1
+                else:
+                    click.secho(" Done", fg="green")
+                    click.echo(f"  └── ID: {new_id}")
+                    click.echo(f"  └── Version: {prev_version + 1}")
+                    stats["registered"] += 1
+    return stats
+
+
+def register_internal(
+    project: str,
+    paths: List[str],
+    modules: List[str],
+    names: List[str] = None,
+    labels: List[str] = None,
+    force: bool = False,
+    in_watch: bool = False,
+) -> None:
+    """Do a single registration pass, loading, building, and registering the
+    requested flows.
+
+    Args:
+        - project (str): the project in which to register the flows.
+        - paths (List[str]): a list of file paths containing flows.
+        - modules (List[str]): a list of python modules containing flows.
+        - names (List[str], optional): a list of flow names that should be
+            registered. If not provided, all flows found will be registered.
+        - labels (List[str], optional): a list of extra labels to set on all
+            flows.
+        - force (bool, optional): If false (default), an idempotency key will
+            be used to avoid unnecessary register calls.
+        - in_watch (bool, optional): Whether this call resulted from a
+            `register --watch` call.
+    """
+    # Load flows from all files/modules requested
+    click.echo("Collecting flows...")
+    source_to_flows = collect_flows(paths, modules, in_watch)
+
+    # Filter flows by name if requested
+    if names:
+        names = set(names)
+        source_to_flows = {
+            source: [f for f in flows if f.name in names]
+            for source, flows in source_to_flows.items()
+        }
+        missing = names.difference(
+            f.name for flows in source_to_flows.values() for f in flows
+        )
+        if missing:
+            missing_flows = "\n".join(f"- {n}" for n in sorted(missing))
+            click.secho(
+                f"Failed to find the following flows:\n{missing_flows}", fg="red"
+            )
+            if not in_watch:
+                raise TerminalError
+
+    # Iterate through each file, building all storage and registering all flows
+    # Log errors as they happen, but only exit once all files have been processed
+    client = prefect.Client()
+    stats = Counter(registered=0, errored=0, skipped=0)
+    for source, flows in source_to_flows.items():
+        if flows:
+            click.echo(f"Processing {source.location!r}:")
+            stats += build_and_register(
+                client, flows, project, labels=labels, force=force
+            )
+
+    # Output summary message
+    registered = stats["registered"]
+    skipped = stats["skipped"]
+    errored = stats["errored"]
+    parts = [click.style(f"{registered} registered", fg="green")]
+    if skipped:
+        parts.append(click.style(f"{skipped} skipped", fg="yellow"))
+    if errored:
+        parts.append(click.style(f"{errored} errored", fg="red"))
+
+    msg = ", ".join(parts)
+    bar_length = max(60 - len(click.unstyle(msg)), 4) // 2
+    bar = "=" * bar_length
+    click.echo(f"{bar} {msg} {bar}")
+
+    # If not in a watch call, exit with appropriate exit code
+    if not in_watch and stats["errored"]:
+        raise TerminalError
+
+
+def watch_for_changes(
+    paths: List[str] = None,
+    modules: List[str] = None,
+    period: float = 0.5,
+) -> "Iterator[Tuple[List[str], List[str]]]":
+    """Watch a list of paths and modules for changes.
+
+    Yields tuples of `(paths, modules)` whenever changes are detected, where
+    `paths` is a list of paths that changed and `modules` is a list of modules
+    that changed.
+    """
+    paths = list(paths or ())
+    modules = list(modules or ())
+
+    for path in paths:
+        if not os.path.exists(path):
+            raise TerminalError(f"Path {path!r} doesn't exist")
+
+    if modules:
+        # If modules are provided, we need to convert these to paths to watch.
+        # There's no way in Python to do this without possibly importing the
+        # defining module. As such, we run the command in a temporary process
+        # pool.
+        with multiprocessing.get_context("spawn").Pool(1) as pool:
+            module_paths = pool.apply(get_module_paths, (modules,))
+            path_to_module = dict(zip(module_paths, modules))
+    else:
+        path_to_module = {}
+
+    tracked = paths + list(path_to_module)
+    cache = dict.fromkeys(path_to_module)
+    while True:
+        cache2 = {}
+        for path in tracked:
+            try:
+                try:
+                    with os.scandir(path) as directory:
+                        for entry in directory:
+                            if entry.is_file() and entry.path.endswith(".py"):
+                                old_mtime = cache.get(entry.path)
+                                mtime = entry.stat().st_mtime
+                                if mtime != old_mtime:
+                                    cache2[entry.path] = mtime
+                except NotADirectoryError:
+                    old_mtime = cache.get(path)
+                    mtime = os.stat(path).st_mtime
+                    if mtime != old_mtime:
+                        cache2[path] = mtime
+            except FileNotFoundError:
+                cache.pop(path, None)
+
+        if cache2:
+            change_paths = []
+            change_mods = []
+            for path in cache2:
+                module = path_to_module.get(path)
+                if module is not None:
+                    change_mods.append(module)
+                else:
+                    change_paths.append(path)
+            if change_paths or change_mods:
+                yield change_paths, change_mods
+                cache.update(cache2)
+
+        time.sleep(period)
+
+
+REGISTER_EPILOG = """
+\bExamples:
+
+\b  Register all flows found in a directory.
+
+\b    $ prefect register --project my-project -p myflows/
+
+\b  Register a flow named "example" found in `flow.py`.
+
+\b    $ prefect register --project my-project -p flow.py -n "example"
+
+\b  Register all flows found in a module named `myproject.flows`.
+
+\b    $ prefect register --project my-project -m "myproject.flows"
+
+\b  Watch a directory of flows for changes, and re-register flows upon change.
+
+\b    $ prefect register --project my-project -p myflows/ --watch
+"""
+
+
+@click.group(invoke_without_command=True, epilog=REGISTER_EPILOG)
+@click.option(
+    "--project",
+    help="The name of the Prefect project to register this flow in. Required.",
+    default=None,
+)
+@click.option(
+    "--path",
+    "-p",
+    "paths",
+    help=(
+        "A path to a file or a directory containing the flow(s) to register. "
+        "May be passed multiple times to specify multiple paths to register."
+    ),
+    multiple=True,
 )
+@click.option(
+    "--module",
+    "-m",
+    "modules",
+    help=(
+        "A python module name containing the flow(s) to register. May be "
+        "passed multiple times to specify multiple modules to register."
+    ),
+    multiple=True,
+)
+@click.option(
+    "--name",
+    "-n",
+    "names",
+    help=(
+        "The name of a flow to register from the specified paths/modules. If "
+        "provided, only flows with a matching name will be registered. May be "
+        "passed multiple times to specify multiple flows to register. If not "
+        "provided, all flows found on all paths/modules will be registered."
+    ),
+    multiple=True,
+)
+@click.option(
+    "--label",
+    "-l",
+    "labels",
+    help=(
+        "A label to add on all registered flow(s). May be passed multiple "
+        "times to specify multiple labels."
+    ),
+    multiple=True,
+)
+@click.option(
+    "--force",
+    "-f",
+    help="Force flow registration, even if the flow's metadata is unchanged.",
+    default=False,
+    is_flag=True,
+)
+@click.option(
+    "--watch",
+    help=(
+        "If set, the specified paths and modules will be monitored and "
+        "registration re-run upon changes."
+    ),
+    default=False,
+    is_flag=True,
+)
+@click.pass_context
+def register(ctx, project, paths, modules, names, labels, force, watch):
+    """Register one or more flows into a project."""
+    # Since the old command was a subcommand of this, we have to do some
+    # mucking to smoothly deprecate it. Can be removed with `prefect register
+    # flow` is removed.
+    if ctx.invoked_subcommand is not None:
+        if any([project, paths, modules, names, labels, force]):
+            raise ClickException(
+                "Got unexpected extra argument (%s)" % ctx.invoked_subcommand
+            )
+        return
+
+    if project is None:
+        raise ClickException("Missing required option '--project'")
+
+    try:
+        if watch:
+            ctx = multiprocessing.get_context("spawn")
+
+            for change_paths, change_mods in watch_for_changes(
+                paths=paths, modules=modules
+            ):
+                proc = ctx.Process(
+                    target=register_internal,
+                    name="prefect-register",
+                    args=(project,),
+                    kwargs=dict(
+                        paths=change_paths,
+                        modules=change_mods,
+                        names=names,
+                        labels=labels,
+                        force=force,
+                        in_watch=True,
+                    ),
+                    daemon=True,
+                )
+                proc.start()
+                proc.join()
+        else:
+            paths = expand_paths(list(paths or ()))
+            modules = list(modules or ())
+            register_internal(project, paths, modules, names, labels, force)
+    except TerminalError as exc:
+        msg = str(exc)
+        if msg:
+            click.secho(msg, fg="red")
+        sys.exit(1)
+
+
+@register.command(hidden=True)
 @click.option(
     "--file",
     "-f",
@@ -58,6 +576,7 @@ def register():
     "--label",
     "-l",
     required=False,
+    help="A label to set on the flow, extending any existing labels.",
     hidden=True,
     multiple=True,
 )
@@ -68,26 +587,14 @@ def register():
     hidden=True,
 )
 def flow(file, name, project, label, skip_if_flow_metadata_unchanged):
-    """
-    Register a flow from a file. This call will pull a Flow object out of a `.py` file
-    and call `flow.register` on it.
-
-    \b
-    Options:
-        --file, -f      TEXT    The path to a local file which contains a flow  [required]
-        --name, -n      TEXT    The `flow.name` to pull out of the file provided. If a name
-                                is not provided then the first flow object found will be registered.
-        --project, -p   TEXT    The name of a Prefect project to register this flow
-        --label, -l     TEXT    A label to set on the flow, extending any existing labels.
-                                Multiple labels are supported, eg. `-l label1 -l label2`.
-
-        --skip-if-flow-metadata-unchanged       If set, the flow will only be re-registered if its
-                                                metadata or structure has changed.
-
-    \b
-    Examples:
-        $ prefect register flow --file my_flow.py --name My-Flow -l label1 -l label2
-    """
+    """Register a flow (DEPRECATED)"""
+    click.secho(
+        (
+            "Warning: `prefect register flow` is deprecated, please transition to "
+            "using `prefect register` instead."
+        ),
+        fg="yellow",
+    )
     # Don't run extra `run` and `register` functions inside file
     file_path = os.path.abspath(file)
     with prefect.context({"loading_flow": True, "local_script_path": file_path}):
diff --git a/src/prefect/storage/_healthcheck.py b/src/prefect/storage/_healthcheck.py
--- a/src/prefect/storage/_healthcheck.py
+++ b/src/prefect/storage/_healthcheck.py
@@ -66,7 +66,7 @@ def import_flow_from_script_check(flow_file_paths: list):
     return flows
 
 
-def result_check(flows: list):
+def result_check(flows: list, quiet=False):
     for flow in flows:
         if flow.result is not None:
             continue
@@ -112,7 +112,8 @@ def result_check(flows: list):
                     f"more details.",
                     stacklevel=2,
                 )
-    print("Result check: OK")
+    if not quiet:
+        print("Result check: OK")
 
 
 def environment_dependency_check(flows: list):
diff --git a/src/prefect/storage/base.py b/src/prefect/storage/base.py
--- a/src/prefect/storage/base.py
+++ b/src/prefect/storage/base.py
@@ -151,4 +151,4 @@ def run_basic_healthchecks(self) -> None:
         if not hasattr(self, "_flows"):
             return
 
-        _healthcheck.result_check(self._flows.values())  # type: ignore
+        _healthcheck.result_check(self._flows.values(), quiet=True)  # type: ignore


Test patch:
diff --git a/tests/cli/test_register.py b/tests/cli/test_register.py
--- a/tests/cli/test_register.py
+++ b/tests/cli/test_register.py
@@ -1,28 +1,30 @@
+import os
+import threading
+import textwrap
 from unittest.mock import MagicMock
 
 import pytest
 from click.testing import CliRunner
 
-from prefect.cli.register import register
-
-
-def test_register_init():
-    runner = CliRunner()
-    result = runner.invoke(register)
-    assert result.exit_code == 0
-    assert "Register flows" in result.output
-
-
-def test_register_help():
-    runner = CliRunner()
-    result = runner.invoke(register, ["--help"])
-    assert result.exit_code == 0
-    assert "Register flows" in result.output
+from prefect import Flow
+from prefect.cli import cli
+from prefect.cli.register import (
+    TerminalError,
+    watch_for_changes,
+    load_flows_from_path,
+    load_flows_from_module,
+    build_and_register,
+)
+from prefect.engine.results import LocalResult
+from prefect.environments.execution import LocalEnvironment
+from prefect.run_configs import UniversalRun
+from prefect.storage import S3, Local, Module
+from prefect.utilities.graphql import GraphQLResult
 
 
 def test_register_flow_help():
     runner = CliRunner()
-    result = runner.invoke(register, ["flow", "--help"])
+    result = runner.invoke(cli, ["register", "flow", "--help"])
     assert result.exit_code == 0
     assert "Register a flow" in result.output
 
@@ -61,6 +63,7 @@ def test_register_flow_call(monkeypatch, tmpdir, kind, labels):
         f.write(contents)
 
     args = [
+        "register",
         "flow",
         "--file",
         full_path,
@@ -74,7 +77,7 @@ def test_register_flow_call(monkeypatch, tmpdir, kind, labels):
         args.extend(["-l", l])
 
     runner = CliRunner()
-    result = runner.invoke(register, args)
+    result = runner.invoke(cli, args)
     assert client.register.called
     assert client.register.call_args[1]["project_name"] == "project"
     assert client.register.call_args[1]["idempotency_key"] is not None
@@ -88,4 +91,425 @@ def test_register_flow_call(monkeypatch, tmpdir, kind, labels):
     else:
         assert flow.run_config.labels == {*labels}
 
+    assert "Warning: `prefect register flow` is deprecated" in result.stdout
     assert result.exit_code == 0
+
+
+def register_flow_errors_if_pass_options_to_register_group():
+    """Since we deprecated a subcommand, we need to manually check that
+    subcommand options are valid"""
+    result = CliRunner().invoke(
+        cli, ["register", "--project", "my-project", "flow", "--file", "some_path.py"]
+    )
+    assert result.exit_code == 1
+    assert "Got unexpected extra argument (flow)" in result.stdout
+
+
+class TestWatchForChanges:
+    def test_errors_path_does_not_exist(self, tmpdir):
+        missing = str(tmpdir.join("not-real"))
+        with pytest.raises(TerminalError) as exc:
+            for _, _ in watch_for_changes(paths=[missing]):
+                assert False, "Should never get here"
+
+        assert repr(missing) in str(exc.value)
+
+    def test_errors_module_does_not_exist(self):
+        name = "not_a_real_module_name"
+        with pytest.raises(TerminalError, match=name):
+            for _, _ in watch_for_changes(modules=[name]):
+                assert False, "Should never get here"
+
+    def test_watch_paths(self, tmpdir):
+        dir_path = str(tmpdir.join("dir").mkdir())
+        path1 = str(tmpdir.join("test1.py").ensure(file=True))
+        path2 = str(tmpdir.join("test2.py").ensure(file=True))
+        path3 = os.path.join(dir_path, "test3.py")
+        path4 = os.path.join(dir_path, "test4.something")
+
+        iterator = watch_for_changes(paths=[dir_path, path1, path2], period=0.1)
+
+        # Initial iteration hits all known paths
+        paths, mods = next(iterator)
+        assert not mods
+        assert set(paths) == {path1, path2}
+
+        # Later iterations only yield paths that are new or changed
+        def update():
+            with open(path3, "wb"):
+                pass
+            with open(path4, "wb"):
+                pass
+            os.utime(path1)
+
+        timer = threading.Timer(0.15, update)
+        timer.start()
+
+        paths, mods = next(iterator)
+        # path4 isn't included, since only `*.py` files are watched
+        assert set(paths) == {path1, path3}
+
+        # Deleting some original paths causes no errors
+        os.remove(path1)
+        os.remove(path3)
+        timer = threading.Timer(0.15, lambda: os.utime(path2))
+        timer.start()
+
+        paths, mods = next(iterator)
+        assert set(paths) == {path2}
+
+        # If those paths are readded, next iteration picks them up
+        with open(path1, "wb"):
+            pass
+        os.utime(path2)
+        paths, mods = next(iterator)
+        assert set(paths) == {path1, path2}
+
+    def test_watch_modules(self, tmpdir, monkeypatch):
+        mod1 = tmpdir.join("module1.py").ensure(file=True)
+        mod2 = tmpdir.join("module2.py").ensure(file=True)
+        monkeypatch.syspath_prepend(str(tmpdir))
+
+        iterator = watch_for_changes(modules=["module1", "module2"], period=0.1)
+
+        # Initial iteration hits all known modules
+        paths, mods = next(iterator)
+        assert not paths
+        assert set(mods) == {"module1", "module2"}
+
+        # Later iterations only yield modules that are new or changed
+        timer = threading.Timer(0.15, lambda: mod1.setmtime())
+        timer.start()
+
+        paths, mods = next(iterator)
+        assert not paths
+        assert set(mods) == {"module1"}
+
+        # Deleting some original modules causes no errors
+        mod1.remove()
+        timer = threading.Timer(0.15, mod2.setmtime)
+        timer.start()
+        paths, mods = next(iterator)
+        assert set(mods) == {"module2"}
+
+        # If those modules are readded, next iteration picks them up
+        mod1.ensure(file=True)
+        mod2.setmtime()
+        paths, mods = next(iterator)
+        assert set(mods) == {"module1", "module2"}
+
+
+class TestRegister:
+    def test_load_flows_from_path(self, tmpdir):
+        path = str(tmpdir.join("test.py"))
+        source = textwrap.dedent(
+            """
+            from prefect import Flow
+            from prefect.storage import S3
+            f1 = Flow("f1")
+            f1.storage = S3("my-bucket", key="my-key", stored_as_script=True)
+            f2 = Flow("f2")
+            """
+        )
+        with open(path, "w") as f:
+            f.write(source)
+
+        flows = {f.name: f for f in load_flows_from_path(path)}
+        assert len(flows) == 2
+        assert isinstance(flows["f1"].storage, S3)
+        assert flows["f1"].storage.local_script_path == path
+        assert isinstance(flows["f2"].storage, Local)
+        assert flows["f2"].storage.path == path
+        assert flows["f2"].storage.stored_as_script
+
+    def test_load_flows_from_path_error(self, tmpdir, capsys):
+        path = str(tmpdir.join("test.py"))
+        with open(path, "w") as f:
+            f.write("raise ValueError('oh no!')")
+
+        with pytest.raises(TerminalError):
+            load_flows_from_path(path)
+
+        out, _ = capsys.readouterr()
+        assert "oh no!" in out
+        assert repr(path) in out
+
+    def test_load_flows_from_module(self, tmpdir, monkeypatch):
+        monkeypatch.syspath_prepend(str(tmpdir))
+        source = textwrap.dedent(
+            """
+            from prefect import Flow
+            from prefect.storage import Module
+            f1 = Flow("f1")
+            f1.storage = Module("mymodule.submodule")
+            f2 = Flow("f2")
+            """
+        )
+        path = tmpdir.join("mymodule.py")
+        path.write(source)
+
+        flows = {f.name: f for f in load_flows_from_module("mymodule")}
+        assert len(flows) == 2
+        assert isinstance(flows["f1"].storage, Module)
+        assert flows["f1"].storage.module == "mymodule.submodule"
+        assert isinstance(flows["f2"].storage, Module)
+        assert flows["f2"].storage.module == "mymodule"
+
+    def test_load_flows_from_module_not_found_error(self, tmpdir, monkeypatch, capsys):
+        monkeypatch.syspath_prepend(str(tmpdir))
+        tmpdir.join("mymodule1.py").ensure(file=True)
+        tmpdir.join("mymodule2.py").write("raise ValueError('oh no!')")
+
+        with pytest.raises(TerminalError, match="No module named 'mymodule3'"):
+            load_flows_from_module("mymodule3")
+
+        with pytest.raises(TerminalError, match="No module named 'mymodule3'"):
+            load_flows_from_module("mymodule3.submodule")
+
+        with pytest.raises(
+            TerminalError, match="No module named 'mymodule1.submodule'"
+        ):
+            load_flows_from_module("mymodule1.submodule")
+
+        with pytest.raises(TerminalError):
+            load_flows_from_module("mymodule2")
+
+        out, _ = capsys.readouterr()
+        assert "oh no!" in out
+        assert "Error loading 'mymodule2'" in out
+
+    @pytest.mark.parametrize("force", [False, True])
+    def test_build_and_register(self, capsys, monkeypatch, force):
+        """Build and register a few flows:
+        - 1 new flow
+        - 1 updated flow
+        - 1 skipped flow
+        - 1 error during registration
+        - 2 sharing the same storage (which fails to build properly)
+        """
+        build_call_count = 0
+
+        class MyModule(Module):
+            def build(self):
+                nonlocal build_call_count
+                build_call_count += 1
+
+        class BadStorage(Module):
+            def build(self):
+                raise ValueError("whoops!")
+
+        client = MagicMock()
+        client.graphql.side_effect = [
+            GraphQLResult({"data": {"flow": []}}),
+            GraphQLResult({"data": {"flow": [{"id": "old-id-2", "version": 1}]}}),
+            GraphQLResult({"data": {"flow": [{"id": "old-id-3", "version": 2}]}}),
+            GraphQLResult({"data": {"flow": [{"id": "old-id-4", "version": 3}]}}),
+        ]
+        client.register.side_effect = [
+            "new-id-1",
+            "old-id-2",
+            "new-id-3",
+            ValueError("Oh no!"),
+        ]
+
+        storage1 = MyModule("testing")
+        storage1.result = LocalResult()
+        flow1 = Flow("flow 1", storage=storage1, run_config=UniversalRun(labels=["a"]))
+        flow2 = Flow(
+            "flow 2",
+            storage=MyModule("testing"),
+            environment=LocalEnvironment(labels=["a"]),
+        )
+        storage2 = MyModule("testing")
+        flow3 = Flow("flow 3", storage=storage2)
+        flow4 = Flow("flow 4", storage=storage2)
+        storage3 = BadStorage("testing")
+        flow5 = Flow("flow 5", storage=storage3)
+        flow6 = Flow("flow 6", storage=storage3)
+        flows = [flow1, flow2, flow3, flow4, flow5, flow6]
+
+        stats = build_and_register(
+            client, flows, "testing", labels=["b", "c"], force=force
+        )
+
+        # 3 calls (one for each unique `MyModule` storage object)
+        assert build_call_count == 3
+
+        # 4 register calls (6 - 2 that failed to build storage)
+        assert client.register.call_count == 4
+        for flow, (args, kwargs) in zip(flows, client.register.call_args_list):
+            assert not args
+            assert kwargs["flow"] is flow
+            assert kwargs["project_name"] == "testing"
+            assert kwargs["build"] is False
+            assert kwargs["no_url"] is True
+            if force:
+                assert kwargs["idempotency_key"] is None
+            else:
+                assert kwargs["idempotency_key"]
+
+        # Stats are recorded properly
+        assert dict(stats) == {"registered": 2, "skipped": 1, "errored": 3}
+
+        # Flows are properly configured
+        assert flow1.result is storage1.result
+        assert flow1.run_config.labels == {"a", "b", "c"}
+        assert flow2.environment.labels == {"a", "b", "c"}
+        assert isinstance(flow3.run_config, UniversalRun)
+        assert flow3.run_config.labels == {"b", "c"}
+        assert isinstance(flow4.run_config, UniversalRun)
+        assert flow4.run_config.labels == {"b", "c"}
+
+        # The output contains a traceback, which will vary between machines
+        # We only check that the following fixed sections exist in the output
+        parts = [
+            (
+                "  Building `MyModule` storage...\n"
+                "  Registering 'flow 1'... Done\n"
+                "  └── ID: new-id-1\n"
+                "  └── Version: 1\n"
+                "  Building `MyModule` storage...\n"
+                "  Registering 'flow 2'... Skipped\n"
+                "  Building `MyModule` storage...\n"
+                "  Registering 'flow 3'... Done\n"
+                "  └── ID: new-id-3\n"
+                "  └── Version: 3\n"
+                "  Registering 'flow 4'... Error\n"
+                "    Traceback (most recent call last):\n"
+            ),
+            (
+                "    ValueError: Oh no!\n"
+                "\n"
+                "  Building `BadStorage` storage...\n"
+                "    Error building storage:\n"
+                "      Traceback (most recent call last):\n"
+            ),
+            (
+                "      ValueError: whoops!\n"
+                "\n"
+                "  Registering 'flow 5'... Error\n"
+                "  Registering 'flow 6'... Error\n"
+            ),
+        ]
+        out, err = capsys.readouterr()
+        assert not err
+        for part in parts:
+            assert part in out
+
+    @pytest.mark.parametrize("force", [False, True])
+    @pytest.mark.parametrize("names", [[], ["flow 1"]])
+    def test_register_cli(self, tmpdir, monkeypatch, force, names):
+        path = str(tmpdir.join("test.py"))
+        source = textwrap.dedent(
+            """
+            from prefect import Flow
+
+            flow1 = Flow("flow 1")
+            flow2 = Flow("flow 2")
+            """
+        )
+        with open(path, "w") as f:
+            f.write(source)
+
+        client = MagicMock()
+        client.graphql.side_effect = [
+            GraphQLResult({"data": {"flow": []}}),
+            GraphQLResult({"data": {"flow": [{"id": "old-id-2", "version": 1}]}}),
+        ]
+        client.register.side_effect = ["new-id-1", "old-id-2"]
+        monkeypatch.setattr("prefect.Client", MagicMock(return_value=client))
+
+        cmd = ["register", "--project", "testing", "--path", path, "-l", "a", "-l", "b"]
+        if force:
+            cmd.append("--force")
+        for name in names:
+            cmd.extend(["--name", name])
+        result = CliRunner().invoke(cli, cmd)
+
+        assert result.exit_code == 0
+        if not names:
+            names = ["flow 1", "flow 2"]
+
+        assert client.register.call_count == len(names)
+        for args, kwargs in client.register.call_args_list:
+            assert not args
+            assert kwargs["project_name"] == "testing"
+            assert kwargs["flow"].name in names
+            assert kwargs["flow"].run_config.labels == {"a", "b"}
+            if force:
+                assert kwargs["idempotency_key"] is None
+            else:
+                assert kwargs["idempotency_key"]
+
+        # Bulk of the output is tested elsewhere, only a few smoketests here
+        assert "Building `Local` storage..." in result.stdout
+        assert repr(path) in result.stdout
+        if len(names) == 2:
+            assert (
+                "================== 1 registered, 1 skipped =================="
+                in result.stdout
+            )
+        else:
+            assert (
+                "======================== 1 registered ========================"
+                in result.stdout
+            )
+
+    def test_register_cli_name_not_found(self, tmpdir):
+        path = str(tmpdir.join("test.py"))
+        source = textwrap.dedent(
+            """
+            from prefect import Flow
+
+            flow1 = Flow("flow 1")
+            flow2 = Flow("flow 2")
+            """
+        )
+        with open(path, "w") as f:
+            f.write(source)
+
+        cmd = [
+            "register",
+            "--project",
+            "testing",
+            "-p",
+            path,
+            "-n",
+            "flow 1",
+            "-n",
+            "flow 3",
+        ]
+        result = CliRunner().invoke(cli, cmd)
+
+        assert result.exit_code == 1
+        assert result.stdout == (
+            "Collecting flows...\n" "Failed to find the following flows:\n" "- flow 3\n"
+        )
+
+    def test_register_cli_path_not_found(self, tmpdir):
+        path = str(tmpdir.join("test.py"))
+        cmd = ["register", "--project", "testing", "-p", path]
+        result = CliRunner().invoke(cli, cmd)
+
+        assert result.exit_code == 1
+        assert result.stdout == f"Path {path!r} doesn't exist\n"
+
+    def test_register_cli_module_not_found(self):
+        cmd = [
+            "register",
+            "--project",
+            "testing",
+            "-m",
+            "a_highly_unlikely_module_name",
+        ]
+        result = CliRunner().invoke(cli, cmd)
+
+        assert result.exit_code == 1
+        assert result.stdout == (
+            "Collecting flows...\n" "No module named 'a_highly_unlikely_module_name'\n"
+        )
+
+    def test_register_cli_no_project_specified(self):
+        result = CliRunner().invoke(cli, ["register", "-p", "some_path"])
+        assert result.exit_code == 1
+        assert result.stdout == "Error: Missing required option '--project'\n"
diff --git a/tests/client/test_client.py b/tests/client/test_client.py
--- a/tests/client/test_client.py
+++ b/tests/client/test_client.py
@@ -850,7 +850,7 @@ def test_client_register_flow_id_no_output(
     assert flow_id == "long-id"
 
     captured = capsys.readouterr()
-    assert captured.out == "Result check: OK\n"
+    assert not captured.out
 
 
 def test_set_flow_run_name(patch_posts, cloud_api):