Feedback required for my Elixir CLI Tool

Hello Community,
After reading a bunch of books and doing nothing :slight_smile: , I finally decided to write something and came up with this https://github.com/chattes/extract_poi.
This is a CLI that downloads POI Data(Points of Interests) of a Country and Stores as JSON in a FIle.
Its mostly follows a fire and forget pattern to extract the data from Triposo.
This was needed for work anyway and hence decided to write it in Elixir.

I would love some feedback/pointers etc regarding any glaring anti-patterns or mistakes!
I am not a beginner programmer, but have started to explore Elixir as my tool of choice for writing any scripts/side projects.

Thanks and would love some feedback here or as issues in Github.

Thanks!

1 Like

I have some style suggestions:

 require PointOfInterest
  require Logger
  import IO.ANSI

Should be:

    import IO.ANSI # As a general rule of thumb, avoid imports if you can. They pepper your code with _magic_ functions

    alias PointOfInterest # You don't really need require, as PointOfInterest has no macros that need to be pre-compiled.

    require Logger

Sources:


%{country: country, filename: filename}
|> PointOfInterest.get_pois_for_country()

Should be:

PointOfInterest.get_pois_for_country(
  %{country: country, filename: filename}
)

Source:


Instead of IO.puts, consider using Logger.info as it is easier to test and can be disabled per environment.


Base url should be an ENV variable, as this link is likely to change with time (and you don’t want to recompile every time it changes):

@base_url "https://www.triposo.com/api/20181213" should be:

@base_url Application.get_env(:poi_cli, :base_url)

You would have a config/config.exs with the following:

config :poi_cli, 
  base_url: "....someurl..."

There are also some issue with your project structure, but at this point I am still having my own dilema on how to organize one properly, so I will leave this advice for someone else to give :stuck_out_tongue:

2 Likes

No need for alias as well, you can use PointOfInterest as is.

4 Likes

Good point. However I still recommend he uses alias in order to make the dependency as explicit as possible.

This way you don’t even need to start reading code to know this module depends on PointOfInterest.

This is a stylistic preference though. I like to have my dependencies very explicit at the top of my modules.

3 Likes

Do you alias IO, Enum and all the other stdlib modules the same way?

To be honest, I rarely read modules top2bottom, I read the functions that are in the module. And when I see an alias in a function call that I do not know already, I hover with the mouse and see its expanded alias or I could even Ctrl-click it and jump directly to the definition of the function called.

In general I try to avoid “noise” in the modules “prelude” as much as possible.

4 Likes

No, because I can’t refactor them, I don’t control them. As a general rule of thumb, if I have control over some dependency, I alias it.

A valid opinion. As I said, it is a stylistic decision. The OP will have to decide.

They’re equally able to be tested and easy to test, through CaptureIO and CaptureLog facilities in ExUnit:

https://hexdocs.pm/ex_unit/v1.8/ExUnit.CaptureIO.html
https://hexdocs.pm/ex_unit/v1.8/ExUnit.CaptureLog.html

I would say Logger is a hair easier to keep your tests’ output tidy because it’s been special-cased as a “capture_log” tag on ExUnit.Case, but this is not a testing consideration directly because it doesn’t allow for assertions.

https://hexdocs.pm/ex_unit/ExUnit.Case.html#module-known-tags
https://hexdocs.pm/ex_unit/v1.8/ExUnit.Case.html

I disagree. You can specify how logger behaves per environment and you can even configure how different backends behave. Good luck doing that wit IO without re-inventing the wheel.

assert capture_log(fn -> Logger.error msg end) =~ msg

This looks like an assert to me.

The tag-based special case is what doesn’t allow for assertions - it’s just to suppress log output when your test cases are passing.

That’s your prerogative, but you originally presented your position as un-nuanced objective fact when it is not so. At very least, now you’ve explained why you believe it to be so, which is a net improvement to the information available to the OP. That’s how you should’ve phrased it in the first place.

tag based special case? I didn’t use any tags afaik in my examples (@tag :some tag). Could you elaborate?

You can’t explain every little detail to everyone and still have a life and a job to do. My comments, as well the comments of anyone, represent their opinions and the solutions we defend for any given problems. If the OP or anyone else has questions, they are always welcome to ask for further clarification.

First of all you should have: my_app/lib/my_app.ex and my_app/lib/my_app/ + all your modules should be prefixed with: MyApp.. Your app name is :poi_cli, but you have only: PointOfInterest.MixProject, PointOfInterest and Poi.CLI which does not makes sense for me and it’s really problematic when using as dependency, because you are reserving for you 2 module namespaces (PointOfInterest.* and Poi.*) and 1 extra app name (poi_cli instead of poi or point_of_interest).

point_of_interest
|--> lib/
|----> point_of_interest/
|------> cli.ex (PointOfInterest.CLI)
|----> point_of_interest.ex (PointOfInterest)

Secondly I would rewrite whole code using for, with and other ways to make everything code much smaller, for example:

  def get_pois_for_country(%{country: country, filename: filename}) do
    country
    |> get_cities
    |> pmap(&get_poi(&1))
    |> List.flatten()
    # |> Enum.filter(&match?({:ok, _}, &1))
    |> Enum.filter(fn
      {:ok, _value} -> true
      _ -> false
    end)
    |> Enum.map(&elem(&1, 1))
    |> write_poi(filename)
    |> (fn
          {:ok, data} -> IO.puts("Wrote #{Enum.count(data)} records succesfully")
          _ -> IO.puts("Failed to write records to File")
        end).()
  end

  # better:

  def get_pois_for_country(%{country: country, filename: filename}) do
    poi = country |> get_cities |> pmap(&get_poi/1) |> List.flatten()
    result = for {:ok, data} <- poi, do: data
    result |> write_poi() |> get_message() |> IO.puts()
  end

  defp get_message({:ok, data}), do: "Wrote #{Enum.count(data)} records succesfully"
  defp get_message(_), do: "Failed to write records to File"

Then you would need to fix some problems. For example write_poi/1 function always returns {:ok, result} and it could fail (by File.write!) - you probably wanted here to catch fails (using File.write). For now it’s your error check in get_pois_for_country/1 function have no sense, because this clause would never hit.

Finally I recommend to use Elixir's builtin formatter.

2 Likes

This was referring to the third and fourth links of my first reply. I wasn’t trying to say that you, @Fl4m3Ph03n1x, had suggested this, just trying to describe one of the adjacent features of the ExUnit.CaptureLog module I had just mentioned in the same post.

Specifically, you can write a test case that looks like so, and no* output from Logger macros will appear during test runs unless a case fails. The full log history will be shown if a test does fail.

@tag capture_log: true
test "function that emits logs" do
  assert :ok = Module.function_that_logs()
end

You can additionally apply this to an entire test module by using @moduletag capture_log: true, or by default using the syntax in the ExUnit.Case (fourth) link.

Caveat:

  • Logger calls from sibling Elixir processes are not always altered, see the capture_log/2 docs for details.

(Mods are welcome to splinter this tangent of discussion off from this thread, if they wish)

Thanks for the feedback. This is really why i shared as all code in general somewhat works, but we need to get the best practices to avoid potential pitfalls.

Thanks for the feedbacks till now. This is a great way for beginners to learn quickly i think…
Showcase -> Feedback -> Refactor -> Repeat.
Thanks Guys!
Keep them coming :slight_smile: