Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Alias a command to a subcommand #281

Closed
weierophinney opened this issue Oct 11, 2022 · 20 comments
Closed

Alias a command to a subcommand #281

weierophinney opened this issue Oct 11, 2022 · 20 comments
Labels
enhancement New feature or request

Comments

@weierophinney
Copy link

Description

I have a script I maintain that either predated nested commands, or where I missed the feature when creating it. As such, I used namespace- prefixes:

  • foo-list
  • foo-install
  • foo-uninstall

Now that I know about nested commands, I'd like to refactor the script to use those. However, because this script is already shipped to end users, I cannot break backwards compatibility.

This is where things get interesting.

Let's say I had this:

commands:
- name: foo-list
  args:
  - name: alpha
    required: false
  flags:
  - long: --beta
  examples:
  - cli foo-list bar
  - cli foo-list bar --beta

If I then add the new command and nested command alongside this:

commands:
- name: foo-list
  help: "DEPRECATED use cli foo list"
  args:
  - name: alpha
    required: false
  flags:
  - long: --beta
  examples:
  - cli foo-list bar
  - cli foo-list bar --beta
- name: foo
  commands:
  - name: list
    args:
    - name: alpha
      required: false
    flags:
    - long: --beta
    examples:
    - cli foo list bar
    - cli foo list bar --beta

I then run into an interesting phenomenon: If I call cli foo-list --help, I do not get the "DEPRECATED use cli foo list" help message. If I reverse the order, so that the deprecated command comes last in the file, I do, but then I also get it for cli foo list --help.

(Helpfully, these both resolve to the same command script, so nothing changes in terms of actual implementation.)

I understand why this happens. Clearly, internally, bashly is normalizing the names to convert - to an underscore, and concatenates command + subcommand using an underscore, so the last definition "wins".

What would be great is if we could alias a command or subcommand to another command or subcommand.

This would not work like the current aliasing which allows providing an alternate name, usually a shorter one, for a given command. Instead, it would mean that when an alias is invoked for any reason, it would act exactly like the other command: the help/usage text would come from that command, and it would call its associated command script. The "alias" would be a name and a target only.

Such an approach would mean that my previous commands would continue to work, but users could start adopting the new syntax.

Proposal

In these examples, I'm choosing the term "target" to indicate that the given target will be executed/merged for the given command, and that no command script should be directly associated.

Behavior:

  • The "target" is specified as the action that should be invoked. These are always resolved as if they were global (not from the parent command).
  • If a command has a "target" element, bashly should consider any other metadata beyond the name and target to be invalid. If encountered, they should cause bashly generate to fail and bashly validate to raise an error.
  • If a command has a "target" element, bashly would generate the "action" for the command such that it invokes the usage or command function associated with the target element instead, or intercept the command during parsing. (See examples below.)

Example 1: aliasing command to a subcommand

This example aliases the command "bar-list" to the nested command "foo list".

commands:
- name: foo
  commands:
  - name: list
    args:
   - name: alpha
     required: false
    flags:
    - long: --beta
    examples:
    - cli foo list bar
    - cli foo list bar --beta
- name: bar-list
  target: "foo list"

Results:

  • Script src/foo_list_command.sh created.
  • Script src/bar_list_command.sh NOT created.
  • Calling cli bar-list --help would output the same help as cli foo list --help
  • Calling cli bar-list would call whatever command script is associated with cli foo list.

In other words, when generating the production script, this could get generated:

  elif [[ $action == "bar-list" ]]; then
    if [[ ${args[--help]:-} ]]; then
      long_usage=yes
      cli_foo_list_usage
    else
      cli_foo_list_command
    fi

Alternately, the generated parse_requirements() function could match "bar-list" and set the action to "foo list" and call the "cli_foo_list_parse_requirements" function.

Example 2: aliasing command to a subcommand (normalized to same name)

This example aliases the command "foo-list" to the nested command "foo list"; doing so allows users to use "foo-list" and "foo list" interchangeably, and the usage text from "foo list" would be presented to users.

commands:
- name: foo
  commands:
  - name: list
    args:
   - name: alpha
     required: false
    flags:
    - long: --beta
    examples:
    - cli foo list bar
    - cli foo list bar --beta
- name: foo-list
  target: "foo list"

Results:

  • Script src/foo_list_command.sh created.
  • Calling cli foo-list --help would output the same help as cli foo list --help
  • Calling cli foo-list would call whatever command script is associated with cli foo list.

This one is interesting as the normalized names for the usage and command functions would be the same.
However, the point is that the non-aliased version is what would be used (not whichever comes last per current versions).

Example 3: aliasing a subcommand to a command

This example aliases the nested command "foo list" to the command "bar-list"

commands:
- name: foo
  commands:
  - name: list
    target: "bar-list"
- name: bar-list
  args:
  - name: alpha
   required: false
  flags:
  - long: --beta
  examples:
  - cli bar-list bar
  - cli bar-list bar --beta

Results:

  • Script src/bar_list_command.sh created.
  • Script src/voo_list_command.sh NOT created.
  • Calling cli foo list --help would output the same help as cli bar-list --help
  • Calling cli foo list would call whatever command script is associated with cli bar-list.

In other words, when generating the production script, this would get generated:

  elif [[ $action == "foo list" ]]; then
    if [[ ${args[--help]:-} ]]; then
      long_usage=yes
      cli_bar_list_usage
    else
      cli_bar_list_command
    fi

Alternately, the generated parse_requirements() function could match "foo list" and set the action to "bar-list" and call the "cli_bar_list_parse_requirements" function.

Example 4: aliasing a subcommand to a command (normalized to same name)

commands:
- name: foo
  commands:
  - name: list
    target: "foo-list"
- name: foo-list
  args:
  - name: alpha
   required: false
  flags:
  - long: --beta
  examples:
  - cli foo-list bar
  - cli foo-list bar --beta

Results:

  • Script src/foo_list_command.sh created.
  • Calling cli foo list --help would output the same help as cli foo-list --help
  • Calling cli foo list would call whatever command script is associated with cli foo-list.

Example 5: aliasing a command to another command

commands:
- name: bar-list
  target: "foo-list"
- name: foo-list
  args:
  - name: alpha
   required: false
  flags:
  - long: --beta
  examples:
  - cli foo-list bar
  - cli foo-list bar --beta

Results:

  • Script src/foo_list_command.sh created.
  • Script src/bar_list_command.sh NOT created.
  • Calling cli bar-list --help would output the same help as cli foo-list --help
  • Calling cli bar-list would call whatever command script is associated with cli foo-list.

In other words, when generating the production script, this would get generated:

  elif [[ $action == "bar-list" ]]; then
    if [[ ${args[--help]:-} ]]; then
      long_usage=yes
      cli_foo_list_usage
    else
      cli_foo_list_command
    fi
@weierophinney weierophinney added the enhancement New feature or request label Oct 11, 2022
@DannyBen
Copy link
Owner

  1. I will read this more thoroughly tomorrow.
  2. Nested commands existed since the very early releases.
  3. Breaking backwards compatibility is an inseparable part of software development, that is what major versions are for :)

@weierophinney
Copy link
Author

Yes, aware of major versions! (I'm a maintainer/project lead of a large OSS project myself!)

In this particular case, consumers are using the script I've created to perform automation tasks, so I'd love to have a forwards-compatible minor release before I bump to a new major and break BC. (This is something I've done in my OSS projects, and it's tended to make the transition to a new major far easier, which has led to fewer support headaches for myself and the other maintainers.)

Thanks for reviewing; curious to see if it's something you're willing or able to tackle! Glad to help test out if you decide to attempt it! (I'd try, but have almost zero Ruby experience!)

@DannyBen
Copy link
Owner

DannyBen commented Oct 12, 2022

Ok.

First of all, my major version comment was a joke :) - I am aware of both your credentials and the need to be backwards compatible wherever possible.

Also - thanks for the detailed ticket, however - I would like to suggest a simpler solution that will hopefully work for you.

The concept is this:

  1. We define both old and new methods, optionally using either YAML aliases or import to define it only once.
  2. The old method will be marked private so it can be executed, but not displayed in the --help text.
  3. The old method will have both a different filename and a different function name (using the function option - which does not yet exist).
  4. The file of the old function will
    • Print a deprecation message to stderr
    • Call the internal function that runs the new one.

Now, to explain the above with files:

# src/bashly.yml
name: cli

commands:
  - name: foo
    help: New foo
    commands:
    - &foo-list
      name: list
      help: New foo list
      flags:
      - long: --alpha
        help: Alpha flag
  
  # Deprecated
  - <<: *foo-list
    name: foo-list
    private: true
    filename: deprecated/foo_list_command.sh
    function: deprecated_foo_list  # this feature does not exist yet
    
# src/deprecated/foo_list_command.sh
echo "DEPRECATED: Use 'foo list' instead" >&2
cli_foo_list_command "$@"

and output:

$ ./cli foo-list
DEPRECATED: Use 'foo list' instead
This is the new foo list script
args: none

$ ./cli foo list
This is the new foo list script
args: none

$ ./cli
Usage:
  cli COMMAND
  cli [COMMAND] --help | -h
  cli --version | -v

Commands:
  foo   New foo

@DannyBen
Copy link
Owner

DannyBen commented Oct 12, 2022

Actually - the above suggestion will also allow you to create a separate help with this YAML:

name: cli

commands:
  - name: foo
    help: New foo
    commands:
    - &foo-list
      name: list
      help: New foo list
      flags:
      - long: --alpha
        help: Alpha flag
  
  # Deprecated
  - <<: *foo-list
    name: foo-list
    help: DEPRECATED - use 'foo list' instead
    private: true
    filename: deprecated/foo_list_command.sh
    function: deprecated_foo_list  # this feature does not exist yet

and I am also toying with the idea of wrapping the entire concept in one new directive:

  - name: foo-list
    deprecated: foo list

If you want, I can push a version that supports this function directive.
Are you using the docker or Ruby version of bashly?

@weierophinney
Copy link
Author

Love these approaches!

The two potential issues I see with using the function directive are:

  • if you were to change the command name, the function name would need to change
  • it requires that the person using bashly understand the normalization rules

The first is not a huge problem, and, honestly, both could be cases that documentation would solve.

I use the Docker version, but can also do the Ruby version for testing purposes.

@DannyBen
Copy link
Owner

if you were to change the command name, the function name would need to change

Not necessarily, and even if so, this is the niche case of the niche case. Normally, there should be no reason for anyone to use the function directive. It is just intended to allow edge cases like yours. I definitely consider this "the edge of the bashly universe".

it requires that the person using bashly understand the normalization rules

I am not following. This function name is internal. It will be suffixed by _command and _usage and other suffixes as needed.

@weierophinney
Copy link
Author

I am not following. This function name is internal. It will be suffixed by _command and _usage and other suffixes as needed.

I mean it requires that somebody writing a command in bashly understands how something like:

name: cli

commands:
- name: config
  commands:
  - name: set-default

translates to the functions cli_config_set_default_command and cli_config_set_default_usage; i.e., that spaces and punctuation in command and subcommand names get translated to underscores for purposes of generating command and usage functions. That's all I was getting at.

@DannyBen
Copy link
Owner

DannyBen commented Oct 12, 2022

I have just pushed a new branch, if you want to test this approach.
See the instructions on the edge page.

I have not published an edge release, so you will need to adjust any of the instructions there to pull from the add/command-function branch.

If using Ruby, make sure you have at least Ruby 2.7.

...and the beauty of it, is that the change cannot be any smaller

@weierophinney
Copy link
Author

Works brilliantly!

A few notes for the docs:

  • If you use this functionality, you MUST recreate any filters, arguments, and flags if you want bashly to validate and aggregate them in the same manner as the original command.
  • You CAN provide alternate help text, footers, etc. to differentiate the command.

These limitations work great for my purposes, and allow me to accomplish what I need to in terms of preparing a forwards compatible release.

Thanks for the quick turnaround!

@weierophinney
Copy link
Author

Oh, this is slick!

I discovered that I can have a script with front matter that omits the name attribute. Then in my bashly.yml:

name: cli

commands:
- name: config
  commands:
  - name: set-default
    import: src/config_set_default_command.sh

# Legacy commands
- name: config-set-default
  import: src/config_set_default_command.sh
  function: config_set_default_command
  footer: $(red "DEPRECATED Use config set-default")

The beauty of this is that I get to re-use the argument, flag, and filter configuration, and help text, but still draw attention to the deprecation. When I'm ready to cut the next major, I just remove all the items after # Legacy commands.

@weierophinney
Copy link
Author

Ran into my first issue.

name: cli

commands:
- name: utility
  commands:
  - name: exec
    import: src/utility_exec_command.sh

# Legacy commands
- name: exec
  import: src/utility_exec_command.sh
  function: utility_exec_command

and where I have a src/utility_exec_command.sh with YAML frontmatter containing the help, arguments, and flags, but no name element.

When I run bashly generate with this, it generates a src/exec_command.sh stub, even though I've indicated it should use a function.

@DannyBen
Copy link
Owner

Excellent.

Since the function directive is not necessarily designed for a deprecation situation, I will probably keep the docs simple, but add an example that shows a "real world" scenario, like deprecation.

I would love your opinion on the docs and example, once I complete them.

@DannyBen
Copy link
Owner

DannyBen commented Oct 12, 2022

Can you paste a minimal version of what you have in src/config_set_default_command.sh?
it will help my example building.

@DannyBen
Copy link
Owner

DannyBen commented Oct 12, 2022

When I run bashly generate with this, it generates a src/exec_command.sh stub, even though I've indicated it should use a function.

Specifying function does not change anything other the name of the internal function.
Like any other command, it must have a file, and you can use filename as I showed in my original example.

And do not use _command suffix in your function: utility_exec_command - it will be added internally.

I suggest you test with my initial minimal example, and review the generated script to understand what it does.

@DannyBen
Copy link
Owner

You might be able to get away with this:

# src/bashly.yml
name: cli

commands:
  - name: foo
    help: New foo
    commands:
    - &foo-list
      name: list
      help: New foo list
      flags:
      - long: --alpha
        help: Alpha flag
  
  # Deprecated
  - <<: *foo-list
    name: foo-list
    help: DEPRECATED
    private: true
    filename: foo_list_command.sh
    function: deprecated_foo_list

Notes:

  • Using the same filename as the non deprecated variant
  • Using a different function name

Although I still recommend my original concept - of allowing it to have its own file, and using this file to a) print deprecation and b) call the internal new function

@weierophinney
Copy link
Author

Wonderful - adding filename indeed worked. Your example also taught me about the private flag, which is perfect here as it means if they know the command and use it, it will work, but it will stop showing up in the lists.

I'd also not understood the & and * signifiers, so after trying your example, I looked those up in the YAML specification, and those help me simplify even more. Thanks for the great examples!

So yes,the combination of the new function directive, along with anchors and the private directive gives me exactly what I'm looking for! When I have a scenario where the associated filename will differ, I can use filename to override it and prevent generation of another file if desired (or not, and use that file to help print deprecation notices).

@DannyBen
Copy link
Owner

This is what I was hoping to hear. I will work on the docs and example.
Will make a new release later today or tomorrow.

Thanks for raising an interesting issue.

@DannyBen
Copy link
Owner

Added a laser-focused example for command.function

I avoided use of imports, YAML aliases and filenames in order to just clarify this one concept.

@weierophinney
Copy link
Author

Documentation examples look great, @DannyBen !

I'll likely write up a blog post about my use case once the feature is released and I've tested it with end users.

Thanks again!

@DannyBen
Copy link
Owner

Version 0.8.9 is released to both Rubygems and Docker Hub.

Thanks for raising this issue, I hope the solution works well for your use case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants