Skip to content

Commit

Permalink
behavior
Browse files Browse the repository at this point in the history
  • Loading branch information
m1dnight committed Dec 16, 2024
1 parent f500d84 commit caed321
Show file tree
Hide file tree
Showing 14 changed files with 461 additions and 175 deletions.
2 changes: 1 addition & 1 deletion .credo.exs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
{Credo.Check.Readability.RedundantBlankLines, []},
{Credo.Check.Readability.Semicolons, []},
{Credo.Check.Readability.SpaceAfterCommas, []},
# {Credo.Check.Readability.Specs, []},
{Credo.Check.Readability.Specs, []},
{Credo.Check.Readability.StringSigils, []},
{Credo.Check.Readability.TrailingBlankLine, []},
{Credo.Check.Readability.TrailingWhiteSpace, []},
Expand Down
34 changes: 27 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,12 @@ system such as Elixir applications.

## Installation

If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `ex_example` to your list of dependencies in `mix.exs`:
There is no package available on hex yet, so add it as a Git dependency:

```elixir
def deps do
[
{:ex_example, "~> 0.1.0"}
{:ex_example, git: "https://github.com/anoma/ex_example"}
]
end
```
Expand All @@ -27,15 +26,35 @@ To get started, create a new module in the `lib/` folder of your Elixir applicat
```elixir
defmodule MyExamples do
use ExExample

defexample read_data() do
1..1000 |> Enum.shuffle() |> Enum.take(10)
end

@spec copy(any()) :: Stack.t()
def copy(stack) do
%Stack{elements: stack.elements}
end

@spec rerun?(any()) :: boolean()
def rerun?(_), do: false
end
```

In a running REPL with your application loaded, you can execute this example using `MyExamples.read_data()`.
The example will be executed once, and the cached result will be returned the next time around.

The optional callbacks `copy/1` and `rerun?/1` are used to change the caching behavior.
These functions are called whenever an example within the module they're defined in are executed.

The `copy/1` function takes in the previous result value if there was one, and allows you to define custom logic on how to copy a value.
This is especially useful if you return values that are mutable (e.g., process ids).
For example, if you want to create a copy of a supervision tree, you define the logic to clone that supervision tree in the `copy/1` function.
This is useful if you have examples that change that value, while other examples do not expect their inputs to be changed.

The `rerun?/1` function takes in the result of an already run example, and determines based on its output if it should be recomputed anyway.
This is useful to circumvent the caching mechanism in case you do not want cached values in examples.

## Caching

In a REPL session it's not uncommon to recompile your code (e.g., using `recompile()`). This changes
Expand All @@ -46,17 +65,18 @@ if the code it depends on, or the example itself, have not been changed.

When the code changes, the example is executed again.

## Tests
## Tests

The examples are created to work with the code base, but they can also serve as a unit test.
The examples are created to work with the code base, but they can also serve as a unit test.

To let ExUnit use the examples in your codebase as tests, add a test file in the `test/` folder, and
import the `ExExample.Test` module.
import the `ExExample.Test` module.

To run the examples from above, add a file `ny_examples_test.exs` to your `test/` folder and include the following.

```elixir
defmodule MyExamplesTest do
use ExExample.Test, for: MyExamples
use ExUnit.Case
use ExExample.Tests, for: MyExamples
end
```
179 changes: 109 additions & 70 deletions lib/ex_example.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,79 +3,120 @@ defmodule ExExample do
Documentation for `ExExample`.
"""
alias ExExample.Analyze
alias ExExample.Cache
alias ExExample.Executor

############################################################
# Types #
############################################################

@typedoc """
A dependency is a function that will be called by an example.
The format of a dependency is `{{module, function}, arity}`
"""
@type dependency :: {{atom(), atom()}, non_neg_integer()}

@typedoc """
"""
@type example :: {atom(), list(dependency)}

############################################################
# Helpers #
############################################################

@doc """
I return the hidden name of an example.
The hidden name is the example body without modification.
"""
@spec hidden_name({atom(), atom()}) :: {atom(), atom()}
def hidden_name({module, func}) do
{module, String.to_atom("__#{func}__")}
end

@doc """
I determine if a module/function pair is an example or not.
A function is an example if it is defined in a module that has the `__examples__/0` function
implemented, and when the `__examples__()` output lists that function name as being an example.
"""
@spec example?(dependency()) :: boolean()
def example?({{module, func}, _arity}) do
example_module?(module) and Keyword.has_key?(module.__examples__(), func)
end

@doc """
I return true if the given module contains examples.
"""
@spec example_module?(atom()) :: boolean
def example_module?(module) do
{:__examples__, 0} in module.__info__(:functions)
end

@doc """
I return a list of all dependencies for this example.
Note: this does includes other called modules too (e.g., Enum).
"""
@spec all_dependencies({atom(), atom()}) :: [dependency()]
def all_dependencies({module, func}) do
module.__examples__()
|> Keyword.get(func, [])
end

@doc """
I return a list of example dependencies for this example.
Note: this does not include other called modules.
"""
@spec example_dependencies({atom(), atom()}) :: [dependency()]
def example_dependencies({module, func}) do
all_dependencies({module, func})
|> Enum.filter(&example?/1)
end

@doc """
I return a list of examples in the order they should be
executed in.
I do this by topologically sorting their execution order.
"""
@spec execution_order(atom()) :: [{atom(), atom()}]
def execution_order(module) do
module.__examples__()
|> Enum.reduce(Graph.new(), fn
{function, []}, g ->
Graph.add_vertex(g, {__MODULE__, function})

{function, dependencies}, g ->
dependencies
# filter out all non-example dependencies
|> Enum.filter(&example?/1)
|> Enum.reduce(g, fn {{module, func}, _arity}, g ->
Graph.add_edge(g, {module, func}, {module, function})
end)
end)
|> Graph.topsort()
end

############################################################
# Macros #
############################################################

defmacro __using__(_options) do
quote do
import unquote(__MODULE__)

@behaviour ExExample.Behaviour

# module attribute that holds all the examples
Module.register_attribute(__MODULE__, :example_dependencies, accumulate: true)
Module.register_attribute(__MODULE__, :examples, accumulate: true)
Module.register_attribute(__MODULE__, :copies, accumulate: true)
Module.register_attribute(__MODULE__, :copy, accumulate: false)

@before_compile unquote(__MODULE__)
end
end

defmacro __before_compile__(_env) do
quote do
@doc """
I return a list of all the dependencies for a given example,
or the list of all dependencies if no argument is given.
"""
def __example_dependencies__, do: @example_dependencies

def __example_dependencies__(dependee) do
@example_dependencies
|> Enum.find({nil, []}, fn {name, _} -> name == dependee end)
|> elem(1)
end

@doc """
I reutrn all the examples in this module.
"""
def __examples__ do
@examples
end

@doc """
I run all the examples in this module.
"""
def __run_examples__ do
__sorted__()
|> Enum.each(fn {module, name} ->
apply(module, name, [])
end)
end

@doc """
I return a topologically sorted list of examples.
This list is the order in which the examples should be run.
"""
@spec __sorted__() :: list({atom(), atom()})
def __sorted__ do
__example_dependencies__()
|> Enum.reduce(Graph.new(), fn
{example, []}, g ->
Graph.add_vertex(g, {__MODULE__, example})

{example, dependencies}, g ->
dependencies
# filter out all non-example dependencies
|> Enum.filter(&Executor.example?/1)
|> Enum.reduce(g, fn {{module, func}, _arity}, g ->
Graph.add_edge(g, {module, func}, {__MODULE__, example})
end)
end)
|> Graph.topsort()
end

def __example_copy__(example_name) do
@copies
|> Keyword.get(example_name, nil)
end
@spec __examples__ :: [ExExample.example()]
def __examples__, do: @examples
end
end

Expand All @@ -91,24 +132,22 @@ defmodule ExExample do
hidden_example_name = String.to_atom("__#{example_name}__")

quote do
# fetch the attribute value, and then clear it for the next examples.
example_copy_tag = Module.get_attribute(unquote(__CALLER__.module), :copy)
Module.delete_attribute(unquote(__CALLER__.module), :copy)

def unquote({hidden_example_name, context, args}) do
unquote(body)
end

@copies {unquote(example_name), {unquote(__CALLER__.module), example_copy_tag}}
@example_dependencies {unquote(example_name), unquote(called_functions)}
@examples unquote(example_name)
@examples {unquote(example_name), unquote(called_functions)}
def unquote(name) do
example_dependencies = __example_dependencies__(unquote(example_name))
example_copy = __example_copy__(unquote(example_name))
case Executor.attempt_example({__MODULE__, unquote(example_name)}, []) do
%{result: %Cache.Result{success: :success} = result} ->
result.result

%{result: %Cache.Result{success: :failed} = result} ->
raise result.result

Executor.maybe_run_example(__MODULE__, unquote(example_name), example_dependencies,
copy: example_copy
)
%{result: %Cache.Result{success: :skipped} = result} ->
:skipped
end
end
end
end
Expand Down
19 changes: 8 additions & 11 deletions lib/ex_example/analyzer/analyze.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,29 +13,21 @@ defmodule ExExample.Analyze do
"""
defstruct called_functions: [], env: nil, functions: []

@spec put_call(map(), {atom(), atom()}, non_neg_integer()) :: map()
def put_call(state, mod, arg) do
%{state | called_functions: [{mod, arg} | state.called_functions]}
end

@spec put_def(map(), atom(), non_neg_integer()) :: map()
def put_def(state, func, arity) do
%{state | functions: [{func, arity} | state.functions]}
end
end

# ----------------------------------------------------------------------------
# Compute hash of all modules that the example depends on

def compile_dependency_hash(dependencies) do
dependencies
|> Enum.map(fn {{module, _func}, _arity} ->
module.__info__(:attributes)[:vsn]
end)
|> :erlang.phash2()
end

# ----------------------------------------------------------------------------
# Exctract function calls from ast

@spec extract_function_calls(tuple(), Macro.Env.t()) :: [{{atom(), atom()}, non_neg_integer()}]
def extract_function_calls(ast, env) do
state = %State{env: env}
# IO.inspect(env)
Expand All @@ -52,6 +44,7 @@ defmodule ExExample.Analyze do

# qualified function call
# e.g., Foo.bar()

defp extract_function_call(
{{:., _, [{:__aliases__, _, aliases}, func_name]}, _, args} = ast,
state
Expand All @@ -70,6 +63,10 @@ defmodule ExExample.Analyze do
end
end

defp extract_function_call({{:., _, _args}, _, _} = ast, state) do
{ast, state}
end

# variable in binding
# e.g. `x` in `x = 1`
defp extract_function_call({_func, _, nil} = ast, state) do
Expand Down
12 changes: 12 additions & 0 deletions lib/ex_example/behaviour.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
defmodule ExExample.Behaviour do
@moduledoc """
I help determine when Examples ought to be run again or be copied
I do this by defining out a behaviour that is to be used with the
use macro for ExExample
"""

@callback rerun?(any()) :: boolean()
@callback copy(any()) :: any()
end
Empty file removed lib/ex_example/cache.ex
Empty file.
11 changes: 10 additions & 1 deletion lib/ex_example/cache/cache.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ defmodule ExExample.Cache do

@cache_name __MODULE__

@doc """
I clear the entire cache.
"""
@spec clear() :: :ok
def clear do
Cachex.clear!(@cache_name)
:ok
end

@doc """
I store a result in cache for a given key.
"""
Expand All @@ -22,7 +31,7 @@ defmodule ExExample.Cache do
I fetch a previous Result from the cache if it exists.
If it does not exist, I return `{:error, :not_found}`.
"""
@spec get_result(Key.t()) :: {:ok, any()} | {:error, :no_result}
@spec get_result(Key.t()) :: {:ok, Result.t()} | {:error, :no_result}
def get_result(%Key{} = key) do
case Cachex.get(@cache_name, key) do
{:ok, nil} ->
Expand Down
1 change: 1 addition & 0 deletions lib/ex_example/cache/result.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ defmodule ExExample.Cache.Result do
field(:key, Key.t())
field(:success, :failed | :success | :skipped)
field(:result, term())
field(:cached, boolean(), default: true)
end
end
Loading

0 comments on commit caed321

Please sign in to comment.